deploy.py 19 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399
  1. """Linux deployment controller. Only manages the API belonging to this checkout."""
  2. import argparse
  3. from contextlib import closing, contextmanager
  4. import ipaddress
  5. import json
  6. import os
  7. from pathlib import Path
  8. import signal
  9. import socket
  10. import sqlite3
  11. import subprocess
  12. import sys
  13. import time
  14. from urllib.request import build_opener, ProxyHandler
  15. ROOT = Path(__file__).resolve().parents[1]
  16. sys.path.insert(0, str(ROOT))
  17. from scripts.memory_service import unit_name
  18. MODULE = 'src.step4_api'
  19. class Deployment:
  20. def __init__(self, root=ROOT):
  21. self.root = Path(root).resolve()
  22. self.output = self.root / 'output/deploy'
  23. self.output.mkdir(parents=True, exist_ok=True)
  24. self.environments = self.output / 'environments'
  25. self.runtime_file = self.output / 'python-path'
  26. self.python = self.root / '.venv/bin/python'
  27. if self.runtime_file.exists() and os.environ.get('DEPLOY_OFFLINE') != '1':
  28. selected = Path(self.runtime_file.read_text(encoding='utf-8').strip())
  29. if not self.owned_python(selected):
  30. raise RuntimeError('invalid_deployment_runtime_path')
  31. self.python = selected
  32. self.pidfile = self.output / 'api.json'
  33. self.log = self.output / 'api.log'
  34. self.prepared = self.root / 'knowledge/prepared'
  35. def run(self, *args, env=None):
  36. subprocess.run([str(a) for a in args], cwd=self.root, check=True, env=env)
  37. def owned_python(self, path):
  38. if not path.is_absolute():
  39. return False
  40. if path == self.root / '.venv/bin/python':
  41. return True
  42. return (path.name == 'python' and path.parent.name == 'bin'
  43. and path.parent.parent.name.startswith('env-')
  44. and path.parent.parent.resolve().parent == self.environments.resolve())
  45. def activate_runtime(self):
  46. temporary = self.runtime_file.with_suffix('.tmp')
  47. temporary.write_text(str(self.python) + '\n', encoding='utf-8')
  48. temporary.replace(self.runtime_file)
  49. def module(self, name, *args):
  50. self.run(self.python, '-m', name, *args)
  51. @contextmanager
  52. def lock(self):
  53. import fcntl
  54. with (self.output / 'deploy.lock').open('a') as handle:
  55. try:
  56. fcntl.flock(handle, fcntl.LOCK_EX | fcntl.LOCK_NB)
  57. except BlockingIOError:
  58. raise RuntimeError('another_deployment_is_running') from None
  59. yield
  60. def identity(self, pid):
  61. """PID + start ticks + boot ID guard against PID reuse and server reboots."""
  62. try:
  63. proc = Path('/proc') / str(int(pid))
  64. stat = (proc / 'stat').read_text().rsplit(')', 1)[1].split()
  65. if stat[0] == 'Z' or (proc / 'cwd').resolve() != self.root:
  66. return None
  67. cmd = (proc / 'cmdline').read_bytes().split(b'\0')
  68. if len(cmd) < 4 or cmd[1:3] != [b'-m', MODULE.encode()]:
  69. return None
  70. if not self.owned_python(Path(os.fsdecode(cmd[0]))):
  71. return None
  72. return {'pid': int(pid), 'start': stat[19],
  73. 'boot': Path('/proc/sys/kernel/random/boot_id').read_text().strip()}
  74. except (FileNotFoundError, ProcessLookupError):
  75. return None
  76. def processes(self):
  77. # Also find a manually launched API using this exact venv and working directory.
  78. found = []
  79. for proc in Path('/proc').iterdir():
  80. if proc.name.isdigit():
  81. try:
  82. identity = self.identity(int(proc.name))
  83. except PermissionError:
  84. continue
  85. if identity:
  86. found.append(identity)
  87. return found
  88. def send(self, identity, sig):
  89. if self.identity(identity['pid']) != identity:
  90. return
  91. # pidfd pins the kernel process; never signal a newly reused numeric PID.
  92. try:
  93. fd = os.pidfd_open(identity['pid'])
  94. except ProcessLookupError:
  95. return
  96. try:
  97. if self.identity(identity['pid']) == identity:
  98. signal.pidfd_send_signal(fd, sig)
  99. finally:
  100. os.close(fd)
  101. def stop(self):
  102. if self.systemd_managed():
  103. self.run('systemctl', 'stop', unit_name(self.root))
  104. processes = self.processes()
  105. for proc in processes:
  106. self.send(proc, signal.SIGTERM)
  107. deadline = time.monotonic() + 35
  108. while any(self.identity(p['pid']) == p for p in processes) and time.monotonic() < deadline:
  109. time.sleep(.2)
  110. for proc in processes:
  111. self.send(proc, signal.SIGKILL)
  112. deadline = time.monotonic() + 5
  113. while any(self.identity(p['pid']) == p for p in processes) and time.monotonic() < deadline:
  114. time.sleep(.1)
  115. if any(self.identity(p['pid']) == p for p in processes):
  116. raise RuntimeError('api_did_not_stop')
  117. self.pidfile.unlink(missing_ok=True)
  118. print('API stopped (only this checkout).', flush=True)
  119. def settings(self):
  120. from src.step4_api.config import load_settings
  121. return load_settings(self.root / '.env')
  122. def healthy(self, host, port):
  123. host = '::1' if host == '::' else '127.0.0.1' if host == '0.0.0.0' else host
  124. authority = f'[{host}]' if ':' in host else host
  125. try:
  126. opener = build_opener(ProxyHandler({}))
  127. with opener.open(f'http://{authority}:{port}/health', timeout=2) as response:
  128. return response.status == 200 and json.load(response).get('status') == 'ready'
  129. except (OSError, ValueError):
  130. return False
  131. def start(self):
  132. settings = self.settings()
  133. if self.systemd_managed():
  134. self.run('systemctl', 'start', unit_name(self.root))
  135. deadline = time.monotonic() + int(os.environ.get('DEPLOY_START_TIMEOUT', '180'))
  136. while time.monotonic() < deadline:
  137. pid = self.systemd_pid()
  138. if (any(proc['pid'] == pid for proc in self.processes()) and
  139. self.healthy(settings.host, settings.port)):
  140. print('Memory-isolated API ready (systemd).', flush=True)
  141. return
  142. time.sleep(1)
  143. raise RuntimeError('systemd_api_health_timeout')
  144. if self.processes():
  145. if self.healthy(settings.host, settings.port):
  146. print('API already running and healthy.', flush=True)
  147. return
  148. raise RuntimeError('existing_api_unhealthy_use_restart')
  149. if not self.manifest_ready():
  150. raise RuntimeError('knowledge_missing_run_update_knowledge')
  151. # Do not mistake another application's /health for successful startup.
  152. family = socket.AF_INET6 if ipaddress.ip_address(settings.host).version == 6 else socket.AF_INET
  153. with socket.socket(family) as probe:
  154. probe.bind((settings.host, settings.port))
  155. with self.log.open('ab') as log:
  156. child = subprocess.Popen([str(self.python), '-m', MODULE], cwd=self.root,
  157. stdin=subprocess.DEVNULL, stdout=log, stderr=log,
  158. start_new_session=True)
  159. identity = None
  160. try:
  161. for _ in range(50):
  162. if child.poll() is not None:
  163. raise RuntimeError('api_exited_before_ready')
  164. identity = self.identity(child.pid)
  165. if identity:
  166. break
  167. time.sleep(.1)
  168. if not identity:
  169. raise RuntimeError('api_identity_unavailable')
  170. self.pidfile.write_text(json.dumps(identity), encoding='utf-8')
  171. deadline = time.monotonic() + int(os.environ.get('DEPLOY_START_TIMEOUT', '180'))
  172. while time.monotonic() < deadline:
  173. if child.poll() is not None:
  174. raise RuntimeError('api_exited_before_ready')
  175. if self.healthy(settings.host, settings.port) and self.identity(child.pid) == identity:
  176. print(f'API ready. PID={child.pid}; log={self.log}', flush=True)
  177. return
  178. time.sleep(1)
  179. raise RuntimeError('api_health_timeout')
  180. except BaseException:
  181. if identity:
  182. self.send(identity, signal.SIGTERM)
  183. elif child.poll() is None:
  184. child.terminate()
  185. try:
  186. child.wait(timeout=35)
  187. except subprocess.TimeoutExpired:
  188. child.kill()
  189. child.wait(timeout=5)
  190. self.pidfile.unlink(missing_ok=True)
  191. raise
  192. def status(self):
  193. processes = self.processes()
  194. if not processes:
  195. print('API stopped.', flush=True)
  196. return 1
  197. settings = self.settings()
  198. ready = self.healthy(settings.host, settings.port)
  199. print(json.dumps({'pids': [p['pid'] for p in processes], 'healthy': ready,
  200. 'log': str(self.log)}), flush=True)
  201. return 0 if ready else 1
  202. def systemd_managed(self):
  203. return (Path('/etc/systemd/system') / unit_name(self.root)).is_file()
  204. def systemd_pid(self):
  205. result = subprocess.run(['systemctl', 'show', '--property=MainPID', '--value', unit_name(self.root)],
  206. check=True, capture_output=True, text=True, timeout=10)
  207. return int(result.stdout.strip())
  208. def serve(self):
  209. # systemd owns the foreground process and its children. Resolve the
  210. # current deployment runtime on every restart, including after sync.
  211. if os.environ.get('API_REQUIRE_MEMORY_CGROUP') != '1':
  212. raise RuntimeError('serve_requires_memory_service')
  213. if self.processes():
  214. raise RuntimeError('existing_api_stop_before_memory_service')
  215. os.execv(str(self.python), [str(self.python), '-m', MODULE])
  216. def sync(self):
  217. if not (self.root / 'uv.lock').is_file():
  218. raise RuntimeError('uv_lock_required')
  219. version = (self.root / '.python-version').read_text().strip()
  220. if os.environ.get('DEPLOY_OFFLINE') == '1':
  221. self.python = self.root / '.venv/bin/python'
  222. if not self.python.is_file():
  223. raise RuntimeError('offline_project_environment_missing')
  224. self.run(self.python, self.root / 'scripts/check-runtime.py', '--environment', self.root / '.venv')
  225. self.run(self.python, '-c',
  226. 'import torch, transformers, pymilvus, redis, langgraph, starlette, uvicorn')
  227. return
  228. # Never mutate packages used by the running API or relocate a venv.
  229. environment = self.environments / f'env-{time.time_ns()}'
  230. environment.mkdir(parents=True)
  231. env = dict(os.environ, UV_PROJECT_ENVIRONMENT=str(environment))
  232. command = ['uv', 'sync', '--locked', '--python', version]
  233. self.run(*command, env=env)
  234. self.python = environment / 'bin/python'
  235. self.run(self.python, self.root / 'scripts/check-runtime.py', '--environment', environment)
  236. def models(self, *, check_only=False):
  237. # Keep download, ingestion and API on exactly the same local BGE path.
  238. from src.step2_knowledge_ingest.ingest import config
  239. actual = config(self.root / '.env')['EMBEDDING_MODEL_PATH'].resolve()
  240. if actual != (self.root / 'models/bge-large-zh-v1.5').resolve():
  241. raise RuntimeError('set_EMBEDDING_MODEL_PATH_to_models_bge_large_zh_v1_5')
  242. local_only = check_only or os.environ.get('DEPLOY_OFFLINE') == '1'
  243. self.run(self.python, self.root / 'scripts/deploy_models.py', *(['--check-only'] if local_only else []))
  244. def manifest_ready(self):
  245. try:
  246. return json.loads((self.prepared / 'ingestion/manifest.json').read_text())['complete'] is True
  247. except (OSError, ValueError, KeyError):
  248. return False
  249. def update_knowledge(self):
  250. stage = self.output / 'knowledge-staging'
  251. stage.mkdir(exist_ok=True)
  252. # SQLite backup includes committed WAL entries; never copy an open DB blindly.
  253. for relative in ('.summary_cache.sqlite3', 'ingestion/embedding_cache.sqlite3'):
  254. source = self.prepared / relative
  255. if source.exists() and not (stage / relative).exists():
  256. destination = stage / relative
  257. destination.parent.mkdir(parents=True, exist_ok=True)
  258. with closing(sqlite3.connect(source)) as old, closing(sqlite3.connect(destination)) as new:
  259. old.backup(new)
  260. print(f'Knowledge staging: {stage}. Remote LLM summaries and Milvus writes follow.', flush=True)
  261. self.module('src.step1_knowledge_prepare', '--output', stage)
  262. self.module('src.step1_knowledge_prepare', '--output', stage, '--verify')
  263. self.module('src.step1_knowledge_prepare.summarize', '--prepared', stage, '--llm-workbook')
  264. self.module('src.step1_knowledge_prepare.summarize', '--prepared', stage, '--llm-workbook', '--verify-only')
  265. self.module('src.step2_knowledge_ingest', '--prepared', stage)
  266. self.module('src.step2_knowledge_ingest', '--prepared', stage, '--verify-only')
  267. self.module('src.step1_knowledge_prepare', '--output', stage, '--verify')
  268. manifest = json.loads((stage / 'ingestion/manifest.json').read_text())
  269. if manifest.get('complete') is not True:
  270. raise RuntimeError('incomplete_ingestion_manifest')
  271. return stage
  272. def publish_knowledge(self, stage):
  273. backup = self.output / f'knowledge-{time.time_ns()}-previous'
  274. self.prepared.parent.mkdir(parents=True, exist_ok=True)
  275. if self.prepared.exists():
  276. self.prepared.rename(backup)
  277. try:
  278. stage.rename(self.prepared)
  279. except BaseException:
  280. if backup.exists():
  281. backup.rename(self.prepared)
  282. raise
  283. print(f'Knowledge published; previous local snapshot (if any): {backup}', flush=True)
  284. def execute(self, action, company_name=None, archive=None, database=None):
  285. libraries = ('policy', 'finance', 'qa', 'article', 'service_guide', 'service_catalog')
  286. if database is not None and (database not in libraries or archive is None):
  287. raise RuntimeError('invalid_knowledge_import_arguments')
  288. if action != 'import-knowledge' and (archive is not None or database is not None):
  289. raise RuntimeError('unexpected_knowledge_import_arguments')
  290. if action == 'serve':
  291. return self.serve() # No deployment lock held for the service lifetime.
  292. with self.lock():
  293. if action == 'import-knowledge':
  294. if not (self.root / '.env').is_file():
  295. raise RuntimeError('configure_dotenv_before_deployment')
  296. snapshot = Path(archive) if archive is not None else self.root / 'output/knowledge-snapshot.zip'
  297. if not snapshot.is_absolute():
  298. snapshot = self.root / snapshot
  299. if not snapshot.is_file():
  300. raise RuntimeError('offline_knowledge_snapshot_missing')
  301. options = ('--database', database) if database else ()
  302. self.run(sys.executable, '-m', 'src.step2_knowledge_ingest.portable', 'import', snapshot, *options)
  303. if not self.manifest_ready():
  304. raise RuntimeError('offline_knowledge_import_incomplete')
  305. return 0
  306. if action in ('check', 'check-milvus', 'check-redis', 'check-dms', 'check-deepseek', 'check-qcc'):
  307. if not (self.root / '.env').is_file():
  308. raise RuntimeError('configure_dotenv_before_check')
  309. from scripts.deploy_check import run
  310. return run(self.root, company_name, action)
  311. if action == 'status':
  312. return self.status()
  313. if action == 'stop':
  314. self.stop()
  315. return 0
  316. if action == 'start':
  317. self.start()
  318. return 0
  319. if not (self.root / '.env').is_file():
  320. raise RuntimeError('configure_dotenv_before_deployment')
  321. self.settings() # Reject invalid listener configuration before preparing.
  322. live = bool(self.processes())
  323. self.sync()
  324. stage = None
  325. if action in ('deploy', 'update-knowledge'):
  326. self.models(check_only=live)
  327. if os.environ.get('DEPLOY_OFFLINE') == '1' and action == 'update-knowledge':
  328. raise RuntimeError('offline_knowledge_update_requires_online_model')
  329. if os.environ.get('DEPLOY_OFFLINE') == '1' and not self.manifest_ready():
  330. snapshot = self.root / 'output/knowledge-snapshot.zip'
  331. if not snapshot.is_file():
  332. raise RuntimeError('offline_knowledge_snapshot_missing')
  333. self.module('src.step2_knowledge_ingest.portable', 'import', snapshot)
  334. if not self.manifest_ready():
  335. raise RuntimeError('offline_knowledge_import_incomplete')
  336. elif action == 'update-knowledge' or not self.manifest_ready():
  337. stage = self.update_knowledge()
  338. # All expensive work and validations precede the interruption.
  339. self.stop()
  340. if stage is not None:
  341. self.publish_knowledge(stage)
  342. self.activate_runtime()
  343. self.start()
  344. return 0
  345. def main():
  346. parser = argparse.ArgumentParser(description=__doc__)
  347. parser.add_argument('action', choices=('deploy', 'import-knowledge', 'update-knowledge', 'restart', 'start', 'stop',
  348. 'status', 'serve', 'check', 'check-milvus', 'check-redis', 'check-dms',
  349. 'check-deepseek', 'check-qcc'))
  350. parser.add_argument('argument', nargs='?', help='Company name for checks, or ZIP path for import-knowledge')
  351. parser.add_argument('database', nargs='?', choices=('policy', 'finance', 'qa', 'article', 'service_guide', 'service_catalog'))
  352. args = parser.parse_args()
  353. if args.action in ('check', 'check-qcc'):
  354. if not args.argument or args.database:
  355. parser.error('check and check-qcc require one exact company name')
  356. elif args.action != 'import-knowledge' and (args.argument is not None or args.database is not None):
  357. parser.error('only import-knowledge accepts a ZIP path and optional library name')
  358. if not sys.platform.startswith('linux'):
  359. parser.error('Linux with /proc and pidfd (kernel >= 5.3) is required')
  360. try:
  361. if args.action == 'import-knowledge':
  362. return Deployment().execute(args.action, archive=args.argument, database=args.database)
  363. return Deployment().execute(args.action, args.argument)
  364. except Exception as exc:
  365. # Never echo subprocess exception command lines, dotenv or service log bodies.
  366. message = str(exc) if isinstance(exc, RuntimeError) else type(exc).__name__
  367. print('Deployment failed: ' + message + '. Check output/deploy/api.log; API may be stopped.', file=sys.stderr)
  368. return 1
  369. if __name__ == '__main__':
  370. raise SystemExit(main())