deploy.py 21 KB

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