ops_run.py 4.8 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. """运维控制台的动作执行器 (2026-09-12) —— 跑一条命令, 并把**真实退出码**写回 run/ops_job.json。
  4. 为什么不让网关直接 Popen 就完事: 那样只知道"进程还在不在", 进程一结束就只能猜它成功没成功 ——
  5. 实测就这么吃过一次(停止服务脚本崩在 TypeError, 页面却显示"已停组件服务")。由本执行器包一层,
  6. 它在子进程结束后把 `status/rc/finished` 落盘, 页面就能显示"完成(退出码 0)"或"失败(退出码 1)"。
  7. 日志: 本进程的 stdout/stderr 已被调用方重定向到 logs/ops/<tag>_<时间戳>.log (统一日志口径), 子命令**继承**它,
  8. 所以命令自己的输出原样进日志(页面显示的就是这些尾巴)。
  9. 用法: python scripts/_ops_run.py --tag rebuild -- <命令与参数...>
  10. """
  11. from __future__ import annotations
  12. try:
  13. from app_common.app_common_guanlan.api import install_root as _install_root
  14. except ImportError: # 极端兜底(包结构异常时按位置上跳)
  15. from pathlib import Path as _P
  16. def _install_root(_f): return _P(_f).resolve().parents[3]
  17. import argparse
  18. import json
  19. import os
  20. import pathlib
  21. import subprocess
  22. import sys
  23. import threading
  24. import time
  25. ROOT = _install_root(__file__)
  26. sys.path.insert(0, str(ROOT))
  27. from src import proc as _proc # 无窗口子进程 (2026-09-16 用户令: 不弹命令窗口)
  28. JOB = ROOT / 'run' / 'ops_job.json'
  29. HEARTBEAT_S = 15
  30. def _write(j: dict):
  31. JOB.parent.mkdir(exist_ok=True)
  32. JOB.write_text(json.dumps(j, ensure_ascii=False, indent=1), encoding='utf-8')
  33. def _heartbeat(stop_evt: threading.Event):
  34. """每 15 s 更新 job 文件的 mtime —— 页面据此区分"还在跑"与"被强杀"(pid 会被复用, 只看 pid 会误判)。"""
  35. while not stop_evt.wait(HEARTBEAT_S):
  36. try:
  37. os.utime(JOB, None)
  38. except OSError:
  39. pass
  40. def main() -> int:
  41. ap = argparse.ArgumentParser()
  42. ap.add_argument('--tag', required=True)
  43. ap.add_argument('cmd', nargs=argparse.REMAINDER, help='-- 之后是真正的命令')
  44. a = ap.parse_args()
  45. cmd = [c for c in a.cmd if c != '--']
  46. if not cmd:
  47. print('[X] 没给命令'); return 2
  48. # 统一日志口径 (用户令 2 / docs §11.2): 运维动作的输出也走 "时间戳 级别 组件 消息"。
  49. # 2026-09-17 实测: 动作日志(products_off/rebuild)是**裸命令输出**, 与 logs/<组件>.log 的格式不一致,
  50. # log_audit 会如实报"末尾 N 行都不符合统一格式" —— 这里挂上流包装, 一处解决所有动作日志。
  51. from src import logfile as _lf
  52. _lf.prefix_stdout(f'ops.{a.tag}')
  53. job = {}
  54. try:
  55. job = json.loads(JOB.read_text(encoding='utf-8'))
  56. except Exception:
  57. pass
  58. job.update(kind=a.tag, cmd=' '.join(cmd), pid=os.getpid(), status='running',
  59. started=job.get('started') or time.strftime('%Y-%m-%d %H:%M:%S'))
  60. _write(job)
  61. print(f'$ {" ".join([sys.executable] + cmd)}\n', flush=True)
  62. stop_evt = threading.Event()
  63. threading.Thread(target=_heartbeat, args=(stop_evt,), daemon=True).start()
  64. t0 = time.time()
  65. try:
  66. # 走 src.proc.run: 本进程是被无窗口起的 (没有可见控制台), 裸 spawn 会让 Windows
  67. # 给子进程**新建一个可见控制台窗口** —— 从页面点"重算"就是这个窗口在跑 20 分钟 (2026-09-16 实测)。
  68. # ★stdout/stderr **显式**传本进程的句柄: CREATE_NO_WINDOW 会新建控制台并把标准句柄重指过去,
  69. # 不显式传的话, 子进程 (以及它的子进程) 的输出会掉进那个隐形控制台 —— 2026-09-16 实逮:
  70. # 页面上"重算中"但 logs/ops/ops_rebuild_*.log 里除了命令行一个字都没有。
  71. rc = _proc.run([sys.executable] + cmd, cwd=str(ROOT), env=dict(os.environ),
  72. stdout=sys.stdout, stderr=sys.stderr).returncode
  73. except Exception as e:
  74. print(f'[X] 命令起不来: {type(e).__name__}: {e}', flush=True)
  75. rc = 99
  76. stop_evt.set()
  77. dt = time.time() - t0
  78. job.update(status='done', rc=rc, seconds=round(dt, 1), finished=time.strftime('%Y-%m-%d %H:%M:%S'))
  79. _write(job)
  80. print(f'\n[ops] {a.tag} 结束: 退出码 {rc}, 耗时 {dt:.1f}s', flush=True)
  81. # 子进程直接写 fd, 绕过了流包装 ⇒ 动作结束时把裸输出补成统一格式 (见 logfile.normalize_file)
  82. try:
  83. _lf.normalize_file(pathlib.Path(str(job.get('log') or '')), f'ops.{a.tag}')
  84. except Exception:
  85. pass
  86. return rc
  87. if __name__ == '__main__':
  88. for _s in (sys.stdout, sys.stderr):
  89. try: _s.reconfigure(errors='replace')
  90. except Exception: pass
  91. sys.exit(main())