deploy_test_env.py 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """
  4. 测试环境日常发版脚本(裸机:Nginx 10001 + backend 127.0.0.1:10002)
  5. 在本机仓库根目录或任意目录执行均可(脚本会定位仓库根):
  6. python3 测试环境部署/deploy_test_env.py # 前端打包 + 后端同步 + 重启
  7. python3 测试环境部署/deploy_test_env.py --frontend-only
  8. python3 测试环境部署/deploy_test_env.py --backend-only
  9. python3 测试环境部署/deploy_test_env.py --verify
  10. python3 测试环境部署/deploy_test_env.py --upload-env # 额外上传 backend/.env → 远端 .env.test
  11. python3 测试环境部署/deploy_test_env.py --pip # 远端重新 pip install -r requirements.txt
  12. SSH 密码默认读取仓库根「测试环境机器的账号密码」(root密码:...),
  13. 也可用环境变量 ZKXW_TEST_SSH_PASS,或 --password / --password-file。
  14. 勿把密码写进本脚本或提交到 git。
  15. """
  16. from __future__ import annotations
  17. import argparse
  18. import os
  19. import re
  20. import shutil
  21. import subprocess
  22. import sys
  23. import tempfile
  24. import time
  25. from pathlib import Path
  26. from typing import List, Optional, Sequence
  27. # ---------------------------------------------------------------------------
  28. # 默认拓扑(与 docs/superpowers/specs/2026-08-06-test-env-bare-metal-deploy-design.md 一致)
  29. # ---------------------------------------------------------------------------
  30. DEFAULT_HOST = "101.35.245.122"
  31. DEFAULT_USER = "root"
  32. REMOTE_ROOT = "/root/zkxw"
  33. REMOTE_BACKEND = f"{REMOTE_ROOT}/backend"
  34. REMOTE_DIST = f"{REMOTE_ROOT}/web-admin/dist"
  35. BACKEND_HOST = "127.0.0.1"
  36. BACKEND_PORT = "10002"
  37. PUBLIC_HTTP_PORT = "10001"
  38. DEFAULT_HEALTH_TIMEOUT_SEC = 60
  39. DEFAULT_HEALTH_INTERVAL_SEC = 2
  40. BACKEND_EXCLUDES = (
  41. ".venv",
  42. "venv",
  43. "__pycache__",
  44. "*.pyc",
  45. "logs",
  46. "graphify-out",
  47. ".env",
  48. ".env.*",
  49. ".pytest_cache",
  50. ".pycache",
  51. ".DS_Store",
  52. )
  53. def repo_root() -> Path:
  54. here = Path(__file__).resolve().parent
  55. # 测试环境部署/ → 仓库根
  56. return here.parent
  57. def die(msg: str, code: int = 1) -> None:
  58. print(f"[ERROR] {msg}", file=sys.stderr)
  59. raise SystemExit(code)
  60. def info(msg: str) -> None:
  61. print(f"[INFO] {msg}")
  62. def run(
  63. cmd: Sequence[str],
  64. *,
  65. cwd: Optional[Path] = None,
  66. env: Optional[dict] = None,
  67. check: bool = True,
  68. ) -> subprocess.CompletedProcess:
  69. print(f"[CMD] {' '.join(cmd)}")
  70. return subprocess.run(
  71. list(cmd),
  72. cwd=str(cwd) if cwd else None,
  73. env=env,
  74. check=check,
  75. )
  76. def load_password(args: argparse.Namespace, root: Path) -> Optional[str]:
  77. if args.password:
  78. return args.password
  79. env_pass = os.environ.get("ZKXW_TEST_SSH_PASS") or os.environ.get("SSHPASS")
  80. if env_pass:
  81. return env_pass
  82. path = Path(args.password_file) if args.password_file else root / "测试环境机器的账号密码"
  83. if not path.is_file():
  84. return None
  85. text = path.read_text(encoding="utf-8", errors="replace")
  86. m = re.search(r"^root密码[::]\s*(.+)\s*$", text, re.M)
  87. if m:
  88. return m.group(1).strip()
  89. # 兜底:第二行非空内容
  90. lines = [ln.strip() for ln in text.splitlines() if ln.strip() and not ln.strip().startswith("#")]
  91. if len(lines) >= 2 and "密码" in lines[1]:
  92. return re.sub(r"^.*?[::]\s*", "", lines[1]).strip()
  93. return None
  94. def require_bin(name: str) -> str:
  95. path = shutil.which(name)
  96. if not path:
  97. die(f"未找到命令 `{name}`,请先安装并加入 PATH")
  98. return path
  99. def ssh_argv(password: Optional[str]) -> List[str]:
  100. """ssh 可执行前缀(含可选 sshpass)。"""
  101. ssh = require_bin("ssh")
  102. opts = ["-o", "StrictHostKeyChecking=accept-new", "-o", "RequestTTY=no"]
  103. if password:
  104. require_bin("sshpass")
  105. return ["sshpass", "-e", ssh, *opts]
  106. return [ssh, "-o", "BatchMode=yes", *opts]
  107. def scp_argv(password: Optional[str]) -> List[str]:
  108. scp = require_bin("scp")
  109. opts = ["-o", "StrictHostKeyChecking=accept-new", "-o", "RequestTTY=no"]
  110. if password:
  111. require_bin("sshpass")
  112. return ["sshpass", "-e", scp, *opts]
  113. return [scp, "-o", "BatchMode=yes", *opts]
  114. def with_sshpass_env(password: Optional[str], base_env: Optional[dict] = None) -> dict:
  115. env = dict(base_env or os.environ)
  116. if password:
  117. env["SSHPASS"] = password
  118. return env
  119. def remote_target(args: argparse.Namespace) -> str:
  120. return f"{args.user}@{args.host}"
  121. def ssh_run(args: argparse.Namespace, password: Optional[str], remote_cmd: str) -> None:
  122. cmd = [*ssh_argv(password), remote_target(args), remote_cmd]
  123. run(cmd, env=with_sshpass_env(password))
  124. def rsync_to(
  125. args: argparse.Namespace,
  126. password: Optional[str],
  127. local: str,
  128. remote_path: str,
  129. *,
  130. delete: bool = False,
  131. excludes: Sequence[str] = (),
  132. ) -> None:
  133. require_bin("rsync")
  134. # rsync -e 需要单个 shell 字符串
  135. ssh_e = " ".join(ssh_argv(password))
  136. cmd: List[str] = ["rsync", "-avz"]
  137. if delete:
  138. cmd.append("--delete")
  139. for ex in excludes:
  140. cmd.extend(["--exclude", ex])
  141. cmd.extend(["-e", ssh_e, local, f"{remote_target(args)}:{remote_path}"])
  142. run(cmd, env=with_sshpass_env(password))
  143. def build_frontend(root: Path) -> None:
  144. web = root / "web-admin"
  145. if not (web / "package.json").is_file():
  146. die(f"未找到前端目录: {web}")
  147. require_bin("pnpm")
  148. env = dict(os.environ)
  149. # Vite 大项目默认堆易 OOM
  150. env.setdefault("NODE_OPTIONS", "--max-old-space-size=8192")
  151. run(["pnpm", "build:test"], cwd=web, env=env)
  152. index = web / "dist" / "index.html"
  153. if not index.is_file():
  154. die(f"打包未生成 {index}")
  155. def sync_frontend(args: argparse.Namespace, password: Optional[str], root: Path) -> None:
  156. dist = root / "web-admin" / "dist"
  157. if not (dist / "index.html").is_file():
  158. die("本地无 web-admin/dist/index.html,请先打包或去掉 --skip-build")
  159. rsync_to(
  160. args,
  161. password,
  162. f"{dist}/",
  163. f"{REMOTE_DIST}/",
  164. delete=True,
  165. )
  166. # Nginx 读 /root 下静态文件需要可穿越权限
  167. ssh_run(
  168. args,
  169. password,
  170. f"chmod 755 /root && chmod -R a+rX {REMOTE_ROOT}/web-admin",
  171. )
  172. def sync_backend(args: argparse.Namespace, password: Optional[str], root: Path) -> None:
  173. backend = root / "backend"
  174. if not (backend / "start-test.sh").is_file():
  175. die(f"未找到后端: {backend}/start-test.sh")
  176. rsync_to(
  177. args,
  178. password,
  179. f"{backend}/",
  180. f"{REMOTE_BACKEND}/",
  181. excludes=BACKEND_EXCLUDES,
  182. )
  183. def upload_env(args: argparse.Namespace, password: Optional[str], root: Path) -> None:
  184. src = root / "backend" / ".env"
  185. if not src.is_file():
  186. die(f"本机无 {src},无法上传 .env.test")
  187. require_bin("scp")
  188. with tempfile.NamedTemporaryFile(prefix="zkxw-env-", suffix=".test", delete=False) as tmp:
  189. tmp_path = Path(tmp.name)
  190. try:
  191. tmp_path.write_bytes(src.read_bytes())
  192. cmd = [
  193. *scp_argv(password),
  194. str(tmp_path),
  195. f"{remote_target(args)}:{REMOTE_BACKEND}/.env.test",
  196. ]
  197. run(cmd, env=with_sshpass_env(password))
  198. info(f"已上传 {src} → {REMOTE_BACKEND}/.env.test")
  199. finally:
  200. tmp_path.unlink(missing_ok=True)
  201. def remote_pip(args: argparse.Namespace, password: Optional[str]) -> None:
  202. ssh_run(
  203. args,
  204. password,
  205. f"cd {REMOTE_BACKEND} && ./.venv/bin/pip install -r requirements.txt",
  206. )
  207. def restart_backend(args: argparse.Namespace, password: Optional[str]) -> None:
  208. ssh_run(
  209. args,
  210. password,
  211. f"cd {REMOTE_BACKEND} && chmod +x start-test.sh && "
  212. f"HOST={BACKEND_HOST} PORT={BACKEND_PORT} ./start-test.sh restart",
  213. )
  214. def _health_ok_via_ssh(args: argparse.Namespace, password: Optional[str]) -> bool:
  215. """本机回环健康检查(不依赖公网 / Nginx)。"""
  216. remote = (
  217. f"curl -sf --connect-timeout 2 --max-time 5 "
  218. f"http://{BACKEND_HOST}:{BACKEND_PORT}/health"
  219. )
  220. cmd = [*ssh_argv(password), remote_target(args), remote]
  221. cp = subprocess.run(
  222. cmd,
  223. env=with_sshpass_env(password),
  224. capture_output=True,
  225. text=True,
  226. )
  227. return cp.returncode == 0 and "healthy" in (cp.stdout or "")
  228. def _health_ok_via_nginx(args: argparse.Namespace) -> bool:
  229. """经 Nginx 公开入口检查(与验收路径一致)。"""
  230. url = f"http://{args.host}:{PUBLIC_HTTP_PORT}/health"
  231. cp = subprocess.run(
  232. ["curl", "-sf", "--connect-timeout", "3", "--max-time", "8", url],
  233. capture_output=True,
  234. text=True,
  235. )
  236. return cp.returncode == 0 and "healthy" in (cp.stdout or "")
  237. def wait_for_health(
  238. args: argparse.Namespace,
  239. password: Optional[str],
  240. *,
  241. prefer_ssh: bool,
  242. ) -> None:
  243. """
  244. 重启后轮询健康检查,成功后再验收。
  245. prefer_ssh=True:优先 SSH 打 127.0.0.1:10002(刚重启时更准)。
  246. 否则优先 Nginx :10001/health。
  247. """
  248. require_bin("curl")
  249. timeout = max(1, int(args.health_timeout))
  250. interval = max(0.5, float(args.health_interval))
  251. deadline = time.monotonic() + timeout
  252. attempt = 0
  253. info(f"等待后端健康(最多 {timeout}s,间隔 {interval}s)…")
  254. while True:
  255. attempt += 1
  256. ssh_ok = _health_ok_via_ssh(args, password)
  257. nginx_ok = _health_ok_via_nginx(args)
  258. ready = ssh_ok if prefer_ssh else (nginx_ok or ssh_ok)
  259. detail = f"ssh={'ok' if ssh_ok else 'fail'} nginx={'ok' if nginx_ok else 'fail'}"
  260. if ready:
  261. info(f"健康检查通过(第 {attempt} 次,{detail})")
  262. # 本机已通但 Nginx 仍 502 时,再短等公开入口
  263. if prefer_ssh and not nginx_ok:
  264. info("本机 health 已通,等待 Nginx /health …")
  265. nginx_deadline = time.monotonic() + min(15.0, float(timeout))
  266. while time.monotonic() < nginx_deadline:
  267. if _health_ok_via_nginx(args):
  268. info("Nginx /health 已就绪")
  269. break
  270. time.sleep(interval)
  271. else:
  272. info("Nginx /health 仍未就绪,将继续验收(可能失败)")
  273. return
  274. if time.monotonic() >= deadline:
  275. die(
  276. f"等待健康检查超时({timeout}s,共 {attempt} 次,{detail})。"
  277. f"请 SSH 查看: cd {REMOTE_BACKEND} && ./start-test.sh logs",
  278. code=3,
  279. )
  280. print(f"[WAIT] 未就绪(第 {attempt} 次,{detail}),{interval}s 后重试…")
  281. time.sleep(interval)
  282. def verify(args: argparse.Namespace) -> None:
  283. require_bin("curl")
  284. base = f"http://{args.host}:{PUBLIC_HTTP_PORT}"
  285. checks = [
  286. ("首页", f"{base}/", True),
  287. ("health", f"{base}/health", False),
  288. ("login-rsa", f"{base}/api/web/auth/login-rsa-public-key", True),
  289. ]
  290. ok = True
  291. for name, url, code_only in checks:
  292. if code_only:
  293. cp = subprocess.run(
  294. ["curl", "-s", "-o", "/dev/null", "-w", "%{http_code}", "--connect-timeout", "10", url],
  295. capture_output=True,
  296. text=True,
  297. )
  298. code = (cp.stdout or "").strip()
  299. print(f"[VERIFY] {name}: HTTP {code} ({url})")
  300. if code != "200":
  301. ok = False
  302. else:
  303. cp = subprocess.run(
  304. ["curl", "-s", "--connect-timeout", "10", url],
  305. capture_output=True,
  306. text=True,
  307. )
  308. body = (cp.stdout or "").strip()
  309. print(f"[VERIFY] {name}: {body[:120]} ({url})")
  310. if "healthy" not in body:
  311. ok = False
  312. # 10002 应对外不可达
  313. cp = subprocess.run(
  314. [
  315. "curl",
  316. "-s",
  317. "-o",
  318. "/dev/null",
  319. "-w",
  320. "%{http_code}",
  321. "--connect-timeout",
  322. "5",
  323. f"http://{args.host}:{BACKEND_PORT}/health",
  324. ],
  325. capture_output=True,
  326. text=True,
  327. )
  328. code = (cp.stdout or "").strip()
  329. if code in ("", "000") or cp.returncode != 0:
  330. print(f"[VERIFY] 外网 10002: 不可达(符合预期) code={code or 'n/a'}")
  331. else:
  332. print(f"[VERIFY] 外网 10002: 意外可达 HTTP {code}(应仅本机监听)")
  333. ok = False
  334. if not ok:
  335. die("验收未全部通过", code=2)
  336. info("验收通过")
  337. def parse_args(argv: Optional[Sequence[str]] = None) -> argparse.Namespace:
  338. p = argparse.ArgumentParser(
  339. description="ZKXW 测试环境发版(前端 build:test + rsync + backend restart)",
  340. )
  341. p.add_argument("--host", default=DEFAULT_HOST, help=f"测试机 IP(默认 {DEFAULT_HOST})")
  342. p.add_argument("--user", default=DEFAULT_USER, help=f"SSH 用户(默认 {DEFAULT_USER})")
  343. p.add_argument("--password", default=None, help="SSH 密码(不推荐;优先用密码文件或环境变量)")
  344. p.add_argument(
  345. "--password-file",
  346. default=None,
  347. help="密码文件路径(默认:仓库根/测试环境机器的账号密码)",
  348. )
  349. g = p.add_mutually_exclusive_group()
  350. g.add_argument("--frontend-only", action="store_true", help="仅前端打包并上传")
  351. g.add_argument("--backend-only", action="store_true", help="仅同步后端并重启")
  352. g.add_argument("--verify", action="store_true", help="仅跑验收 curl")
  353. p.add_argument("--skip-build", action="store_true", help="前端跳过 pnpm build:test,直接上传已有 dist")
  354. p.add_argument("--skip-restart", action="store_true", help="同步后端后不重启")
  355. p.add_argument("--upload-env", action="store_true", help="上传本机 backend/.env 为远端 .env.test")
  356. p.add_argument("--pip", action="store_true", help="远端执行 pip install -r requirements.txt")
  357. p.add_argument("--no-verify", action="store_true", help="发版后不自动验收")
  358. p.add_argument(
  359. "--health-timeout",
  360. type=int,
  361. default=DEFAULT_HEALTH_TIMEOUT_SEC,
  362. help=f"重启后等待健康检查的最长时间(秒,默认 {DEFAULT_HEALTH_TIMEOUT_SEC})",
  363. )
  364. p.add_argument(
  365. "--health-interval",
  366. type=float,
  367. default=DEFAULT_HEALTH_INTERVAL_SEC,
  368. help=f"健康检查轮询间隔(秒,默认 {DEFAULT_HEALTH_INTERVAL_SEC})",
  369. )
  370. return p.parse_args(argv)
  371. def main(argv: Optional[Sequence[str]] = None) -> None:
  372. args = parse_args(argv)
  373. root = repo_root()
  374. os.chdir(root)
  375. info(f"仓库根目录: {root}")
  376. if args.verify:
  377. verify(args)
  378. return
  379. password = load_password(args, root)
  380. if not password and not os.environ.get("SSH_AUTH_SOCK"):
  381. info("未找到 SSH 密码;将尝试密钥登录(BatchMode)")
  382. elif password:
  383. info("已加载 SSH 密码(来自参数/环境变量/密码文件)")
  384. do_fe = not args.backend_only
  385. do_be = not args.frontend_only
  386. did_restart = False
  387. if do_fe:
  388. if not args.skip_build:
  389. info("开始前端打包 pnpm build:test …")
  390. build_frontend(root)
  391. else:
  392. info("跳过打包,使用现有 web-admin/dist")
  393. info("上传前端 dist …")
  394. sync_frontend(args, password, root)
  395. if do_be:
  396. info("同步后端源码 …")
  397. sync_backend(args, password, root)
  398. if args.upload_env:
  399. upload_env(args, password, root)
  400. if args.pip:
  401. info("远端 pip install …")
  402. remote_pip(args, password)
  403. if not args.skip_restart:
  404. info("重启后端 …")
  405. restart_backend(args, password)
  406. did_restart = True
  407. else:
  408. info("已跳过后端重启")
  409. if not args.no_verify:
  410. if did_restart:
  411. wait_for_health(args, password, prefer_ssh=True)
  412. else:
  413. # 仅前端发版:仍确认当前后端/Nginx 可用再验收
  414. wait_for_health(args, password, prefer_ssh=False)
  415. info("发版后验收 …")
  416. verify(args)
  417. info("完成")
  418. if __name__ == "__main__":
  419. try:
  420. main()
  421. except subprocess.CalledProcessError as e:
  422. die(f"命令失败(exit {e.returncode}): {e.cmd}")
  423. except KeyboardInterrupt:
  424. die("已中断", code=130)