735 lines
30 KiB
Python
735 lines
30 KiB
Python
|
|
"""部署脚本(支持 .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<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()
|