deploy.py 12 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297
  1. #!/usr/bin/env python3
  2. """Linux 部署包安装、版本切换与单进程服务管理。"""
  3. from __future__ import annotations
  4. import argparse
  5. from contextlib import contextmanager
  6. import fcntl
  7. import json
  8. import os
  9. from pathlib import Path
  10. import shutil
  11. import signal
  12. import subprocess
  13. import sys
  14. import tempfile
  15. import time
  16. import urllib.request
  17. sys.dont_write_bytecode = True
  18. from common import validate_package, validation_env, version
  19. PACKAGE = Path(__file__).resolve().parents[1]
  20. SHARED_DIRS = ("data", "workspace", "conversation_history", "large_tool_results")
  21. def default_prefix() -> Path:
  22. return PACKAGE.parent.parent if PACKAGE.parent.name == "releases" else Path.home() / "vent-agent"
  23. @contextmanager
  24. def locked(path: Path):
  25. path.parent.mkdir(parents=True, exist_ok=True)
  26. with path.open("a+") as handle:
  27. try:
  28. fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
  29. except BlockingIOError:
  30. raise ValueError(f"已有操作或实例占用:{path}") from None
  31. try:
  32. yield
  33. finally:
  34. fcntl.flock(handle, fcntl.LOCK_UN)
  35. def running(prefix: Path) -> bool:
  36. try:
  37. with locked(prefix / "shared/run/instance.lock"):
  38. return False
  39. except ValueError:
  40. return True
  41. def record_path(prefix: Path) -> Path:
  42. return prefix / "shared/run/server.json"
  43. def read_record(prefix: Path) -> dict:
  44. try:
  45. return json.loads(record_path(prefix).read_text())
  46. except (OSError, ValueError):
  47. return {}
  48. def start_ticks(pid: int) -> str | None:
  49. try:
  50. # comm 可包含空格和括号;starttime 是右括号后第 20 个字段。
  51. fields = Path(f"/proc/{pid}/stat").read_text().rsplit(")", 1)[1].split()
  52. return None if fields[0] == "Z" else fields[19]
  53. except (OSError, IndexError):
  54. return None
  55. def same_process(record: dict) -> bool:
  56. pid = record.get("pid", 0)
  57. return isinstance(pid, int) and pid > 1 and bool(record.get("start_ticks")) and start_ticks(pid) == record["start_ticks"]
  58. def current_release(prefix: Path) -> Path:
  59. link = prefix / "current"
  60. if not link.is_symlink():
  61. raise ValueError("请先 activate 一个已安装版本")
  62. root = link.resolve(strict=True)
  63. if root.parent != prefix / "releases":
  64. raise ValueError("current 必须指向当前部署目录的 releases/<版本>")
  65. return root
  66. def install(prefix: Path):
  67. manifest = validate_package(PACKAGE)
  68. target = prefix / "releases" / version(manifest["version"])
  69. if target.exists() or target.is_symlink():
  70. raise ValueError(f"版本已安装,不覆盖:{target}")
  71. if prefix == PACKAGE or PACKAGE in prefix.parents or prefix in PACKAGE.parents:
  72. raise ValueError("安装目录与解压目录必须分开")
  73. target.parent.mkdir(parents=True, exist_ok=True)
  74. target.mkdir()
  75. try:
  76. for name in [*manifest["files"], "release-manifest.json"]:
  77. dest = target / name
  78. dest.parent.mkdir(parents=True, exist_ok=True)
  79. shutil.copy2(PACKAGE / name, dest)
  80. python = target / ".venv/bin/python"
  81. subprocess.run([sys.executable, "-m", "venv", str(target / ".venv")], check=True)
  82. subprocess.run([str(python), "-m", "pip", "install", "--disable-pip-version-check",
  83. "--require-hashes", "--only-binary=:all:", "-r", str(target / "release/requirements.lock")], check=True)
  84. subprocess.run([str(python), "-m", "pip", "check"], check=True)
  85. # 自检只操作临时数据;在连接 shared 前执行,升级安装不会触碰线上数据库。
  86. with tempfile.TemporaryDirectory(prefix="vent-install-") as temporary:
  87. subprocess.run([str(python), "scripts/selfcheck.py"], cwd=target,
  88. env=validation_env(Path(temporary)), check=True)
  89. shared = prefix / "shared"
  90. shared.mkdir(exist_ok=True)
  91. for name in (*SHARED_DIRS, "logs", "run"):
  92. (shared / name).mkdir(exist_ok=True)
  93. env_file = shared / ".env"
  94. if not env_file.exists():
  95. fd = os.open(env_file, os.O_WRONLY | os.O_CREAT | os.O_EXCL, 0o600)
  96. with os.fdopen(fd, "wb") as stream:
  97. stream.write((target / ".env.example").read_bytes())
  98. for name in (".env", *SHARED_DIRS):
  99. path = target / name
  100. if path.exists(): # 仅为这个新版本的隔离自检生成的目录
  101. shutil.rmtree(path) if path.is_dir() else path.unlink()
  102. path.symlink_to(shared / name, target_is_directory=name != ".env")
  103. validate_package(target, installed=True)
  104. except BaseException:
  105. shutil.rmtree(target)
  106. raise
  107. print(f"已安装 {manifest['version']}:{target}\n配置:{prefix / 'shared/.env'}\n请配置后执行 activate,再 start。")
  108. def replace_link(path: Path, destination: Path):
  109. temporary = path.with_name(f".{path.name}.{os.getpid()}.tmp")
  110. try:
  111. temporary.symlink_to(destination)
  112. os.replace(temporary, path)
  113. finally:
  114. temporary.unlink(missing_ok=True)
  115. def activate(prefix: Path, selected: str):
  116. if running(prefix):
  117. raise ValueError("请先 stop,再切换版本")
  118. target = prefix / "releases" / version(selected)
  119. if target.is_symlink() or not (target / ".venv/bin/python").is_file():
  120. raise ValueError(f"版本尚未安装:{selected}")
  121. validate_package(target, installed=True)
  122. with locked(prefix / "shared/run/instance.lock"):
  123. current = prefix / "current"
  124. if current.exists() and not current.is_symlink():
  125. raise ValueError("current 已存在且不是符号链接")
  126. if current.is_symlink() and current.resolve() != target:
  127. replace_link(prefix / "previous", current_release(prefix))
  128. replace_link(current, target)
  129. print(f"当前版本:{selected}")
  130. def serve(prefix: Path):
  131. with locked(prefix / "shared/run/instance.lock"):
  132. if current_release(prefix) != PACKAGE:
  133. raise ValueError("启动期间版本已切换,请重试")
  134. validate_package(PACKAGE, installed=True)
  135. os.chdir(PACKAGE)
  136. sys.path.insert(0, str(PACKAGE))
  137. import config
  138. import uvicorn
  139. record = {"pid": os.getpid(), "start_ticks": start_ticks(os.getpid()),
  140. "version": config.APP_VERSION, "release": str(PACKAGE), "host": config.HOST, "port": config.PORT}
  141. path = record_path(prefix)
  142. temporary = path.with_suffix(".tmp")
  143. temporary.write_text(json.dumps(record))
  144. os.replace(temporary, path)
  145. try:
  146. uvicorn.run("main:app", host=config.HOST, port=config.PORT, workers=1,
  147. timeout_graceful_shutdown=60, log_level=config.LOG_LEVEL.lower(),
  148. log_config=None, access_log=False)
  149. finally:
  150. if read_record(prefix).get("pid") == os.getpid():
  151. path.unlink(missing_ok=True)
  152. def health(record: dict) -> bool:
  153. if not same_process(record):
  154. return False
  155. host = record["host"]
  156. host = "127.0.0.1" if host in ("0.0.0.0", "") else "::1" if host == "::" else host
  157. if ":" in host:
  158. host = f"[{host}]"
  159. try:
  160. opener = urllib.request.build_opener(urllib.request.ProxyHandler({}))
  161. with opener.open(f"http://{host}:{record['port']}/health", timeout=2) as response:
  162. result = json.load(response)
  163. return result.get("status") == "ok" and result.get("pid") == record["pid"] and result.get("version") == record["version"]
  164. except (OSError, ValueError):
  165. return False
  166. def stop(prefix: Path, timeout: float):
  167. if not running(prefix):
  168. record_path(prefix).unlink(missing_ok=True)
  169. print("服务已停止")
  170. return
  171. record = read_record(prefix)
  172. if not same_process(record):
  173. raise ValueError("实例锁被占用,但无法确认进程身份;请检查日志,不自动终止其他进程")
  174. # pidfd 避免检查后 PID 被回收再复用时误杀其他进程。
  175. descriptor = os.pidfd_open(record["pid"])
  176. try:
  177. if not same_process(record):
  178. raise ValueError("进程身份已变化,请重新检查状态")
  179. signal.pidfd_send_signal(descriptor, signal.SIGTERM)
  180. finally:
  181. os.close(descriptor)
  182. deadline = time.monotonic() + timeout
  183. while time.monotonic() < deadline:
  184. if not running(prefix):
  185. print("服务已停止")
  186. return
  187. time.sleep(0.2)
  188. raise ValueError("停止超时;服务可能仍在收尾,请检查日志后重试")
  189. def start(prefix: Path, timeout: float):
  190. if running(prefix):
  191. raise ValueError("服务已经运行,请使用 status 查看")
  192. root = current_release(prefix)
  193. validate_package(root, installed=True)
  194. log = prefix / "shared/logs/server.log"
  195. log.parent.mkdir(parents=True, exist_ok=True)
  196. with log.open("ab") as stream:
  197. child = subprocess.Popen([str(root / ".venv/bin/python"), "-B", str(root / "release/deploy.py"),
  198. "_serve", "--prefix", str(prefix)], cwd=root,
  199. stdin=subprocess.DEVNULL, stdout=stream, stderr=subprocess.STDOUT, start_new_session=True)
  200. deadline = time.monotonic() + timeout
  201. while time.monotonic() < deadline:
  202. if child.poll() is not None:
  203. raise ValueError(f"启动失败(退出码 {child.returncode}),请查看 {log}")
  204. record = read_record(prefix)
  205. if record.get("pid") == child.pid and health(record):
  206. print(f"服务已启动:版本 {record['version']},PID {child.pid},端口 {record['port']}\n日志:{log}")
  207. return
  208. time.sleep(0.3)
  209. # 只终止自己创建且尚未回收的子进程。
  210. child.terminate()
  211. try:
  212. child.wait(timeout=timeout)
  213. except subprocess.TimeoutExpired:
  214. pass
  215. raise ValueError(f"启动健康检查超时,已请求停止;请查看 {log}")
  216. def main():
  217. os.umask(0o077)
  218. parser = argparse.ArgumentParser(description=__doc__)
  219. parser.add_argument("command", choices=["install", "activate", "start", "stop", "status", "logs", "run", "_serve"])
  220. parser.add_argument("version", nargs="?")
  221. parser.add_argument("--prefix", type=Path, default=default_prefix())
  222. parser.add_argument("--timeout", type=float, default=90)
  223. args = parser.parse_args()
  224. prefix = args.prefix.expanduser().resolve()
  225. try:
  226. if sys.platform != "linux" or sys.version_info[:2] != (3, 12):
  227. raise ValueError("部署脚本需要 Linux + Python 3.12")
  228. if args.timeout <= 0:
  229. raise ValueError("timeout 必须大于 0")
  230. if args.command == "_serve":
  231. serve(prefix)
  232. elif args.command == "run":
  233. root = current_release(prefix)
  234. python = root / ".venv/bin/python"
  235. os.execv(python, [str(python), "-B", str(root / "release/deploy.py"), "_serve", "--prefix", str(prefix)])
  236. elif args.command == "logs":
  237. subprocess.run(["tail", "-n", "100", "-f", str(prefix / "shared/logs/server.log")], check=True)
  238. elif args.command == "status":
  239. active = running(prefix)
  240. record = read_record(prefix)
  241. ready = active and health(record)
  242. print(json.dumps({"running": active, "healthy": ready, "process": record}, ensure_ascii=False, indent=2))
  243. return 0 if ready else 1
  244. else:
  245. with locked(prefix / "shared/run/control.lock"):
  246. if args.command == "install":
  247. install(prefix)
  248. elif args.command == "activate":
  249. if not args.version:
  250. raise ValueError("用法:service.sh activate <版本>")
  251. activate(prefix, args.version)
  252. elif args.command == "start":
  253. start(prefix, args.timeout)
  254. elif args.command == "stop":
  255. stop(prefix, args.timeout)
  256. except (OSError, ValueError, subprocess.CalledProcessError) as exc:
  257. parser.exit(1, f"操作失败:{exc}\n")
  258. return 0
  259. if __name__ == "__main__":
  260. sys.exit(main())