"""部署脚本(支持 .env/.json 配置) 实现以下规范: - 构建:Maven/Gradle clean 并跳过测试,生成可执行 jar - 严格校验:校验 BOOT-INF/classes 配置文件与 MANIFEST 元数据 - 安全传输:SCP 非 22 端口 + 指纹校验 + MD5 验证 - 预检查:JDK17+、磁盘空间 >= 2GB、ulimit 满足 SpringBoot3 - 进程管理:优雅停止(SIGTERM 30s)超时强杀(SIGKILL) - 备份保留:/opt/app/backups 保留最近 5 个版本 - 启动与健康:nohup 启动、120s 日志无 ERROR、actuator/health=200 - 回滚与告警:自动回滚最近可用版本并发送告警 - 日志:JSON Lines 记录所有步骤与环境信息 配置优先级: - 优先读取 DEPLOY_CONFIG 指定的路径(.env 或 .json) - 未指定时优先加载同目录下 deployment.env,其次 deployment.config.json """ import base64 import datetime import hashlib import json import os import re import shutil import socket import subprocess import sys import time import urllib.request import urllib.error import zipfile import paramiko from types import SimpleNamespace def _ts_ms(): """返回当前时间戳(毫秒)""" return int(time.time() * 1000) def _log_path(): """返回部署日志文件路径(JSONL 格式)""" return os.path.join(os.path.dirname(__file__), "deployment.log.jsonl") def log_step(step, status, details=None, extra=None, env=None): """记录部署步骤到 JSONL 日志 step:步骤名称;status:ok/fail/skip 等; details/extra:附加信息;env:环境信息(服务器、目录等) """ rec = { "timestamp_ms": _ts_ms(), "step": step, "status": status, "details": details or "", "extra": extra or {}, } if env: rec["env"] = env with open(_log_path(), "a", encoding="utf-8") as f: f.write(json.dumps(rec, ensure_ascii=False) + "\n") def print_progress(percent, step, status="进行中", details=None): try: msg = f"[{str(percent).rjust(3)}%] {step} - {status}" if details: msg += f" | {details}" print(msg, flush=True) except Exception: pass def run_cmd(cmd, cwd=None, env=None, capture_output=True, timeout=None): """运行本地命令并返回 (返回码, 标准输出, 标准错误)""" try: p = subprocess.run( cmd, shell=True, cwd=cwd, env=env, stdout=subprocess.PIPE if capture_output else None, stderr=subprocess.PIPE if capture_output else None, timeout=timeout, ) out = p.stdout.decode("utf-8", errors="replace") if capture_output and p.stdout else "" err = p.stderr.decode("utf-8", errors="replace") if capture_output and p.stderr else "" return p.returncode, out, err except subprocess.TimeoutExpired as te: out = te.stdout.decode("utf-8", errors="replace") if capture_output and te.stdout else "" err = te.stderr.decode("utf-8", errors="replace") if capture_output and te.stderr else "command timeout" return 124, out, err def md5_file(path): """计算本地文件 MD5 摘要""" h = hashlib.md5() with open(path, "rb") as f: for chunk in iter(lambda: f.read(8192), b""): h.update(chunk) return h.hexdigest() def sha256_bytes(b): """计算字节的 SHA256 并返回 base64 字符串(用于指纹)""" return base64.b64encode(hashlib.sha256(b).digest()).decode("ascii") def find_jar(project_dir, build_tool, target_jar_name=None): """定位构建产物 jar 文件(支持指定文件名或自动选择最新)""" if target_jar_name: maven_path = os.path.join(project_dir, "target", target_jar_name) gradle_path = os.path.join(project_dir, "build", "libs", target_jar_name) if os.path.exists(maven_path): return maven_path if os.path.exists(gradle_path): return gradle_path return target_jar_name if build_tool == "maven": target = os.path.join(project_dir, "target") else: target = os.path.join(project_dir, "build", "libs") if not os.path.isdir(target): return None jars = [] for name in os.listdir(target): if name.endswith(".jar"): jars.append(os.path.join(target, name)) if not jars: return None jars.sort(key=lambda p: os.path.getmtime(p), reverse=True) return jars[0] def verify_jar_integrity(jar_path): """校验 jar 完整性(支持 Boot Jar 与 Boot War): - 必须存在 application.yml、application-prod.yml、logback-plus.xml(在 classes 下) - MANIFEST 必须包含 Start-Class / Implementation-Version / Build-Time """ z = zipfile.ZipFile(jar_path) names = set(z.namelist()) prefixes = ["BOOT-INF/classes/", "WEB-INF/classes/"] files = ["application.yml", "application-prod.yml", "logback-plus.xml"] missing = [] for f in files: if not any((p + f) in names for p in prefixes): missing.append(f) ok = len(missing) == 0 manifest = None if "META-INF/MANIFEST.MF" in names: manifest = z.read("META-INF/MANIFEST.MF").decode("utf-8", errors="replace") else: ok = False if manifest: has_start_class = bool(re.search(r"^Start-Class:\s*.+", manifest, re.MULTILINE)) has_impl_ver = bool(re.search(r"^Implementation-Version:\s*.+", manifest, re.MULTILINE)) has_build_time = bool(re.search(r"^Build-Time:\s*.+", manifest, re.MULTILINE)) if not (has_start_class and has_impl_ver and has_build_time): ok = False if not has_start_class: missing.append("Start-Class") if not has_impl_ver: missing.append("Implementation-Version") if not has_build_time: missing.append("Build-Time") z.close() return ok, missing def parse_manifest_info(jar_path): """解析 MANIFEST.MF 中的关键字段信息""" z = zipfile.ZipFile(jar_path) info = {} if "META-INF/MANIFEST.MF" in z.namelist(): m = z.read("META-INF/MANIFEST.MF").decode("utf-8", errors="replace") for k in ["Start-Class", "Implementation-Version", "Build-Time"]: mobj = re.search(rf"^{k}:\s*(.+)$", m, re.MULTILINE) if mobj: info[k] = mobj.group(1).strip() z.close() return info def maven_build(project_dir, maven_home): """执行 Maven 构建(跳过测试)""" mvn = os.path.join(maven_home, "bin", "mvn.cmd") if os.name == "nt" else os.path.join(maven_home, "bin", "mvn") cmd = f'"{mvn}" clean package -DskipTests=true' return run_cmd(cmd, cwd=project_dir, capture_output=False) def gradle_build(project_dir, gradle_home=None): """执行 Gradle 构建(跳过测试,优先使用 gradlew)""" gradlew = os.path.join(project_dir, "gradlew.cmd") if os.name == "nt" else os.path.join(project_dir, "gradlew") if os.path.exists(gradlew): cmd = f'"{gradlew}" clean build -x test' return run_cmd(cmd, cwd=project_dir, capture_output=False) if gradle_home: gradle_bin = os.path.join(gradle_home, "bin", "gradle") if os.name == "nt": gradle_bin += ".bat" cmd = f'"{gradle_bin}" clean build -x test' return run_cmd(cmd, cwd=project_dir, capture_output=False) return 1, "", "gradle not found" def connect_ssh(host, port, user, password=None, keyfile=None): """建立 SSH 连接(密码或密钥两种方式)""" ssh = paramiko.SSHClient() ssh.set_missing_host_key_policy(paramiko.AutoAddPolicy()) if keyfile: ssh.connect(host, port=port, username=user, key_filename=keyfile) else: ssh.connect(host, port=port, username=user, password=password) try: ssh.get_transport().set_keepalive(15) except Exception: pass return ssh def ensure_fingerprint(host, port, user, password, keyfile, expect_fp): """校验服务器指纹(要求与配置的 SHA256 指纹一致)""" ssh = connect_ssh(host, port, user, password, keyfile) k = ssh.get_transport().get_remote_server_key() fp = "SHA256:" + sha256_bytes(base64.b64decode(k.get_base64())) ssh.close() return fp == expect_fp, fp def scp_transfer(local_path, host, port, user, remote_path, timeout=None): """使用 scp 非标准端口传输文件到远端目录""" cmd = ( f'scp -o BatchMode=yes ' f'-o ConnectTimeout=30 -o ServerAliveInterval=15 -o ServerAliveCountMax=3 ' f'-o StrictHostKeyChecking=accept-new -P {port} "{local_path}" {user}@{host}:"{remote_path}"' ) return run_cmd(cmd, timeout=timeout) def sftp_transfer(ssh, local_path, remote_path): sftp = ssh.open_sftp() try: sftp.put(local_path, remote_path) return 0, "", "" finally: try: sftp.close() except Exception: pass def ssh_exec(ssh, cmd, timeout=None, print_output=True): """执行远端命令并返回 (返回码, 标准输出, 标准错误)""" if print_output: print(f'SSH$ {cmd}', flush=True) stdin, stdout, stderr = ssh.exec_command(cmd, timeout=timeout) out = stdout.read().decode("utf-8", errors="replace") err = stderr.read().decode("utf-8", errors="replace") rc = stdout.channel.recv_exit_status() if print_output: if out: try: print(out if out.endswith("\n") else out + "\n", end="", flush=True) except Exception: pass if err: try: print(err if err.endswith("\n") else err + "\n", end="", flush=True) except Exception: pass return rc, out, err def remote_md5(ssh, path): """获取远端文件摘要(优先 md5sum,失败则尝试 sha256sum)""" rc, out, _ = ssh_exec(ssh, f'md5sum "{path}" || sha256sum "{path}"') algo = "md5" if " " in out.strip(): val = out.strip().split()[0] if rc != 0: return None, algo return val, algo return None, algo def pre_checks(ssh, remote_dir): ok = True details = {} rc, out, _ = ssh_exec(ssh, "java -version") version_match = re.search(r'version \"(?P[\\d\\.]+)', out) or re.search(r'openjdk version \"(?P[\\d\\.]+)', out) if version_match: major = int(version_match.group("v").split(".")[0]) details["java_major"] = major details["java_ok"] = major >= 17 ok = ok and details["java_ok"] else: details["java_ok"] = False ok = False rc, out, _ = ssh_exec(ssh, f'df -Pk "{remote_dir}" | tail -1') parts = out.split() if len(parts) >= 4: try: avail_k = int(parts[3]) except: avail_k = 0 details["disk_avail_k"] = avail_k details["disk_ok"] = avail_k >= 2 * 1024 * 1024 ok = ok and details["disk_ok"] else: details["disk_ok"] = False ok = False rc, out, _ = ssh_exec(ssh, "ulimit -n") try: nofile = int(out.strip()) details["ulimit_nofile"] = nofile details["ulimit_nofile_ok"] = nofile >= 65535 ok = ok and details["ulimit_nofile_ok"] except: details["ulimit_nofile_ok"] = False ok = False rc, out, _ = ssh_exec(ssh, "ulimit -u") try: nproc = int(out.strip()) details["ulimit_nproc"] = nproc details["ulimit_nproc_ok"] = nproc >= 4096 ok = ok and details["ulimit_nproc_ok"] except: details["ulimit_nproc_ok"] = False ok = False return ok, details def stop_process(ssh, jar_name): """优雅停止现有服务:SIGTERM 等待 30s,超时则 SIGKILL""" rc, out, _ = ssh_exec(ssh, f"ps -ef | grep 'java.*{re.escape(jar_name)}' | grep -v grep | awk '{{print $2}}'") pids = [p.strip() for p in out.splitlines() if p.strip()] for pid in pids: ssh_exec(ssh, f"kill -15 {pid}") deadline = time.time() + 30 remaining = pids[:] while time.time() < deadline and remaining: rc, out, _ = ssh_exec(ssh, f"ps -o pid= -p {' '.join(remaining)}") alive = [p.strip() for p in out.splitlines() if p.strip()] remaining = alive time.sleep(1) for pid in remaining: ssh_exec(ssh, f"kill -9 {pid}") return True def backup_remote(ssh, remote_dir, jar_name, impl_ver): """备份当前版本至 /opt/app/backups,并保留最近 5 个版本""" ssh_exec(ssh, f'mkdir -p "{remote_dir}/backups"') ts = datetime.datetime.utcnow().strftime("%Y%m%d%H%M%S") bn = os.path.splitext(jar_name)[0] ver = impl_ver or "unknown" backup_name = f"{bn}-{ver}-{ts}.jar" ssh_exec(ssh, f'if [ -f "{remote_dir}/{jar_name}" ]; then cp -f "{remote_dir}/{jar_name}" "{remote_dir}/backups/{backup_name}"; fi') rc, out, _ = ssh_exec(ssh, f'ls -1t "{remote_dir}/backups" | grep -E "^{re.escape(bn)}-.*\\.jar$"') lines = [l.strip() for l in out.splitlines() if l.strip()] if len(lines) > 5: to_del = lines[5:] names = " ".join([f'"{remote_dir}/backups/{x}"' for x in to_del]) ssh_exec(ssh, f"rm -f {names}") return backup_name def start_service(ssh, remote_dir, jar_name, jvm_opts): """使用 nohup 启动服务并返回 PID 与日志路径""" log_path = f"{remote_dir}/log.out" cmd = f'nohup java {jvm_opts} -jar "{remote_dir}/{jar_name}" > "{log_path}" 2>&1 &' ssh_exec(ssh, cmd) time.sleep(2) rc, out, _ = ssh_exec(ssh, f"ps -ef | grep 'java.*{re.escape(jar_name)}' | grep -v grep | awk '{{print $2}}' | head -1") pid = out.strip() if out.strip() else "" return pid, log_path def monitor_logs_no_error(ssh, log_path, seconds): """在指定时长内监控启动日志,若出现 ERROR 则失败""" end = time.time() + seconds while time.time() < end: rc, out, _ = ssh_exec(ssh, f'tail -n 200 "{log_path}" || true', print_output=False) if "ERROR" in out: return False time.sleep(2) return True def detect_crash_within(ssh, jar_name, seconds): """监控指定时长,若进程曾启动后又消失,判定为崩溃""" end = time.time() + seconds alive_once = False while time.time() < end: rc, out, _ = ssh_exec(ssh, f"ps -ef | grep 'java.*{re.escape(jar_name)}' | grep -v grep | awk '{{print $2}}'") pids = [p.strip() for p in out.splitlines() if p.strip()] if pids: alive_once = True else: if alive_once: return True time.sleep(5) return False def verify_deploy_info(ssh, url, expected_version=None, expected_build_time=None): """调用部署验证接口并校验版本与构建时间""" rc, code_str, _ = ssh_exec(ssh, f'curl -s -o /dev/null -w "%{{http_code}}" "{url}"') rc2, body, _ = ssh_exec(ssh, f'curl -s "{url}"') http_code = code_str.strip() ok_http = http_code == "200" version = None build_time = None try: data = json.loads(body) payload = data.get("data") if isinstance(data, dict) and "data" in data else data if isinstance(payload, dict): version = payload.get("version") build_time = payload.get("buildTime") except: pass match_ver = (expected_version is None) or (version == expected_version) match_bt = (not expected_build_time) or (build_time == expected_build_time) ok = ok_http and match_ver and match_bt return ok, {"http_code": http_code, "version": version, "buildTime": build_time} def rollback(ssh, remote_dir, jar_name, jvm_opts): """自动回滚到最近可用备份并重启服务""" rc, out, _ = ssh_exec(ssh, f'ls -1t "{remote_dir}/backups" | grep -E "^{re.escape(os.path.splitext(jar_name)[0])}-.*\\.jar$" | head -1') last = out.strip() if not last: return False ssh_exec(ssh, f'cp -f "{remote_dir}/backups/{last}" "{remote_dir}/{jar_name}"') pid, log_path = start_service(ssh, remote_dir, jar_name, jvm_opts) return True def send_alert(webhook, payload): """向运维告警地址发送 JSON 通知(可选)""" if not webhook: return False data = json.dumps(payload).encode("utf-8") req = urllib.request.Request(webhook, data=data, headers={"Content-Type": "application/json"}) try: urllib.request.urlopen(req, timeout=10) return True except urllib.error.URLError: return False def parse_bool(s): """解析布尔字符串(支持 true/false/1/0/yes/no/on/off)""" if s is None: return False v = str(s).strip().lower() return v in ("1", "true", "yes", "y", "on") def load_env_file(path): """加载 .env 配置文件:支持注释与引号包裹的值""" data = {} with open(path, "r", encoding="utf-8") as f: for raw in f: line = raw.strip() if not line: continue if line.startswith("#") or line.startswith(";"): continue if "=" not in line: continue k, v = line.split("=", 1) k = k.strip() v = v.strip() if (v.startswith('"') and v.endswith('"')) or (v.startswith("'") and v.endswith("'")): v = v[1:-1] data[k] = v return { "server": data.get("SERVER"), "port": int(data.get("PORT")) if data.get("PORT") else None, "user": data.get("USER"), "password": data.get("PASSWORD"), "keyfile": data.get("KEYFILE"), "fingerprint": data.get("FINGERPRINT"), "env": data.get("ENV"), "build_tool": data.get("BUILD_TOOL"), "project_dir": data.get("PROJECT_DIR"), "maven_home": data.get("MAVEN_HOME"), "gradle_home": data.get("GRADLE_HOME"), "target_jar_name": data.get("TARGET_JAR_NAME"), "remote_dir": data.get("REMOTE_DIR") or "/opt/app", "jvm_opts": data.get("JVM_OPTS") or "-Xms512m -Xmx1024m", "transfer_method": (data.get("TRANSFER_METHOD") or "sftp").lower(), "transfer_timeout": int(data.get("TRANSFER_TIMEOUT")) if data.get("TRANSFER_TIMEOUT") else 300, "deploy_info_url": data.get("DEPLOY_INFO_URL") or "http://localhost:8080/deploy/info", "alert_webhook": data.get("ALERT_WEBHOOK"), "dry_run": parse_bool(data.get("DRY_RUN")), "skip_build": parse_bool(data.get("SKIP_BUILD")), "skip_prechecks": parse_bool(data.get("SKIP_PRECHECKS")), "precheck_strict": parse_bool(data.get("PRECHECK_STRICT")), "crash_monitor_seconds": int(data.get("CRASH_MONITOR_SECONDS")) if data.get("CRASH_MONITOR_SECONDS") else 60, "skip_crash_monitor": parse_bool(data.get("SKIP_CRASH_MONITOR")), } def main(): """主流程:加载配置 -> 构建 -> 校验 -> 传输 -> 预检 -> 停止 -> 备份 -> 启动 -> 健康 -> 回滚/完成""" default_env = os.path.join(os.path.dirname(__file__), "deployment.env") cfg_path = os.getenv("DEPLOY_CONFIG") or default_env # 仅支持 .env 配置文件,并校验必填字段 if not os.path.isfile(cfg_path): log_step("config_load", "fail", {"path": cfg_path}) print(f"配置文件未找到:{cfg_path}", file=sys.stderr) sys.exit(1) cfg = load_env_file(cfg_path) required = ["server", "port", "user", "env", "build_tool", "project_dir"] missing = [] for k in required: v = cfg.get(k) if v is None or (isinstance(v, str) and v.strip() == ""): missing.append(k) if missing: log_step("config_validate", "fail", {"missing": missing, "path": cfg_path}) print(f"配置错误:缺少必填项 {', '.join(missing)};配置文件:{cfg_path}", file=sys.stderr) sys.exit(1) print_progress(0, "启动部署", "进行中", f"配置文件:{cfg_path}") args = SimpleNamespace( server=cfg["server"], port=int(cfg["port"]), user=cfg["user"], password=cfg.get("password"), keyfile=cfg.get("keyfile"), fingerprint=cfg["fingerprint"], env=cfg["env"], build_tool=cfg["build_tool"], project_dir=cfg["project_dir"], maven_home=cfg.get("maven_home"), gradle_home=cfg.get("gradle_home"), target_jar_name=cfg.get("target_jar_name"), remote_dir=cfg.get("remote_dir", "/opt/app"), jvm_opts=cfg.get("jvm_opts", "-Xms512m -Xmx1024m"), deploy_info_url=cfg.get("deploy_info_url", "http://localhost:8080/deploy/info"), alert_webhook=cfg.get("alert_webhook"), dry_run=bool(cfg.get("dry_run", False)), skip_build=bool(cfg.get("skip_build", False)), transfer_method=cfg.get("transfer_method", "sftp"), transfer_timeout=int(cfg.get("transfer_timeout", 300)), skip_prechecks=bool(cfg.get("skip_prechecks", False)), precheck_strict=bool(cfg.get("precheck_strict", False)), crash_monitor_seconds=int(cfg.get("crash_monitor_seconds", 60)), skip_crash_monitor=bool(cfg.get("skip_crash_monitor", False)), ) env_info = { "server": args.server, "port": args.port, "env": args.env, "build_tool": args.build_tool, "project_dir": args.project_dir, "remote_dir": args.remote_dir, } try: if args.dry_run: log_step("start", "dry_run", {"config_path": cfg_path}, env=env_info) print_progress(5, "加载配置", "完成") else: log_step("start", "running", {"config_path": cfg_path}, env=env_info) print_progress(5, "加载配置", "完成") if not args.skip_build: print_progress(10, "开始构建", "进行中", args.build_tool) if args.build_tool == "maven": rc, out, err = maven_build(args.project_dir, args.maven_home or "") else: rc, out, err = gradle_build(args.project_dir, args.gradle_home) log_step("build", "ok" if rc == 0 else "fail", {"stdout": out[-1000:], "stderr": err[-1000:]}, env=env_info) print_progress(20, "构建完成", "成功" if rc == 0 else "失败") if rc != 0 and not args.dry_run: sys.exit(1) else: print_progress(10, "跳过构建", "已跳过") jar_path = find_jar(args.project_dir, args.build_tool, args.target_jar_name) if not jar_path or not os.path.isfile(jar_path): log_step("find_jar", "fail", {"path": jar_path}, env=env_info) print_progress(25, "查找构建产物", "失败") if not args.dry_run: sys.exit(1) else: log_step("find_jar", "ok", {"path": jar_path}, env=env_info) print_progress(25, "查找构建产物", "成功", os.path.basename(jar_path)) if jar_path and os.path.isfile(jar_path): ok, missing = verify_jar_integrity(jar_path) manifest_info = parse_manifest_info(jar_path) log_step("jar_integrity", "ok" if ok else "fail", {"manifest": manifest_info, "missing": missing}, env=env_info) print_progress(35, "校验产物完整性", "成功" if ok else f"失败(缺失: {', '.join(missing)})") if not ok and not args.dry_run: sys.exit(1) if args.dry_run: log_step("fingerprint_check", "skip", env=env_info) log_step("transfer", "skip", env=env_info) log_step("pre_checks", "skip", env=env_info) log_step("stop_process", "skip", env=env_info) log_step("backup", "skip", env=env_info) log_step("start_service", "skip", env=env_info) log_step("monitor_crash", "skip", env=env_info) print_progress(100, "完成", "DRY-RUN") log_step("done", "ok", env=env_info) return if args.fingerprint: print_progress(40, "校验服务器指纹", "进行中") ok_fp, actual_fp = ensure_fingerprint(args.server, args.port, args.user, args.password, args.keyfile, args.fingerprint) log_step("fingerprint_check", "ok" if ok_fp else "fail", {"actual": actual_fp}, env=env_info) print_progress(45, "校验服务器指纹", "成功" if ok_fp else "失败") if not ok_fp: print(f"服务器指纹不匹配:期望 {args.fingerprint},实际 {actual_fp}", file=sys.stderr) sys.exit(1) else: log_step("fingerprint_check", "skip", {"reason": "no_fingerprint_provided"}, env=env_info) print_progress(40, "校验服务器指纹", "已跳过") print_progress(50, "连接服务器", "进行中") ssh = connect_ssh(args.server, args.port, args.user, args.password, args.keyfile) ssh.exec_command(f'mkdir -p "{args.remote_dir}"') print_progress(55, "连接服务器", "成功") local_md5 = md5_file(jar_path) print_progress(60, "传输文件", "进行中", os.path.basename(jar_path)) if args.transfer_method == "scp": rc, out, err = scp_transfer( jar_path, args.server, args.port, args.user, f'{args.remote_dir}/{os.path.basename(jar_path)}', timeout=args.transfer_timeout, ) else: rc, out, err = sftp_transfer( ssh, jar_path, f'{args.remote_dir}/{os.path.basename(jar_path)}', ) log_step("transfer", "ok" if rc == 0 else "fail", {"stdout": out[-1000:], "stderr": err[-1000:]}, env=env_info) print_progress(65, "传输文件", "成功" if rc == 0 else "失败") if rc != 0: ssh.close() sys.exit(1) remote_hash, algo = remote_md5(ssh, f'{args.remote_dir}/{os.path.basename(jar_path)}') match = remote_hash == local_md5 if algo == "md5" and remote_hash else True log_step("md5_verify", "ok" if match else "fail", {"local_md5": local_md5, "remote_hash": remote_hash, "algo": algo}, env=env_info) print_progress(70, "校验文件摘要", "成功" if match else "失败") if not match: ssh.close() sys.exit(1) if args.skip_prechecks: log_step("pre_checks", "skip", env=env_info) print_progress(75, "预部署检查", "已跳过") else: checks_ok, check_details = pre_checks(ssh, args.remote_dir) status = "ok" if checks_ok else ("fail" if args.precheck_strict else "warn") log_step("pre_checks", status, check_details, env=env_info) print_progress(75, "预部署检查", "成功" if checks_ok else ("警告" if not args.precheck_strict else "失败")) if not checks_ok and args.precheck_strict: ssh.close() sys.exit(1) jar_name = os.path.basename(jar_path) print_progress(78, "停止现有服务", "进行中") stop_process(ssh, jar_name) log_step("stop_process", "ok", env=env_info) print_progress(80, "停止现有服务", "完成") backup_name = backup_remote(ssh, args.remote_dir, jar_name, parse_manifest_info(jar_path).get("Implementation-Version")) log_step("backup", "ok", {"backup": backup_name}, env=env_info) print_progress(82, "备份旧版本", "完成", backup_name) pid, log_path = start_service(ssh, args.remote_dir, jar_name, args.jvm_opts) log_step("start_service", "ok" if pid else "fail", {"pid": pid, "log": log_path}, env=env_info) print_progress(85, "启动服务", "成功" if pid else "失败", f"pid={pid}") if not pid: ssh.close() sys.exit(1) log_step("startup_logs", "skip", env=env_info) print_progress(90, "监控启动日志", "已跳过") last_metrics = {} if args.skip_crash_monitor: log_step("monitor_crash", "skip", env=env_info) print_progress(97, f"崩溃监控({args.crash_monitor_seconds}秒)", "已跳过") else: crashed = detect_crash_within(ssh, jar_name, args.crash_monitor_seconds) log_step("monitor_crash", "fail" if crashed else "ok", env=env_info) print_progress(97, f"崩溃监控({args.crash_monitor_seconds}秒)", "失败" if crashed else "通过") ver_expected = parse_manifest_info(jar_path).get("Implementation-Version") bt_expected = parse_manifest_info(jar_path).get("Build-Time") print_progress(98, "验证部署接口", "进行中") ok_deploy, deploy_info = verify_deploy_info(ssh, args.deploy_info_url, ver_expected, bt_expected) log_step("deploy_info_check", "ok" if ok_deploy else "fail", {"expect_version": ver_expected, "expect_build_time": bt_expected, "response": deploy_info}, env=env_info) print_progress(98, "验证部署接口", "成功" if ok_deploy else "失败") if crashed or not ok_deploy: print_progress(98, "触发回滚", "进行中") rb_ok = rollback(ssh, args.remote_dir, jar_name, args.jvm_opts) log_step("rollback", "ok" if rb_ok else "fail", env=env_info) print_progress(99, "回滚完成", "成功" if rb_ok else "失败") send_alert(args.alert_webhook, {"event": "rollback", "server": args.server, "env": args.env, "reason": "deploy_info_fail_or_crash"}) ssh.close() if not rb_ok: sys.exit(1) sys.exit(0) log_step("done", "ok", {"metrics": last_metrics}, env=env_info) print_progress(100, "部署完成", "成功") ssh.close() except Exception as e: import traceback log_step("exception", "fail", {"error": str(e), "trace": traceback.format_exc()}, env=env_info) print_progress(100, "部署失败", "异常", str(e)) print(traceback.format_exc(), file=sys.stderr) sys.exit(1) if __name__ == "__main__": main()