service.py 15 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""服务注册/卸载/起停/状态 (2026-09-17 用户令 1: 观澜安装为服务)。
  4. Windows : Windows 服务 (SCM)。本器用 `sc.exe` 注册, 服务体是 `scripts\win_service.py`
  5. (ctypes 直连 SCM, 不依赖 pywin32 —— 保持"单包离线零新依赖")。
  6. Linux : systemd 单元 `guanlan.service`, 服务体是 `scripts/service_main.py` (前台守护)。
  7. ## 为什么要有 --dry-run 与前置检查
  8. 注册服务需要**管理员/root**;没有权限时最该做的是"把将要做的事说清楚",而不是抛一句 Access is denied。
  9. 所以本器的每个动作都先过一遍前置检查(权限 / .venv / 版本记录 / 端口),
  10. `--dry-run` 逐条打印将要执行的命令与实际会写的文件内容(systemd 单元全文),一条都不落。
  11. ## 用法
  12. python scripts/service_ctl.py status # 装没装 / 在跑没在跑(不需要权限)
  13. python scripts/service_ctl.py install --dry-run # 只打印: 前置检查 + 将执行的命令 + 单元文件
  14. python scripts/service_ctl.py install # 真注册(Windows 需管理员, Linux 需 root)
  15. python scripts/service_ctl.py start|stop|restart|uninstall
  16. # 注: Windows 的 sc.exe **没有 restart 子命令** ⇒ 本器的 restart = 停 → 等真停稳 → 起(2026-09-28 消缺)
  17. python scripts/service_ctl.py install --user-login # Windows 备选: 不用管理员的"登录自启(Run 键)"
  18. 退出码: 0 成功 · 2 前置条件不满足(如缺 .venv) · 3 权限不足 · 4 平台不支持 · 5 服务操作失败
  19. """
  20. from __future__ import annotations
  21. try:
  22. from app_common.common.app_common_guanlan.api import install_root as _install_root
  23. except ImportError: # 极端兜底(包结构异常时按位置上跳)
  24. from pathlib import Path as _P
  25. def _install_root(_f): return _P(_f).resolve().parents[3]
  26. import argparse
  27. import os
  28. import pathlib
  29. import subprocess
  30. import sys
  31. import time
  32. ROOT = _install_root(__file__)
  33. sys.path.insert(0, str(ROOT))
  34. from src import paths as P # noqa: E402
  35. from src import version as V # noqa: E402
  36. PY = None
  37. UNIT_PATH = pathlib.Path('/etc/systemd/system') / f'{V.SERVICE_NAME}.service'
  38. WIN32 = os.name == 'nt'
  39. def py_exe() -> str:
  40. global PY
  41. if PY is None:
  42. cand = P.venv_python()
  43. PY = str(cand if cand else sys.executable)
  44. return PY
  45. # ── 前置条件 ─────────────────────────────────────────────────────────────────────────
  46. def is_admin() -> bool:
  47. if not WIN32:
  48. return os.geteuid() == 0
  49. try:
  50. import ctypes
  51. return bool(ctypes.windll.shell32.IsUserAnAdmin())
  52. except Exception:
  53. return False
  54. def preflight() -> list[str]:
  55. """→ 问题清单(空 = 可以做)。每条都写成"人能照着解决"的一句话。"""
  56. bad = []
  57. if not (ROOT / 'guanlan.py').is_file():
  58. bad.append(f'安装根不对:{ROOT} 下没有 guanlan.py')
  59. if not pathlib.Path(py_exe()).is_file():
  60. bad.append(f'没找到虚拟环境解释器 {py_exe()} —— 先跑 install.bat / sh install.sh')
  61. if WIN32 and not (ROOT / 'scripts' / 'win_service.py').is_file():
  62. bad.append('缺 scripts\\win_service.py(服务体)')
  63. if not WIN32 and not (ROOT / 'scripts' / 'service_main.py').is_file():
  64. bad.append('缺 scripts/service_main.py(服务体)')
  65. return bad
  66. def unit_text() -> str:
  67. """systemd 单元全文(--dry-run 会原样打印;install 时写进 UNIT_PATH)。
  68. ★ 路径统一用 POSIX 写法并用引号包住(安装目录可能带空格:`/opt/guanlan app`),
  69. 否则 systemd 会把 `ExecStart=/opt/gu anlan/.venv/bin/python` 拆成两个参数。
  70. """
  71. root = pathlib.Path(ROOT).as_posix()
  72. py = pathlib.Path(py_exe()).as_posix()
  73. return f'''[Unit]
  74. Description={V.SERVICE_DISPLAY}
  75. Documentation=file://{root}/docs/系统设计说明.md
  76. After=network-online.target
  77. Wants=network-online.target
  78. [Service]
  79. Type=simple
  80. WorkingDirectory="{root}"
  81. ExecStart="{py}" "{root}/scripts/service_main.py"
  82. ExecStop="{py}" "{root}/guanlan.py" stop
  83. Restart=always
  84. RestartSec=10
  85. TimeoutStopSec=120
  86. KillMode=mixed
  87. # 组件只绑 127.0.0.1;要对外**不要改组件绑定** —— 改 configs/serve.json 的 public_host
  88. # (用户令 2026-09-19「观澜改为监听所有 IP」): 只让门户网关绑所有网卡, 组件照旧只本机。
  89. # 页面无鉴权 ⇒ 对外必须在防火墙侧限来源, 或走 nginx 反代加认证。
  90. Environment=PYTHONUTF8=1
  91. Environment=PYTHONUNBUFFERED=1
  92. [Install]
  93. WantedBy=multi-user.target
  94. '''
  95. def win_commands(action: str) -> list[list[str]]:
  96. """Windows 侧要执行的 sc.exe 命令(--dry-run 原样打印,install/start/... 真跑)。"""
  97. binpath = f'"{py_exe()}" "{ROOT / "scripts" / "win_service.py"}"'
  98. n = V.SERVICE_NAME
  99. if action == 'install':
  100. return [
  101. ['sc.exe', 'create', n, 'binPath=', binpath, 'start=', 'auto', 'DisplayName=', V.SERVICE_DISPLAY],
  102. ['sc.exe', 'description', n, f'{V.NAME} v{V.VERSION} 本机离线版(观澜风电资产智能)'],
  103. # 服务崩溃/被结束时的恢复策略: 60 s 后重启, 连续失败继续重启, 一天后重置计数
  104. ['sc.exe', 'failure', n, 'reset=', '86400', 'actions=', 'restart/60000/restart/60000/restart/60000'],
  105. ['sc.exe', 'start', n],
  106. ]
  107. if action == 'uninstall':
  108. return [['sc.exe', 'stop', n], ['sc.exe', 'delete', n]]
  109. if action == 'restart':
  110. # sc.exe 没有 restart 子命令(这是 Windows 与 systemd 的差异,2017 年就有定论);
  111. # 这里给"停 → 起"两条,真跑时用 win_restart() 在中间等停稳。
  112. return [['sc.exe', 'stop', n], ['sc.exe', 'start', n]]
  113. return [['sc.exe', action, n]]
  114. def wait_stopped(timeout: float = 90.0) -> bool:
  115. """轮询 SCM 直到服务不再是 RUNNING。
  116. `sc.exe stop` 是**异步**的(只回一个 STOP_PENDING),紧接着 start 会吃 1056/1058 错,
  117. 所以中间必须等到真停稳 —— 这是 restart 在 Windows 上唯一可靠的做法。
  118. """
  119. end = time.time() + timeout
  120. while time.time() < end:
  121. if not win_status()['running']:
  122. return True
  123. time.sleep(2)
  124. return False
  125. def win_restart(dry: bool) -> int:
  126. """Windows 的 restart = 停 → 等真停稳 → 起(2026-09-28 消缺:原先直接 `sc.exe restart` 是无效命令)。"""
  127. n = V.SERVICE_NAME
  128. if dry:
  129. rc = run_cmds([['sc.exe', 'stop', n]], True)
  130. print(' (真跑时在此等 SCM 报 STOPPED, 最长 90 s)')
  131. return run_cmds([['sc.exe', 'start', n]], True) or rc
  132. rc = run_cmds([['sc.exe', 'stop', n]], dry)
  133. if dry:
  134. return rc
  135. if not wait_stopped():
  136. print(f' [X] 等待服务停止超时(90 s): 先 `sc.exe query {n}` 看状态, 必要时 taskkill 兜底')
  137. return 5
  138. print(' 已停稳, 重新启动 …')
  139. return run_cmds([['sc.exe', 'start', n]], dry)
  140. def linux_commands(action: str) -> list[list[str]]:
  141. n = V.SERVICE_NAME
  142. if action == 'install':
  143. return [['systemctl', 'daemon-reload'], ['systemctl', 'enable', '--now', n],
  144. ['systemctl', 'status', n, '--no-pager']]
  145. if action == 'uninstall':
  146. return [['systemctl', 'disable', '--now', n], ['rm', '-f', str(UNIT_PATH)], ['systemctl', 'daemon-reload']]
  147. return [['systemctl', action, n]]
  148. def run_cmds(cmds: list[list[str]], dry: bool) -> int:
  149. rc = 0
  150. for c in cmds:
  151. printable = ' '.join(f'"{x}"' if ' ' in x else x for x in c)
  152. print(f' $ {printable}')
  153. if dry:
  154. continue
  155. try:
  156. r = subprocess.run(c, capture_output=True, text=True, errors='replace')
  157. except FileNotFoundError:
  158. print(f' [X] 找不到命令 {c[0]}')
  159. rc = 5
  160. continue
  161. for ln in (r.stdout or '').strip().splitlines():
  162. print(' ' + ln)
  163. for ln in (r.stderr or '').strip().splitlines():
  164. print(' [stderr] ' + ln)
  165. rc = rc or (0 if r.returncode == 0 else 5)
  166. return rc
  167. # ── 状态(不需要权限) ───────────────────────────────────────────────────────────────
  168. def win_status() -> dict:
  169. r = subprocess.run(['sc.exe', 'query', V.SERVICE_NAME], capture_output=True, text=True, errors='replace')
  170. txt = (r.stdout or '') + (r.stderr or '')
  171. installed = r.returncode == 0
  172. running = installed and ('RUNNING' in txt.upper())
  173. return dict(platform='windows', installed=installed, running=running, detail=txt.strip().splitlines()[:6])
  174. def linux_status() -> dict:
  175. if not UNIT_PATH.is_file():
  176. return dict(platform='linux', installed=False, running=False, detail=[f'没有 {UNIT_PATH}'])
  177. r = subprocess.run(['systemctl', 'is-active', V.SERVICE_NAME], capture_output=True, text=True, errors='replace')
  178. return dict(platform='linux', installed=True, running=(r.stdout or '').strip() == 'active',
  179. detail=[f'{UNIT_PATH} 存在', f'is-active: {(r.stdout or "").strip() or (r.stderr or "").strip()[:60]}'])
  180. def user_login_entry(action: str, dry: bool) -> int:
  181. """Windows 备选(**不需要管理员**):把启动器写进当前用户的开机自启(HKCU\\...\\Run)。
  182. 真服务要管理员权限;很多现场账号没有。这条给一个"登录即起"的等价退路,
  183. 并在文档里写明它与真服务的区别(服务=开机即起、无需登录、可被 SCM 恢复;本项=用户登录后起)。
  184. """
  185. import winreg # noqa: PLC0415
  186. key_path = r'Software\Microsoft\Windows\CurrentVersion\Run'
  187. cmd = f'"{py_exe()}" "{ROOT / "scripts" / "guanlan_start_hidden.py"}"'
  188. if dry:
  189. print(f' $ reg add HKCU\\{key_path} /v {V.SERVICE_NAME} /d "{cmd}" /f (= {action})')
  190. return 0
  191. with winreg.OpenKey(winreg.HKEY_CURRENT_USER, key_path, 0, winreg.KEY_SET_VALUE) as k:
  192. if action == 'install':
  193. winreg.SetValueEx(k, V.SERVICE_NAME, 0, winreg.REG_SZ, cmd)
  194. print(f' 已写入 HKCU\\{key_path}\\{V.SERVICE_NAME}')
  195. else:
  196. try:
  197. winreg.DeleteValue(k, V.SERVICE_NAME)
  198. print(' 已移除登录自启项')
  199. except FileNotFoundError:
  200. print(' (本来就没有这项)')
  201. return 0
  202. def main() -> int:
  203. ap = argparse.ArgumentParser()
  204. ap.add_argument('action', choices=('status', 'install', 'uninstall', 'start', 'stop', 'restart', 'selftest'))
  205. ap.add_argument('--dry-run', action='store_true', help='只打印要做的事与将执行的命令, 不落任何改动')
  206. ap.add_argument('--user-login', action='store_true',
  207. help='Windows 备选: 不注册服务, 改成"当前用户登录时自启"(不需要管理员)')
  208. a = ap.parse_args()
  209. print(f'== {V.NAME} v{V.VERSION} 服务化 · {a.action} ({"" if WIN32 else "linux/macos"}) ==')
  210. print(f' 安装根: {ROOT}')
  211. print(f' 解释器: {py_exe()}')
  212. print(f' 管理员/root: {is_admin()}')
  213. if a.action == 'selftest':
  214. import service_worker as SW
  215. return SW.selftest()
  216. st = win_status() if WIN32 else linux_status()
  217. print(f' 当前状态: 已注册={st["installed"]} 运行中={st["running"]}')
  218. for ln in st['detail']:
  219. print(f' {ln}')
  220. if a.action == 'status':
  221. return 0
  222. if a.action == 'install' and a.dry_run:
  223. bad = preflight()
  224. if bad:
  225. print(' 前置条件不满足:')
  226. for b in bad:
  227. print(f' - {b}')
  228. return 2
  229. print(' 前置检查: 通过')
  230. elif a.action == 'install':
  231. bad = preflight()
  232. if bad:
  233. print(' [X] 前置条件不满足:')
  234. for b in bad:
  235. print(f' - {b}')
  236. return 2
  237. if a.action in ('install', 'uninstall', 'start', 'stop', 'restart') and not is_admin() and not a.dry_run \
  238. and not (WIN32 and a.action == 'install' and a.user_login):
  239. print(' [X] 权限不足: 注册/卸载/起停服务需要管理员权限。')
  240. print(' ① 用管理员身份重开终端再执行本命令; 或')
  241. print(' ② 先看它会做什么: python scripts/service_ctl.py install --dry-run')
  242. if WIN32:
  243. print(' ③ 不想提权: python scripts/service_ctl.py install --user-login'
  244. '(当前用户登录时自启, 不是真服务)')
  245. return 3
  246. if WIN32 and a.action == 'install' and a.user_login:
  247. return user_login_entry('install', a.dry_run)
  248. if WIN32 and a.action == 'uninstall' and a.user_login:
  249. return user_login_entry('uninstall', a.dry_run)
  250. if not WIN32 and a.action == 'install':
  251. print(f' 将写入单元文件 {UNIT_PATH}:')
  252. print(' ' + '-' * 66)
  253. for ln in unit_text().rstrip().splitlines():
  254. print(' | ' + ln)
  255. print(' ' + '-' * 66)
  256. if a.dry_run:
  257. run_cmds(linux_commands(a.action), True)
  258. print(' (dry-run: 以上命令未执行)')
  259. return 0
  260. UNIT_PATH.parent.mkdir(parents=True, exist_ok=True)
  261. UNIT_PATH.write_text(unit_text(), encoding='utf-8')
  262. print(f' 已写 {UNIT_PATH}')
  263. if WIN32 and a.action == 'restart':
  264. rc = win_restart(a.dry_run)
  265. if a.dry_run:
  266. print(' (dry-run: 以上命令未执行;去掉 --dry-run 才真做)')
  267. return rc
  268. cmds = win_commands(a.action) if WIN32 else linux_commands(a.action)
  269. rc = run_cmds(cmds, a.dry_run)
  270. if a.dry_run:
  271. print(' (dry-run: 以上命令未执行;去掉 --dry-run 才真做)')
  272. # 装完写安装记录里的运行方式 (install-info.json 的 mode)
  273. if a.action == 'install' and not a.dry_run and rc == 0:
  274. mode = 'windows-service' if WIN32 else 'systemd'
  275. V.write_installed(ROOT, python=py_exe(), mode=mode)
  276. print(f' 已在 {V.info_path(ROOT)} 记录运行方式 = {mode}')
  277. return rc
  278. if __name__ == '__main__':
  279. sys.exit(main())