# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug # Copyright: (c) # Released under the AGPL-3.0 License. from django_redis import get_redis_connection from django.conf import settings from libs.utils import AttrDict, human_time, human_datetime, parse_time from apps.host.models import Host from apps.notify.models import Notify from apps.deploy.models import DeployRequest from concurrent import futures from datetime import datetime import requests import subprocess import json import uuid import os REPOS_DIR = settings.REPOS_DIR class SpugError(Exception): pass def deploy_dispatch(request, req, token): rds = get_redis_connection() try: api_token = uuid.uuid4().hex rds.setex(api_token, 60 * 60, f'{req.deploy.app_id},{req.deploy.env_id}') helper = Helper(rds, token, req.id) helper.send_step('local', 1, f'完成\r\n{human_time()} 发布准备... ') env = AttrDict( SPUG_APP_NAME=req.deploy.app.name, SPUG_APP_ID=str(req.deploy.app_id), SPUG_REQUEST_NAME=req.name, SPUG_DEPLOY_ID=str(req.deploy.id), SPUG_REQUEST_ID=str(req.id), SPUG_ENV_ID=str(req.deploy.env_id), SPUG_ENV_KEY=req.deploy.env.key, SPUG_VERSION=req.version, SPUG_DEPLOY_TYPE=req.type, SPUG_API_TOKEN=api_token, SPUG_REPOS_DIR=REPOS_DIR, ) if req.deploy.extend == '1': env.update(json.loads(req.deploy.extend_obj.custom_envs)) _ext1_deploy(req, helper, env) else: _ext2_deploy(req, helper, env) req.status = '3' except Exception as e: req.status = '-3' raise e finally: rds.expire(token, 5 * 60) rds.close() req.save() Helper.send_deploy_notify(req) def _ext1_deploy(req, helper, env): extend = req.deploy.extend_obj extras = json.loads(req.extra) env.update(SPUG_DST_DIR=extend.dst_dir) if extras[0] == 'branch': tree_ish = extras[2] env.update(SPUG_GIT_BRANCH=extras[1], SPUG_GIT_COMMIT_ID=extras[2]) else: tree_ish = extras[1] env.update(SPUG_GIT_TAG=extras[1]) if req.type == '2': helper.send_step('local', 6, f'完成\r\n{human_time()} 回滚发布... 跳过') else: helper.local(f'cd {REPOS_DIR} && rm -rf {req.deploy_id}_*') helper.send_step('local', 1, '完成\r\n') if extend.hook_pre_server: helper.send_step('local', 2, f'{human_time()} 检出前任务...\r\n') helper.local(f'cd /tmp && {extend.hook_pre_server}', env) helper.send_step('local', 3, f'{human_time()} 执行检出... ') git_dir = os.path.join(REPOS_DIR, str(req.deploy.id)) command = f'cd {git_dir} && git archive --prefix={env.SPUG_VERSION}/ {tree_ish} | (cd .. && tar xf -)' helper.local(command) helper.send_step('local', 3, '完成\r\n') if extend.hook_post_server: helper.send_step('local', 4, f'{human_time()} 检出后任务...\r\n') helper.local(f'cd {os.path.join(REPOS_DIR, env.SPUG_VERSION)} && {extend.hook_post_server}', env) helper.send_step('local', 5, f'\r\n{human_time()} 执行打包... ') filter_rule, exclude, contain = json.loads(extend.filter_rule), '', env.SPUG_VERSION files = helper.parse_filter_rule(filter_rule['data']) if files: if filter_rule['type'] == 'exclude': excludes = [] for x in files: if x.startswith('/'): excludes.append(f'--exclude={env.SPUG_VERSION}{x}') else: excludes.append(f'--exclude={x}') exclude = ' '.join(excludes) else: contain = ' '.join(f'{env.SPUG_VERSION}/{x}' for x in files) helper.local(f'cd {REPOS_DIR} && tar zcf {env.SPUG_VERSION}.tar.gz {exclude} {contain}') helper.send_step('local', 6, f'完成') threads, latest_exception = [], None with futures.ThreadPoolExecutor(max_workers=max(10, os.cpu_count() + 5)) as executor: for h_id in json.loads(req.host_ids): env = AttrDict(env.items()) t = executor.submit(_deploy_ext1_host, helper, h_id, extend, env) t.h_id = h_id threads.append(t) for t in futures.as_completed(threads): exception = t.exception() if exception: latest_exception = exception if not isinstance(exception, SpugError): helper.send_error(t.h_id, f'Exception: {exception}', False) if latest_exception: raise latest_exception def _ext2_deploy(req, helper, env): extend = req.deploy.extend_obj extras = json.loads(req.extra) host_actions = json.loads(extend.host_actions) server_actions = json.loads(extend.server_actions) if extras and extras[0]: env.update({'SPUG_RELEASE': extras[0]}) step = 2 for action in server_actions: helper.send_step('local', step, f'\r\n{human_time()} {action["title"]}...\r\n') helper.local(f'cd /tmp && {action["data"]}', env) step += 1 helper.send_step('local', 100, '完成\r\n' if step == 2 else '\r\n') tmp_transfer_file = None for action in host_actions: if action.get('type') == 'transfer': if action.get('src_mode') == '1': break helper.send_info('local', f'{human_time()} 检测到来源为本地路径的数据传输动作,执行打包... ') action['src'] = action['src'].rstrip('/ ') action['dst'] = action['dst'].rstrip('/ ') if not action['src'] or not action['dst']: helper.send_error('local', f'invalid path for transfer, src: {action["src"]} dst: {action["dst"]}') is_dir, exclude = os.path.isdir(action['src']), '' sp_dir, sd_dst = os.path.split(action['src']) contain = sd_dst if action['mode'] != '0' and is_dir: files = helper.parse_filter_rule(action['rule'], ',') if files: if action['mode'] == '1': contain = ' '.join(f'{sd_dst}/{x}' for x in files) else: excludes = [] for x in files: if x.startswith('/'): excludes.append(f'--exclude={sd_dst}{x}') else: excludes.append(f'--exclude={x}') exclude = ' '.join(excludes) tar_gz_file = f'{env.SPUG_VERSION}.tar.gz' helper.local(f'cd {sp_dir} && tar zcf {tar_gz_file} {exclude} {contain}') helper.send_info('local', '完成\r\n') tmp_transfer_file = os.path.join(sp_dir, tar_gz_file) break if host_actions: threads, latest_exception = [], None with futures.ThreadPoolExecutor(max_workers=max(10, os.cpu_count() + 5)) as executor: for h_id in json.loads(req.host_ids): env = AttrDict(env.items()) t = executor.submit(_deploy_ext2_host, helper, h_id, host_actions, env) t.h_id = h_id threads.append(t) for t in futures.as_completed(threads): exception = t.exception() if exception: latest_exception = exception if not isinstance(exception, SpugError): helper.send_error(t.h_id, f'Exception: {exception}', False) if tmp_transfer_file: os.remove(tmp_transfer_file) if latest_exception: raise latest_exception else: helper.send_step('local', 100, f'\r\n{human_time()} ** 发布成功 **') def _deploy_ext1_host(helper, h_id, extend, env): helper.send_step(h_id, 1, f'{human_time()} 数据准备... ') host = Host.objects.filter(pk=h_id).first() if not host: helper.send_error(h_id, 'no such host') env.update({'SPUG_HOST_ID': h_id, 'SPUG_HOST_NAME': host.hostname}) ssh = host.get_ssh() if env.SPUG_DEPLOY_TYPE != '2': code, _ = ssh.exec_command( f'mkdir -p {extend.dst_repo} && [ -e {extend.dst_dir} ] && [ ! -L {extend.dst_dir} ]') if code == 0: helper.send_error(host.id, f'检测到该主机的发布目录 {extend.dst_dir!r} 已存在,为了数据安全请自行备份后删除该目录,Spug 将会创建并接管该目录。') # clean clean_command = f'ls -d {extend.deploy_id}_* 2> /dev/null | sort -t _ -rnk2 | tail -n +{extend.versions + 1} | xargs rm -rf' helper.remote(host.id, ssh, f'cd {extend.dst_repo} && rm -rf {env.SPUG_VERSION} && {clean_command}') # transfer files tar_gz_file = f'{env.SPUG_VERSION}.tar.gz' try: ssh.put_file(os.path.join(REPOS_DIR, tar_gz_file), os.path.join(extend.dst_repo, tar_gz_file)) except Exception as e: helper.send_error(host.id, f'exception: {e}') command = f'cd {extend.dst_repo} && tar xf {tar_gz_file} && rm -f {env.SPUG_APP_ID}_*.tar.gz' helper.remote(host.id, ssh, command) helper.send_step(h_id, 1, '完成\r\n') # pre host repo_dir = os.path.join(extend.dst_repo, env.SPUG_VERSION) if extend.hook_pre_host: helper.send_step(h_id, 2, f'{human_time()} 发布前任务... \r\n') command = f'cd {repo_dir} ; {extend.hook_pre_host}' helper.remote(host.id, ssh, command, env) # do deploy helper.send_step(h_id, 3, f'{human_time()} 执行发布... ') helper.remote(host.id, ssh, f'rm -f {extend.dst_dir} && ln -sfn {repo_dir} {extend.dst_dir}') helper.send_step(h_id, 3, '完成\r\n') # post host if extend.hook_post_host: helper.send_step(h_id, 4, f'{human_time()} 发布后任务... \r\n') command = f'cd {extend.dst_dir} ; {extend.hook_post_host}' helper.remote(host.id, ssh, command, env) helper.send_step(h_id, 5, f'\r\n{human_time()} ** 发布成功 **') def _deploy_ext2_host(helper, h_id, actions, env): helper.send_step(h_id, 1, f'{human_time()} 数据准备... ') host = Host.objects.filter(pk=h_id).first() if not host: helper.send_error(h_id, 'no such host') env.update({'SPUG_HOST_ID': h_id, 'SPUG_HOST_NAME': host.hostname}) ssh = host.get_ssh() helper.send_step(h_id, 2, '完成\r\n') for index, action in enumerate(actions): helper.send_step(h_id, 2 + index, f'{human_time()} {action["title"]}...\r\n') if action.get('type') == 'transfer': if action.get('src_mode') == '1': try: ssh.put_file(os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, env.SPUG_VERSION), action['dst']) except Exception as e: helper.send_error(host.id, f'exception: {e}') helper.send_info(host.id, 'transfer completed\r\n') continue else: sp_dir, sd_dst = os.path.split(action['src']) tar_gz_file = f'{env.SPUG_VERSION}.tar.gz' try: ssh.put_file(os.path.join(sp_dir, tar_gz_file), f'/tmp/{tar_gz_file}') except Exception as e: helper.send_error(host.id, f'exception: {e}') command = f'cd /tmp && tar xf {tar_gz_file} && rm -f {tar_gz_file} ' command += f'&& rm -rf {action["dst"]} && mv /tmp/{sd_dst} {action["dst"]} && echo "transfer completed"' else: command = f'cd /tmp ; {action["data"]}' helper.remote(host.id, ssh, command, env) helper.send_step(h_id, 100, f'\r\n{human_time()} ** 发布成功 **') class Helper: def __init__(self, rds, token, r_id): self.rds = rds self.token = token self.log_key = f'{settings.REQUEST_KEY}:{r_id}' self.rds.delete(self.log_key) @classmethod def _make_dd_notify(cls, action, req, version, host_str): texts = [ f'**申请标题:** {req.name}', f'**应用名称:** {req.deploy.app.name}', f'**应用版本:** {version}', f'**发布环境:** {req.deploy.env.name}', f'**发布主机:** {host_str}', ] if action == 'approve_req': texts.insert(0, '## %s ## ' % '发布审核申请') texts.extend([ f'**申请人员:** {req.created_by.nickname}', f'**申请时间:** {human_datetime()}', '> 来自 Spug运维平台' ]) elif action == 'approve_rst': color, text = ('#008000', '通过') if req.status == '1' else ('#f90202', '驳回') texts.insert(0, '## %s ## ' % '发布审核结果') texts.extend([ f'**审核人员:** {req.approve_by.nickname}', f'**审核结果:** {text}', f'**审核意见:** {req.reason or ""}', f'**审核时间:** {human_datetime()}', '> 来自 Spug运维平台' ]) else: color, text = ('#008000', '成功') if req.status == '3' else ('#f90202', '失败') texts.insert(0, '## %s ## ' % '发布结果通知') if req.approve_at: texts.append(f'**审核人员:** {req.approve_by.nickname}') texts.extend([ f'**执行人员:** {req.do_by.nickname}', f'**发布结果:** {text}', f'**发布时间:** {human_datetime()}', '> 来自 Spug运维平台' ]) return { 'msgtype': 'markdown', 'markdown': { 'title': 'Spug 发布消息通知', 'text': '\n\n'.join(texts) } } @classmethod def _make_wx_notify(cls, action, req, version, host_str): texts = [ f'申请标题: {req.name}', f'应用名称: {req.deploy.app.name}', f'应用版本: {version}', f'发布环境: {req.deploy.env.name}', f'发布主机: {host_str}', ] if action == 'approve_req': texts.insert(0, '## %s' % '发布审核申请') texts.extend([ f'申请人员: {req.created_by.nickname}', f'申请时间: {human_datetime()}', '> 来自 Spug运维平台' ]) elif action == 'approve_rst': color, text = ('info', '通过') if req.status == '1' else ('warning', '驳回') texts.insert(0, '## %s' % '发布审核结果') texts.extend([ f'审核人员: {req.approve_by.nickname}', f'审核结果: {text}', f'审核意见: {req.reason or ""}', f'审核时间: {human_datetime()}', '> 来自 Spug运维平台' ]) else: color, text = ('info', '成功') if req.status == '3' else ('warning', '失败') texts.insert(0, '## %s' % '发布结果通知') if req.approve_at: texts.append(f'审核人员: {req.approve_by.nickname}') texts.extend([ f'执行人员: {req.do_by.nickname}', f'发布结果: {text}', f'发布时间: {human_datetime()}', '> 来自 Spug运维平台' ]) return { 'msgtype': 'markdown', 'markdown': { 'content': '\n'.join(texts) } } @classmethod def send_deploy_notify(cls, req, action=None): rst_notify = json.loads(req.deploy.rst_notify) host_ids = json.loads(req.host_ids) if rst_notify['mode'] != '0' and rst_notify.get('value'): extra = json.loads(req.extra) if req.deploy.extend == '1': mode, extra1, extra2 = extra if mode == 'branch': version = f'{extra1}#{extra2[:6]}' else: version = extra1 else: version = extra[0] or '' hosts = [{'id': x.id, 'name': x.name} for x in Host.objects.filter(id__in=host_ids)] host_str = ', '.join(x['name'] for x in hosts[:2]) if len(hosts) > 2: host_str += f'等{len(hosts)}台主机' if rst_notify['mode'] == '1': data = cls._make_dd_notify(action, req, version, host_str) elif rst_notify['mode'] == '2': data = { 'action': action, 'req_id': req.id, 'req_name': req.name, 'app_id': req.deploy.app_id, 'app_name': req.deploy.app.name, 'env_id': req.deploy.env_id, 'env_name': req.deploy.env.name, 'status': req.status, 'reason': req.reason, 'version': version, 'targets': hosts, 'is_success': req.status == '3', 'created_at': human_datetime() } elif rst_notify['mode'] == '3': data = cls._make_wx_notify(action, req, version, host_str) else: raise NotImplementedError res = requests.post(rst_notify['value'], json=data) if res.status_code != 200: Notify.make_notify('flag', '1', '发布通知发送失败', f'返回状态码:{res.status_code}, 请求URL:{res.url}') if rst_notify['mode'] in ['1', '3']: res = res.json() if res.get('errcode') != 0: Notify.make_notify('flag', '1', '发布通知发送失败', f'返回数据:{res}') def parse_filter_rule(self, data: str, sep='\n'): data, files = data.strip(), [] if data: for line in data.split(sep): line = line.strip() if line and not line.startswith('#'): files.append(line) return files def _send(self, message): self.rds.lpush(self.token, json.dumps(message)) self.rds.lpush(self.log_key, json.dumps(message)) def send_info(self, key, message): self._send({'key': key, 'status': 'info', 'data': message}) def send_error(self, key, message, with_break=True): message = '\r\n' + message self._send({'key': key, 'status': 'error', 'data': message}) if with_break: raise SpugError def send_step(self, key, step, data): self._send({'key': key, 'step': step, 'data': data}) def local(self, command, env=None): if env: env = dict(env.items()) env.update(os.environ) command = 'set -e\n' + command task = subprocess.Popen(command, env=env, shell=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) while True: message = task.stdout.readline() if not message: break self.send_info('local', message.decode()) if task.wait() != 0: self.send_error('local', f'exit code: {task.returncode}') def remote(self, key, ssh, command, env=None): code = -1 for code, out in ssh.exec_command_with_stream(command, environment=env): self.send_info(key, out) if code != 0: self.send_error(key, f'exit code: {code}') def auto_update_status(): now = datetime.now() for req in DeployRequest.objects.filter(status='2'): if (now - parse_time(req.do_at)).seconds > 3600: req.status = '-3' req.save()