Files
wucaixing-backend/src/main/resources/deployment.py

735 lines
30 KiB
Python
Raw Normal View History

2026-05-13 16:14:53 +08:00
"""部署脚本(支持 .env/.json 配置)
实现以下规范
- 构建Maven/Gradle clean 并跳过测试生成可执行 jar
- 严格校验校验 BOOT-INF/classes 配置文件与 MANIFEST 元数据
- 安全传输SCP 22 端口 + 指纹校验 + MD5 验证
- 预检查JDK17+磁盘空间 >= 2GBulimit 满足 SpringBoot3
- 进程管理优雅停止SIGTERM 30s超时强杀SIGKILL
- 备份保留/opt/app/backups 保留最近 5 个版本
- 启动与健康nohup 启动120s 日志无 ERRORactuator/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步骤名称statusok/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.ymlapplication-prod.ymllogback-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<v>[\\d\\.]+)', out) or re.search(r'openjdk version \"(?P<v>[\\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()