diff --git a/spug_api/apps/deploy/ext1.py b/spug_api/apps/deploy/ext1.py new file mode 100644 index 0000000..41f02b7 --- /dev/null +++ b/spug_api/apps/deploy/ext1.py @@ -0,0 +1,138 @@ +# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug +# Copyright: (c) +# Released under the AGPL-3.0 License. +from django.conf import settings +from libs.utils import AttrDict, render_str, human_seconds_time +from apps.host.models import Host +from apps.repository.models import Repository +from apps.repository.utils import dispatch as build_repository +from apps.deploy.helper import SpugError +from concurrent import futures +import json +import time +import os + +BUILD_DIR = settings.BUILD_DIR + + +def ext1_deploy(req, helper, env): + if not req.repository_id: + rep = Repository( + app_id=req.deploy.app_id, + env_id=req.deploy.env_id, + deploy_id=req.deploy_id, + version=req.version, + spug_version=req.spug_version, + extra=req.extra, + remarks='SPUG AUTO MAKE', + created_by_id=req.created_by_id + ) + build_repository(rep, helper) + req.repository = rep + extras = json.loads(req.extra) + if extras[0] == 'repository': + extras = extras[1:] + if extras[0] == 'branch': + env.update(SPUG_GIT_BRANCH=extras[1], SPUG_GIT_COMMIT_ID=extras[2]) + else: + env.update(SPUG_GIT_TAG=extras[1]) + if req.deploy.is_parallel: + threads, latest_exception = [], None + max_workers = max(10, os.cpu_count() * 5) + with futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + for h_id in helper.deploy_host_ids: + new_env = AttrDict(env.items()) + t = executor.submit(_deploy_ext1_host, req, helper, h_id, new_env) + t.h_id = h_id + threads.append(t) + for t in futures.as_completed(threads): + exception = t.exception() + if exception: + helper.set_deploy_fail(t.h_id) + latest_exception = exception + if not isinstance(exception, SpugError): + helper.send_error(t.h_id, f'Exception: {exception}', with_break=False) + else: + helper.set_deploy_success(t.h_id) + if latest_exception: + raise latest_exception + else: + host_ids = sorted(helper.deploy_host_ids, reverse=True) + while host_ids: + h_id = host_ids.pop() + new_env = AttrDict(env.items()) + try: + _deploy_ext1_host(req, helper, h_id, new_env) + helper.set_deploy_success(h_id) + except Exception as e: + helper.set_deploy_fail(h_id) + helper.send_error(h_id, f'Exception: {e}', with_break=False) + for h_id in host_ids: + helper.set_deploy_fail(h_id) + helper.send_error(h_id, '终止发布', with_break=False) + raise e + + +def _deploy_ext1_host(req, helper, h_id, env): + flag = time.time() + helper.set_deploy_process(h_id) + helper.send_clear(h_id) + helper.send_info(h_id, '数据准备... ', status='doing') + 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}) + extend = req.deploy.extend_obj + extend.dst_dir = render_str(extend.dst_dir, env) + extend.dst_repo = render_str(extend.dst_repo, env) + env.update(SPUG_DST_DIR=extend.dst_dir) + with host.get_ssh(default_env=env) as ssh: + helper.save_pid(ssh.get_pid(), h_id) + base_dst_dir = os.path.dirname(extend.dst_dir) + code, _ = ssh.exec_command_raw( + f'mkdir -p {extend.dst_repo} {base_dst_dir} && [ -e {extend.dst_dir} ] && [ ! -L {extend.dst_dir} ]') + if code == 0: + helper.send_error(host.id, + f'\r\n检测到该主机的发布目录 {extend.dst_dir!r} 已存在,为了数据安全请自行备份后删除该目录,Spug 将会创建并接管该目录。') + if req.type == '2': + helper.send_warn(h_id, '跳过√\r\n') + else: + # 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_raw(host.id, ssh, f'cd {extend.dst_repo} && {clean_command}') + # transfer files + tar_gz_file = f'{req.spug_version}.tar.gz' + try: + callback = helper.progress_callback(host.id) + ssh.put_file( + os.path.join(BUILD_DIR, tar_gz_file), + os.path.join(extend.dst_repo, tar_gz_file), + callback + ) + except Exception as e: + helper.send_error(host.id, f'Exception: {e}') + + command = f'cd {extend.dst_repo} && rm -rf {req.spug_version} && tar xf {tar_gz_file} && rm -f {req.deploy_id}_*.tar.gz' + helper.remote_raw(host.id, ssh, command) + helper.send_success(h_id, '完成√\r\n') + + # pre host + repo_dir = os.path.join(extend.dst_repo, req.spug_version) + if extend.hook_pre_host: + helper.send_info(h_id, '发布前任务... \r\n') + command = f'cd {repo_dir} && {extend.hook_pre_host}' + helper.remote(host.id, ssh, command) + + # do deploy + helper.send_info(h_id, '执行发布... ') + helper.remote_raw(host.id, ssh, f'rm -f {extend.dst_dir} && ln -sfn {repo_dir} {extend.dst_dir}') + helper.send_success(h_id, '完成√\r\n') + + # post host + if extend.hook_post_host: + helper.send_info(h_id, '发布后任务... \r\n') + command = f'cd {extend.dst_dir} && {extend.hook_post_host}' + helper.remote(host.id, ssh, command) + + human_time = human_seconds_time(time.time() - flag) + helper.send_success(h_id, f'\r\n** 发布成功,耗时:{human_time} **', status='success') diff --git a/spug_api/apps/deploy/ext2.py b/spug_api/apps/deploy/ext2.py new file mode 100644 index 0000000..95f3b1b --- /dev/null +++ b/spug_api/apps/deploy/ext2.py @@ -0,0 +1,170 @@ +# Copyright: (c) OpenSpug Organization. https://github.com/openspug/spug +# Copyright: (c) +# Released under the AGPL-3.0 License. +from django.conf import settings +from libs.utils import AttrDict, render_str, human_seconds_time +from apps.host.models import Host +from apps.deploy.helper import SpugError +from concurrent import futures +import json +import time +import os + +REPOS_DIR = settings.REPOS_DIR + + +def ext2_deploy(req, helper, env, with_local): + flag = time.time() + extend, step = req.deploy.extend_obj, 1 + host_actions = json.loads(extend.host_actions) + server_actions = json.loads(extend.server_actions) + env.update({'SPUG_RELEASE': req.version}) + if req.version: + for index, value in enumerate(req.version.split()): + env.update({f'SPUG_RELEASE_{index + 1}': value}) + + transfer_action = None + for action in host_actions: + if action.get('type') == 'transfer': + action['src'] = render_str(action.get('src', '').strip().rstrip('/'), env) + action['dst'] = render_str(action['dst'].strip().rstrip('/'), env) + if action.get('src_mode') == '1': # upload when publish + if not req.extra: + helper.send_error('local', '\r\n未找到上传的文件信息,请尝试新建发布申请') + extra = json.loads(req.extra) + if 'name' in extra: + action['name'] = extra['name'] + else: + transfer_action = action + break + + if with_local: + helper.set_deploy_process('local') + helper.send_success('local', '', status='doing') + if server_actions or transfer_action: + helper.send_clear('local') + for action in server_actions: + helper.send_info('local', f'{action["title"]}...\r\n') + helper.local(f'cd /tmp && {action["data"]}', env) + step += 1 + if transfer_action: + action = transfer_action + helper.send_info('local', '检测到来源为本地路径的数据传输动作,执行打包... \r\n') + 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"]}') + if not os.path.exists(action['src']): + helper.send_error('local', f'No such file or directory: {action["src"]}') + 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'], ',', env) + 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 = os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, f'{req.spug_version}.tar.gz') + helper.local(f'cd {sp_dir} && tar -zcf {tar_gz_file} {exclude} {contain}') + helper.send_info('local', '打包完成\r\n') + helper.set_deploy_success('local') + human_time = human_seconds_time(time.time() - flag) + helper.send_success('local', f'\r\n** 执行完成,耗时:{human_time} **', status='success') + + if host_actions: + if req.deploy.is_parallel: + threads, latest_exception = [], None + max_workers = max(10, os.cpu_count() * 5) + with futures.ThreadPoolExecutor(max_workers=max_workers) as executor: + for h_id in sorted(helper.deploy_host_ids, reverse=True): + new_env = AttrDict(env.items()) + t = executor.submit(_deploy_ext2_host, helper, h_id, host_actions, new_env, req.spug_version) + t.h_id = h_id + threads.append(t) + for t in futures.as_completed(threads): + exception = t.exception() + if exception: + helper.set_deploy_fail(t.h_id) + latest_exception = exception + if not isinstance(exception, SpugError): + helper.send_error(t.h_id, f'Exception: {exception}', with_break=False) + else: + helper.set_deploy_success(t.h_id) + if latest_exception: + raise latest_exception + else: + host_ids = sorted(helper.deploy_host_ids) + while host_ids: + h_id = host_ids.pop() + new_env = AttrDict(env.items()) + try: + _deploy_ext2_host(helper, h_id, host_actions, new_env, req.spug_version) + helper.set_deploy_success(h_id) + except Exception as e: + helper.set_deploy_fail(h_id) + if not isinstance(e, SpugError): + helper.send_error(h_id, f'Exception: {e}', with_break=False) + for h_id in host_ids: + helper.set_deploy_fail(h_id) + helper.send_clear(h_id) + helper.send_error(h_id, '串行模式,终止发布', with_break=False) + raise e + + +def _deploy_ext2_host(helper, h_id, actions, env, spug_version): + flag = time.time() + helper.set_deploy_process(h_id) + 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}) + with host.get_ssh(default_env=env) as ssh: + helper.send_clear(h_id) + helper.save_pid(ssh.get_pid(), h_id) + helper.send_success(h_id, '', status='doing') + for index, action in enumerate(actions, start=1): + if action.get('type') == 'transfer': + helper.send_info(h_id, f'{action["title"]}...') + if action.get('src_mode') == '1': + try: + dst = action['dst'] + command = f'[ -e {dst} ] || mkdir -p $(dirname {dst}); [ -d {dst} ]' + code, _ = ssh.exec_command_raw(command) + if code == 0: # is dir + if not action.get('name'): + raise RuntimeError('internal error 1002') + dst = dst.rstrip('/') + '/' + action['name'] + callback = helper.progress_callback(host.id) + ssh.put_file(os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, spug_version), dst, callback) + except Exception as e: + helper.send_error(host.id, f'\r\nException: {e}') + helper.send_success(host.id, '完成√\r\n') + else: + _, sd_dst = os.path.split(action['src']) + tar_gz_file = f'{spug_version}.tar.gz' + src_file = os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, tar_gz_file) + try: + callback = helper.progress_callback(host.id) + ssh.put_file(src_file, f'/tmp/{tar_gz_file}', callback) + except Exception as e: + helper.send_error(host.id, f'\r\nException: {e}') + helper.send_success(host.id, '完成√\r\n') + command = f'mkdir -p /tmp/{spug_version} ' + command += f'&& tar xf /tmp/{tar_gz_file} -C /tmp/{spug_version}/ 2> /dev/null ' + command += f'&& rm -rf {action["dst"]} && mv /tmp/{spug_version}/{sd_dst} {action["dst"]} ' + command += f'&& rm -rf /tmp/{spug_version}*' + helper.remote(host.id, ssh, command) + else: + helper.send_info(h_id, f'{action["title"]}...\r\n') + command = f'cd /tmp && {action["data"]}' + helper.remote(host.id, ssh, command) + human_time = human_seconds_time(time.time() - flag) + helper.send_success(h_id, f'\r\n** 发布成功,耗时:{human_time} **', status='success') diff --git a/spug_api/apps/deploy/helper.py b/spug_api/apps/deploy/helper.py index f7a8297..c911cac 100644 --- a/spug_api/apps/deploy/helper.py +++ b/spug_api/apps/deploy/helper.py @@ -231,6 +231,8 @@ class Helper(NotifyMixin, KitMixin): self.callback = [] self.buffers = defaultdict(str) self.flags = defaultdict(bool) + self.deploy_status = {} + self.deploy_host_ids = [] self.files = {} self.already_clear = False @@ -242,6 +244,9 @@ class Helper(NotifyMixin, KitMixin): rds.delete(rds_key) instance = cls(rds, rds_key) for key in keys: + if key != 'local': + instance.deploy_host_ids.append(key) + instance.deploy_status[key] = '0' instance.get_file(key) return instance @@ -282,6 +287,15 @@ class Helper(NotifyMixin, KitMixin): line = f.readline() return counter + def set_deploy_process(self, key): + self.deploy_status[key] = '1' + + def set_deploy_success(self, key): + self.deploy_status[key] = '2' + + def set_deploy_fail(self, key): + self.deploy_status[key] = '3' + def get_file(self, key): if key in self.files: return self.files[key] diff --git a/spug_api/apps/deploy/models.py b/spug_api/apps/deploy/models.py index 1d95937..521f77b 100644 --- a/spug_api/apps/deploy/models.py +++ b/spug_api/apps/deploy/models.py @@ -19,6 +19,7 @@ class DeployRequest(models.Model, ModelMixin): ('1', '待发布'), ('2', '发布中'), ('3', '发布成功'), + ('4', '灰度成功'), ) TYPES = ( ('1', '正常发布'), @@ -37,8 +38,7 @@ class DeployRequest(models.Model, ModelMixin): version = models.CharField(max_length=100, null=True) spug_version = models.CharField(max_length=50, null=True) plan = models.DateTimeField(null=True) - fail_host_ids = models.TextField(default='[]') - + deploy_status = models.TextField(default='{}') created_at = models.CharField(max_length=20, default=human_datetime) created_by = models.ForeignKey(User, models.PROTECT, related_name='+') approve_at = models.CharField(max_length=20, null=True) @@ -67,6 +67,7 @@ class DeployRequest(models.Model, ModelMixin): os.remove(os.path.join(settings.REPOS_DIR, str(self.deploy_id), self.spug_version)) except FileNotFoundError: pass + #TODO: 清理日志文件, 删除自定义发布tar.gz文件 def __repr__(self): return f'' diff --git a/spug_api/apps/deploy/utils.py b/spug_api/apps/deploy/utils.py index 9473fb8..6f85ad3 100644 --- a/spug_api/apps/deploy/utils.py +++ b/spug_api/apps/deploy/utils.py @@ -4,33 +4,22 @@ from django_redis import get_redis_connection from django.conf import settings from django.db import close_old_connections -from libs.utils import AttrDict, render_str, human_seconds_time -from apps.host.models import Host +from libs.utils import AttrDict from apps.config.utils import compose_configs -from apps.repository.models import Repository -from apps.repository.utils import dispatch as build_repository from apps.deploy.models import DeployRequest from apps.deploy.helper import Helper, SpugError -from concurrent import futures -from functools import partial +from apps.deploy.ext1 import ext1_deploy +from apps.deploy.ext2 import ext2_deploy import json import uuid -import time -import os REPOS_DIR = settings.REPOS_DIR -BUILD_DIR = settings.BUILD_DIR -def dispatch(req, fail_mode=False): +def dispatch(req, deploy_host_ids, with_local): rds = get_redis_connection() rds_key = req.deploy_key - if fail_mode: - req.host_ids = req.fail_host_ids - req.fail_mode = fail_mode - req.host_ids = json.loads(req.host_ids) - req.fail_host_ids = req.host_ids[:] - keys = req.host_ids if fail_mode else req.host_ids + ['local'] + keys = deploy_host_ids + ['local'] if with_local else deploy_host_ids helper = Helper.make(rds, rds_key, keys) try: @@ -57,9 +46,9 @@ def dispatch(req, fail_mode=False): env.update(configs_env) if req.deploy.extend == '1': - _ext1_deploy(req, helper, env) + ext1_deploy(req, helper, env) else: - _ext2_deploy(req, helper, env) + ext2_deploy(req, helper, env, with_local) req.status = '3' except Exception as e: req.status = '-3' @@ -67,283 +56,19 @@ def dispatch(req, fail_mode=False): raise e finally: close_old_connections() - DeployRequest.objects.filter(pk=req.id).update( - status=req.status, - repository=req.repository, - fail_host_ids=json.dumps(req.fail_host_ids), - ) + request = DeployRequest.objects.get(pk=req.id) + deploy_status = json.loads(request.deploy_status) + deploy_status.update({str(k): v for k, v in helper.deploy_status.items()}) + values = [v for k, v in deploy_status.items() if k != 'local'] + if all([x == '2' for x in values]): + if len(values) == len(json.loads(request.host_ids)): + request.status = '3' + else: + request.status = '4' + else: + request.status = '-3' + request.repository = req.repository + request.deploy_status = json.dumps(deploy_status) + request.save() helper.clear() Helper.send_deploy_notify(req) - - -def _ext1_deploy(req, helper, env): - if not req.repository_id: - rep = Repository( - app_id=req.deploy.app_id, - env_id=req.deploy.env_id, - deploy_id=req.deploy_id, - version=req.version, - spug_version=req.spug_version, - extra=req.extra, - remarks='SPUG AUTO MAKE', - created_by_id=req.created_by_id - ) - build_repository(rep, helper) - req.repository = rep - extras = json.loads(req.extra) - if extras[0] == 'repository': - extras = extras[1:] - if extras[0] == 'branch': - env.update(SPUG_GIT_BRANCH=extras[1], SPUG_GIT_COMMIT_ID=extras[2]) - else: - env.update(SPUG_GIT_TAG=extras[1]) - if req.deploy.is_parallel: - threads, latest_exception = [], None - max_workers = max(10, os.cpu_count() * 5) - with futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - for h_id in req.host_ids: - new_env = AttrDict(env.items()) - t = executor.submit(_deploy_ext1_host, req, helper, h_id, new_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}', with_break=False) - else: - req.fail_host_ids.remove(t.h_id) - if latest_exception: - raise latest_exception - else: - host_ids = sorted(req.host_ids, reverse=True) - while host_ids: - h_id = host_ids.pop() - new_env = AttrDict(env.items()) - try: - _deploy_ext1_host(req, helper, h_id, new_env) - req.fail_host_ids.remove(h_id) - except Exception as e: - helper.send_error(h_id, f'Exception: {e}', with_break=False) - for h_id in host_ids: - helper.send_error(h_id, '终止发布', with_break=False) - raise e - - -def _ext2_deploy(req, helper, env): - flag = time.time() - extend, step = req.deploy.extend_obj, 1 - host_actions = json.loads(extend.host_actions) - server_actions = json.loads(extend.server_actions) - env.update({'SPUG_RELEASE': req.version}) - if req.version: - for index, value in enumerate(req.version.split()): - env.update({f'SPUG_RELEASE_{index + 1}': value}) - - transfer_action = None - for action in host_actions: - if action.get('type') == 'transfer': - action['src'] = render_str(action.get('src', '').strip().rstrip('/'), env) - action['dst'] = render_str(action['dst'].strip().rstrip('/'), env) - if action.get('src_mode') == '1': # upload when publish - if not req.extra: - helper.send_error('local', '\r\n未找到上传的文件信息,请尝试新建发布申请') - extra = json.loads(req.extra) - if 'name' in extra: - action['name'] = extra['name'] - else: - transfer_action = action - break - - if not req.fail_mode: - helper.send_success('local', '', status='doing') - if server_actions or transfer_action: - helper.send_clear('local') - for action in server_actions: - helper.send_info('local', f'{action["title"]}...\r\n') - helper.local(f'cd /tmp && {action["data"]}', env) - step += 1 - if transfer_action: - action = transfer_action - helper.send_info('local', '检测到来源为本地路径的数据传输动作,执行打包... \r\n') - 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"]}') - if not os.path.exists(action['src']): - helper.send_error('local', f'No such file or directory: {action["src"]}') - 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'], ',', env) - 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'{req.spug_version}.tar.gz' - helper.local(f'cd {sp_dir} && tar -zcf {tar_gz_file} {exclude} {contain}') - helper.send_info('local', '打包完成\r\n') - helper.add_callback(partial(os.remove, os.path.join(sp_dir, tar_gz_file))) - - human_time = human_seconds_time(time.time() - flag) - if host_actions: - helper.send_success('local', f'\r\n** 执行完成,耗时:{human_time} **', status='success') - if req.deploy.is_parallel: - threads, latest_exception = [], None - max_workers = max(10, os.cpu_count() * 5) - with futures.ThreadPoolExecutor(max_workers=max_workers) as executor: - for h_id in sorted(req.host_ids, reverse=True): - new_env = AttrDict(env.items()) - t = executor.submit(_deploy_ext2_host, helper, h_id, host_actions, new_env, req.spug_version) - 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}', with_break=False) - else: - req.fail_host_ids.remove(t.h_id) - if latest_exception: - raise latest_exception - else: - host_ids = sorted(req.host_ids) - while host_ids: - h_id = host_ids.pop() - new_env = AttrDict(env.items()) - try: - _deploy_ext2_host(helper, h_id, host_actions, new_env, req.spug_version) - req.fail_host_ids.remove(h_id) - except Exception as e: - if not isinstance(e, SpugError): - helper.send_error(h_id, f'Exception: {e}', with_break=False) - for h_id in host_ids: - helper.send_clear(h_id) - helper.send_error(h_id, '串行模式,终止发布', with_break=False) - raise e - else: - req.fail_host_ids = [] - helper.send_success('local', f'\r\n** 发布成功,耗时:{human_time} **', status='success') - - -def _deploy_ext1_host(req, helper, h_id, env): - flag = time.time() - helper.send_clear(h_id) - helper.send_info(h_id, '数据准备... ', status='doing') - 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}) - extend = req.deploy.extend_obj - extend.dst_dir = render_str(extend.dst_dir, env) - extend.dst_repo = render_str(extend.dst_repo, env) - env.update(SPUG_DST_DIR=extend.dst_dir) - with host.get_ssh(default_env=env) as ssh: - helper.save_pid(ssh.get_pid(), h_id) - base_dst_dir = os.path.dirname(extend.dst_dir) - code, _ = ssh.exec_command_raw( - f'mkdir -p {extend.dst_repo} {base_dst_dir} && [ -e {extend.dst_dir} ] && [ ! -L {extend.dst_dir} ]') - if code == 0: - helper.send_error(host.id, - f'\r\n检测到该主机的发布目录 {extend.dst_dir!r} 已存在,为了数据安全请自行备份后删除该目录,Spug 将会创建并接管该目录。') - if req.type == '2': - helper.send_warn(h_id, '跳过√\r\n') - else: - # 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_raw(host.id, ssh, f'cd {extend.dst_repo} && {clean_command}') - # transfer files - tar_gz_file = f'{req.spug_version}.tar.gz' - try: - callback = helper.progress_callback(host.id) - ssh.put_file( - os.path.join(BUILD_DIR, tar_gz_file), - os.path.join(extend.dst_repo, tar_gz_file), - callback - ) - except Exception as e: - helper.send_error(host.id, f'Exception: {e}') - - command = f'cd {extend.dst_repo} && rm -rf {req.spug_version} && tar xf {tar_gz_file} && rm -f {req.deploy_id}_*.tar.gz' - helper.remote_raw(host.id, ssh, command) - helper.send_success(h_id, '完成√\r\n') - - # pre host - repo_dir = os.path.join(extend.dst_repo, req.spug_version) - if extend.hook_pre_host: - helper.send_info(h_id, '发布前任务... \r\n') - command = f'cd {repo_dir} && {extend.hook_pre_host}' - helper.remote(host.id, ssh, command) - - # do deploy - helper.send_info(h_id, '执行发布... ') - helper.remote_raw(host.id, ssh, f'rm -f {extend.dst_dir} && ln -sfn {repo_dir} {extend.dst_dir}') - helper.send_success(h_id, '完成√\r\n') - - # post host - if extend.hook_post_host: - helper.send_info(h_id, '发布后任务... \r\n') - command = f'cd {extend.dst_dir} && {extend.hook_post_host}' - helper.remote(host.id, ssh, command) - - human_time = human_seconds_time(time.time() - flag) - helper.send_success(h_id, f'\r\n** 发布成功,耗时:{human_time} **', status='success') - - -def _deploy_ext2_host(helper, h_id, actions, env, spug_version): - flag = time.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}) - with host.get_ssh(default_env=env) as ssh: - helper.send_clear(h_id) - helper.save_pid(ssh.get_pid(), h_id) - helper.send_success(h_id, '', status='doing') - for index, action in enumerate(actions, start=1): - if action.get('type') == 'transfer': - helper.send_info(h_id, f'{action["title"]}...') - if action.get('src_mode') == '1': - try: - dst = action['dst'] - command = f'[ -e {dst} ] || mkdir -p $(dirname {dst}); [ -d {dst} ]' - code, _ = ssh.exec_command_raw(command) - if code == 0: # is dir - if not action.get('name'): - raise RuntimeError('internal error 1002') - dst = dst.rstrip('/') + '/' + action['name'] - callback = helper.progress_callback(host.id) - ssh.put_file(os.path.join(REPOS_DIR, env.SPUG_DEPLOY_ID, spug_version), dst, callback) - except Exception as e: - helper.send_error(host.id, f'\r\nException: {e}') - helper.send_success(host.id, '完成√\r\n') - continue - else: - sp_dir, sd_dst = os.path.split(action['src']) - tar_gz_file = f'{spug_version}.tar.gz' - try: - callback = helper.progress_callback(host.id) - ssh.put_file(os.path.join(sp_dir, tar_gz_file), f'/tmp/{tar_gz_file}', callback) - except Exception as e: - helper.send_error(host.id, f'\r\nException: {e}') - - command = f'mkdir -p /tmp/{spug_version} ' - command += f'&& tar xf /tmp/{tar_gz_file} -C /tmp/{spug_version}/ 2> /dev/null ' - command += f'&& rm -rf {action["dst"]} && mv /tmp/{spug_version}/{sd_dst} {action["dst"]} ' - command += f'&& rm -rf /tmp/{spug_version}* && echo "\033[32m完成√\033[0m"' - else: - helper.send_info(h_id, f'{action["title"]}...\r\n') - command = f'cd /tmp && {action["data"]}' - helper.remote(host.id, ssh, command) - human_time = human_seconds_time(time.time() - flag) - helper.send_success(h_id, f'\r\n** 发布成功,耗时:{human_time} **', status='success') diff --git a/spug_api/apps/deploy/views.py b/spug_api/apps/deploy/views.py index 80dfc61..10f721a 100644 --- a/spug_api/apps/deploy/views.py +++ b/spug_api/apps/deploy/views.py @@ -5,7 +5,6 @@ from django.views.generic import View from django.db.models import F from django.conf import settings from django.http.response import HttpResponseBadRequest -from django_redis import get_redis_connection from libs import json_response, JsonParser, Argument, human_datetime, human_time, auth, AttrDict from apps.deploy.models import DeployRequest from apps.app.models import Deploy, DeployExtend2 @@ -46,7 +45,7 @@ class RequestView(View): tmp['app_name'] = item.app_name tmp['app_extend'] = item.app_extend tmp['host_ids'] = json.loads(item.host_ids) - tmp['fail_host_ids'] = json.loads(item.fail_host_ids) + tmp['deploy_status'] = json.loads(item.deploy_status) tmp['extra'] = json.loads(item.extra) if item.extra else None tmp['rep_extra'] = json.loads(item.rep_extra) if item.rep_extra else None tmp['app_host_ids'] = json.loads(item.app_host_ids) @@ -125,49 +124,72 @@ class RequestDetailView(View): @auth('deploy.request.do') def post(self, request, r_id): - form, _ = JsonParser(Argument('mode', default='all')).parse(request.body) - query, is_fail_mode = {'pk': r_id}, form.mode == 'fail' - if not request.user.is_supper: - perms = request.user.deploy_perms - query['deploy__app_id__in'] = perms['apps'] - query['deploy__env_id__in'] = perms['envs'] - req = DeployRequest.objects.filter(**query).first() - if not req: - return json_response(error='未找到指定发布申请') - if req.status not in ('1', '-3'): - return json_response(error='该申请单当前状态还不能执行发布') - host_ids = req.fail_host_ids if is_fail_mode else req.host_ids + form, error = JsonParser( + Argument('mode', filter=lambda x: x in ('fail', 'gray', 'all'), help='参数错误'), + Argument('host_ids', type=list, required=False) + ).parse(request.body) + if error is None: + query, is_fail_mode = {'pk': r_id}, form.mode == 'fail' + if not request.user.is_supper: + perms = request.user.deploy_perms + query['deploy__app_id__in'] = perms['apps'] + query['deploy__env_id__in'] = perms['envs'] + req = DeployRequest.objects.filter(**query).first() + if not req: + return json_response(error='未找到指定发布申请') + if req.status not in ('1', '-3', '4'): + return json_response(error='该申请单当前状态还不能执行发布') - req.status = '2' - req.do_at = human_datetime() - req.do_by = request.user - req.save() - Thread(target=dispatch, args=(req, is_fail_mode)).start() - - hosts = Host.objects.filter(id__in=json.loads(host_ids)) - message = Helper.term_message('等待调度... ') - outputs = {x.id: {'id': x.id, 'title': x.name, 'data': message} for x in hosts} - if req.is_quick_deploy: - if req.repository_id: - outputs['local'] = { - 'id': 'local', - 'status': 'success', - 'data': Helper.term_message('已构建完成忽略执行', 'warn') - } + deploy_status = json.loads(req.deploy_status) + if form.mode == 'gray': + if not form.host_ids: + return json_response(error='请选择灰度发布的主机') + host_ids = form.host_ids + elif form.mode == 'fail': + host_ids = [int(k) for k, v in deploy_status.items() if v != '2' and k != 'local'] else: - outputs['local'] = {'id': 'local', 'data': Helper.term_message('等待初始化... ')} - if req.deploy.extend == '2': - message = Helper.term_message('等待初始化... ') - if is_fail_mode: - message = Helper.term_message('已完成本地动作忽略执行', 'warn') - outputs['local'] = {'id': 'local', 'data': message} - s_actions = json.loads(req.deploy.extend_obj.server_actions) - h_actions = json.loads(req.deploy.extend_obj.host_actions) - if not s_actions: - outputs.pop('local') - if not h_actions: - outputs = {'local': outputs['local']} - return json_response({'outputs': outputs, 'token': req.deploy_key}) + host_ids = json.loads(req.host_ids) + + with_local = False + hosts = Host.objects.filter(id__in=host_ids) + message = Helper.term_message('等待调度... ') + outputs = {x.id: {'id': x.id, 'title': x.name, 'data': message} for x in hosts} + if req.deploy.extend == '1': + if req.repository_id: + if req.is_quick_deploy: + outputs['local'] = { + 'id': 'local', + 'status': 'success', + 'data': Helper.term_message('已构建完成忽略执行', 'warn') + } + else: + with_local = True + outputs['local'] = {'id': 'local', 'data': Helper.term_message('等待初始化... ')} + elif req.deploy.extend == '2': + s_actions = json.loads(req.deploy.extend_obj.server_actions) + h_actions = json.loads(req.deploy.extend_obj.host_actions) + if s_actions: + if deploy_status.get('local') == '2': + outputs['local'] = { + 'id': 'local', + 'status': 'success', + 'data': Helper.term_message('已完成本地动作忽略执行', 'warn') + } + else: + with_local = True + outputs['local'] = {'id': 'local', 'data': Helper.term_message('等待初始化... ')} + if not h_actions: + outputs = {'local': outputs['local']} + else: + raise NotImplementedError + + req.status = '2' + req.do_at = human_datetime() + req.do_by = request.user + req.save() + Thread(target=dispatch, args=(req, host_ids, with_local)).start() + return json_response({'outputs': outputs, 'token': req.deploy_key}) + return json_response(error=error) @auth('deploy.request.approve') def patch(self, request, r_id): @@ -323,7 +345,7 @@ def get_request_info(request): if error is None: req = DeployRequest.objects.get(pk=form.id) response = req.to_dict(selects=('status', 'reason')) - response['fail_host_ids'] = json.loads(req.fail_host_ids) + response['deploy_status'] = json.loads(req.deploy_status) response['status_alias'] = req.get_status_display() return json_response(response) return json_response(error=error) diff --git a/spug_api/apps/repository/utils.py b/spug_api/apps/repository/utils.py index b546912..67a46a9 100644 --- a/spug_api/apps/repository/utils.py +++ b/spug_api/apps/repository/utils.py @@ -26,6 +26,7 @@ def dispatch(rep: Repository, helper=None): helper = Helper.make(rds, rep.deploy_key, ['local']) rep.save() try: + helper.set_deploy_process('local') api_token = uuid.uuid4().hex helper.rds.setex(api_token, 60 * 60, f'{rep.app_id},{rep.env_id}') env = AttrDict( @@ -63,8 +64,10 @@ def dispatch(rep: Repository, helper=None): _build(rep, helper, env) rep.status = '5' + helper.set_deploy_success('local') except Exception as e: rep.status = '2' + helper.set_deploy_fail('local') raise e finally: helper.local(f'cd {REPOS_DIR} && rm -rf {rep.spug_version}') diff --git a/spug_web/src/pages/deploy/request/Console.js b/spug_web/src/pages/deploy/request/Console.js index 68c5ab1..f4f658c 100644 --- a/spug_web/src/pages/deploy/request/Console.js +++ b/spug_web/src/pages/deploy/request/Console.js @@ -78,7 +78,12 @@ function Console(props) { function doDeploy() { let socket; - http.post(`/api/deploy/request/${props.request.id}/`, {mode: props.request.mode}) + const formData = {mode: props.request.mode} + if (Array.isArray(props.request.mode)) { + formData.mode = 'gray' + formData.host_ids = props.request.mode + } + http.post(`/api/deploy/request/${props.request.id}/`, formData) .then(res => { _handleResponse(res) socket = _makeSocket() diff --git a/spug_web/src/pages/deploy/request/Ext1Form.js b/spug_web/src/pages/deploy/request/Ext1Form.js index ca451c5..823142f 100644 --- a/spug_web/src/pages/deploy/request/Ext1Form.js +++ b/spug_web/src/pages/deploy/request/Ext1Form.js @@ -249,6 +249,7 @@ export default observer(function () { )} {visible && setVisible(false)} diff --git a/spug_web/src/pages/deploy/request/Ext2Form.js b/spug_web/src/pages/deploy/request/Ext2Form.js index 22726c3..aa2a56d 100644 --- a/spug_web/src/pages/deploy/request/Ext2Form.js +++ b/spug_web/src/pages/deploy/request/Ext2Form.js @@ -120,6 +120,7 @@ export default observer(function () { )} {visible && setVisible(false)} diff --git a/spug_web/src/pages/deploy/request/HostSelector.js b/spug_web/src/pages/deploy/request/HostSelector.js index 5be6b26..4e65139 100644 --- a/spug_web/src/pages/deploy/request/HostSelector.js +++ b/spug_web/src/pages/deploy/request/HostSelector.js @@ -1,6 +1,6 @@ import React, { useState, useEffect } from 'react'; import { observer } from 'mobx-react'; -import { Modal, Table, Button, Alert } from 'antd'; +import { Modal, Table, Button, Alert, Tag } from 'antd'; import hostStore from 'pages/host/store'; export default observer(function (props) { @@ -31,19 +31,34 @@ export default observer(function (props) { } } + function DeployStatus(props) { + switch (props.status) { + case '0': + return 待调度 + case '1': + return 发布中 + case '2': + return 发布成功 + case '3': + return 发布失败 + default: + return 待发布 + } + } + return ( - {selectedRowKeys.length > 0 && ( - setSelectedRowKeys([])}>取消选择}/> - )} + 已选择 {selectedRowKeys.length} 台主机} + action={}/> props.app_host_ids.includes(x.id))} @@ -61,6 +76,10 @@ export default observer(function (props) { }}> + + {props.deploy_status ? ( + }/> + ) : null}
) diff --git a/spug_web/src/pages/deploy/request/Rollback.js b/spug_web/src/pages/deploy/request/Rollback.js index b4c59d2..7d3769b 100644 --- a/spug_web/src/pages/deploy/request/Rollback.js +++ b/spug_web/src/pages/deploy/request/Rollback.js @@ -39,7 +39,7 @@ export default observer(function () { }, () => setLoading(false)) } - const {app_host_ids, deploy_id} = store.record; + const {app_host_ids, deploy_id, deploy_status} = store.record; return ( {visible && setVisible(false)} onOk={ids => setHostIds(ids)}/>} diff --git a/spug_web/src/pages/deploy/request/Table.js b/spug_web/src/pages/deploy/request/Table.js index dfc8b54..02e6b2e 100644 --- a/spug_web/src/pages/deploy/request/Table.js +++ b/spug_web/src/pages/deploy/request/Table.js @@ -3,27 +3,21 @@ * Copyright (c) * Released under the AGPL-3.0 License. */ -import React from 'react'; +import React, { useState } from 'react'; import { observer } from 'mobx-react'; import { BranchesOutlined, BuildOutlined, TagOutlined, PlusOutlined, TagsOutlined } from '@ant-design/icons'; -import { Radio, Modal, Popover, Tag, Popconfirm, Tooltip, message } from 'antd'; +import { Radio, Modal, Popover, Tag, Tooltip, Button, Space, message } from 'antd'; import { http, hasPermission } from 'libs'; import { Action, AuthButton, TableCard } from 'components'; +import HostSelector from './HostSelector'; import S from './index.module.less'; import store from './store'; import moment from 'moment'; - -function DeployConfirm() { - return ( -
-
确认发布方式
-
补偿:仅发布上次发布失败的主机。
-
全量:再次发布所有主机。
-
- ) -} +import lds from 'lodash'; function ComTable() { + const [request, setRequest] = useState() + const columns = [{ title: '申请标题', className: S.min180, @@ -144,6 +138,14 @@ function ComTable() { store.rollback(info)}>回滚 )} ; + case '4': + return + store.readConsole(info)}>查看 + + {info.visible_rollback && ( + store.rollback(info)}>回滚 + )} + ; case '-1': return store.showForm(info)}>编辑 @@ -171,17 +173,23 @@ function ComTable() { }]; function DoAction(props) { - const {host_ids, fail_host_ids} = props.info; + const {deploy_status} = props.info; return ( - } - okText="全量" - cancelText="补偿" - cancelButtonProps={{disabled: [0, host_ids.length].includes(fail_host_ids.length)}} - onConfirm={e => handleDeploy(e, props.info, 'all')} - onCancel={e => handleDeploy(e, props.info, 'fail')}> + +
全量:发布所有主机(包含已成功的)。
+
补偿:仅发布上次发布失败的主机。
+
灰度:选择指定主机发布。
+ + + + + + + )}> 发布 -
+ ) } @@ -199,43 +207,58 @@ function ComTable() { }) } - function handleDeploy(e, info, mode) { + function handleDeploy(info, mode) { + if (request && mode.length === 0) { + return message.error('请选择灰度发布的主机') + } info.mode = mode - store.showConsole(info); + store.showConsole(info) + if (request) setRequest() } return ( - row.key || row.id} - title="申请列表" - columns={columns} - scroll={{x: 1500}} - tableLayout="auto" - loading={store.isFetching} - dataSource={store.dataSource} - onReload={store.fetchRecords} - actions={[ - } - onClick={() => store.addVisible = true}>新建申请, - store.f_status = e.target.value}> - 全部({store.counter['all'] || 0}) - 待审核({store.counter['0'] || 0}) - 待发布({store.counter['1'] || 0}) - 发布成功({store.counter['3'] || 0}) - 发布异常({store.counter['-3'] || 0}) - 其他({store.counter['99'] || 0}) - - ]} - pagination={{ - showSizeChanger: true, - showLessItems: true, - showTotal: total => `共 ${total} 条`, - pageSizeOptions: ['10', '20', '50', '100'] - }}/> + + row.key || row.id} + title="申请列表" + columns={columns} + scroll={{x: 1500}} + tableLayout="auto" + loading={store.isFetching} + dataSource={store.dataSource} + onReload={store.fetchRecords} + actions={[ + } + onClick={() => store.addVisible = true}>新建申请, + store.f_status = e.target.value}> + 全部({store.counter['all'] || 0}) + 待审核({store.counter['0'] || 0}) + 待发布({store.counter['1'] || 0}) + 发布成功({store.counter['3'] || 0}) + 发布异常({store.counter['-3'] || 0}) + 其他({store.counter['99'] || 0}) + + ]} + pagination={{ + showSizeChanger: true, + showLessItems: true, + showTotal: total => `共 ${total} 条`, + pageSizeOptions: ['10', '20', '50', '100'] + }}/> + + {request ? ( + setRequest()} + deploy_status={request.deploy_status} + onOk={ids => handleDeploy(request, ids)}/> + ) : null} + ) } diff --git a/spug_web/src/pages/deploy/request/store.js b/spug_web/src/pages/deploy/request/store.js index 78eb4c4..e4fac4c 100644 --- a/spug_web/src/pages/deploy/request/store.js +++ b/spug_web/src/pages/deploy/request/store.js @@ -108,7 +108,7 @@ class Store { }; rollback = (info) => { - this.record = lds.pick(info, ['deploy_id', 'host_ids']); + this.record = lds.pick(info, ['deploy_id', 'host_ids', 'deploy_status']); this.record.app_host_ids = info.host_ids; this.record.name = `${info.name} - 回滚`; this.rollbackVisible = true