log_audit.py 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272
  1. #!/usr/bin/env python3
  2. # -*- coding: utf-8 -*-
  3. r"""日志审计 —— 把"日志目录/命名/格式统一"变成机器每天能查的事 (2026-09-17 用户令 2)。
  4. ## 五条规则
  5. L1 只在 logs/ 下 全仓的 `*.log` / `*.jsonl` 只许出现在 `logs/` (白名单: 产物里的
  6. `analyze_stdout.log` 属**产物附件**, 与运行日志不是一回事, 见登记理由)。
  7. L2 命名 `logs/<组件>.log`(组件 = configs/serve.json 的服务键 + gateway/serve/start_hidden)、
  8. `logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log`、`logs/audit/<名字>.jsonl`。别的名字报出来。
  9. L3 行格式 每一行必须匹配 `YYYY-MM-DD HH:MM:SS LEVEL 组件 消息` (src/logfile.py::LINE_RE)。
  10. **按文件末尾 200 行判**: 启用统一格式之前的旧行不算数 (这批已归档到 logs/legacy/)。
  11. L4 行尾与颜色 logs/ 下的文本日志不许有 CRLF、不许有 ANSI 颜色码 (重定向到文件后颜色码是垃圾字节)。
  12. L5 保留策略 `logs/ops/` 只留最近 20 份 / 30 天; 超出即报 (并可用 --prune 就地清理)。
  13. `logs/audit/*.jsonl` 每行必须是合法 JSON。
  14. ## 退出码
  15. 0 全部通过 · 5 日志跑到 logs/ 外面 · 6 命名不合口径 · 7 行格式不合规 · 8 行尾/颜色问题 · 9 保留策略超限
  16. ## 用法
  17. python scripts/log_audit.py # 检查
  18. python scripts/log_audit.py --prune # 检查并就地清理超限的动作日志
  19. python scripts/log_audit.py --json
  20. """
  21. from __future__ import annotations
  22. try:
  23. from app_common.app_common_guanlan.api import install_root as _install_root
  24. except ImportError: # 理论不可达;包结构异常时回退到按位置上跳
  25. from pathlib import Path as _P
  26. def _install_root(_f): return _P(_f).resolve().parents[4]
  27. import argparse
  28. import json
  29. import os
  30. import pathlib
  31. import re
  32. import sys
  33. ROOT = _install_root(__file__)
  34. sys.path.insert(0, str(ROOT))
  35. from src import logfile as L # noqa: E402
  36. from src import paths as P # noqa: E402
  37. SKIP_WALK = {'.venv', '.git', '.github', '__pycache__', 'node_modules', 'wheels', 'vendor', '.git-*'}
  38. # logs/ 之外的日志白名单: (正则, 理由)。两类:
  39. # ① 产物附件: CMS 插件把"这次分析的 stdout"当产物附件留在产物目录;
  40. # ② 第三方工具的缓存/配置库: npm 缓存、Puppeteer(Chromium) 的 QA profile —— 它们的 000003.log
  41. # 是 LevelDB 内部文件, 不是本系统的运行日志 (删不删是另一件事, 见 docs §11 的清理建议)。
  42. OUTSIDE_OK = [
  43. (re.compile(r'^outputs/.*/windcms/.*analyze_stdout\.log$'),
  44. 'CMS 插件把"这次分析的 stdout"当作产物附件留在产物目录 (属产物, 不是运行日志)'),
  45. (re.compile(r'^(?:.*/)?(?:\.npm-cache|\.qa-profile)/'),
  46. '第三方工具 (npm 缓存 / Chromium QA profile) 自带的状态文件, 非本系统日志'),
  47. (re.compile(r'^(?:.*/)?node_modules/'),
  48. 'node_modules 里的第三方包自带日志'),
  49. ]
  50. TAIL_LINES = 200
  51. def scan_outside() -> list[pathlib.Path]:
  52. found = []
  53. for dp, dn, fns in os.walk(ROOT):
  54. dn[:] = [d for d in dn if d not in SKIP_WALK and not d.startswith('.git')]
  55. rel_dir = pathlib.Path(dp).relative_to(ROOT).as_posix()
  56. if rel_dir == 'logs' or rel_dir.startswith('logs/'):
  57. dn[:] = []
  58. continue
  59. for f in fns:
  60. if f.endswith(('.log', '.jsonl')):
  61. found.append(pathlib.Path(dp) / f)
  62. return found
  63. def tail_lines(p: pathlib.Path, n: int = TAIL_LINES) -> list[str]:
  64. try:
  65. data = p.read_bytes()
  66. except OSError:
  67. return []
  68. txt = data.decode('utf-8', 'replace')
  69. lines = txt.replace('\r\n', '\n').split('\n')
  70. return lines[-n:]
  71. def migrate_product_logs(dry: bool = True) -> list:
  72. """把产物目录里的日志搬进 `logs/build/`, 并同步台账 (用户令 2: 日志不许留在产物里)。
  73. 为什么要搬: 交付包里 39 个 `.log` 躺在 `outputs/<场>/…` 下, 还被 `_provenance.json`
  74. 登记成 `shipped` 随包件 —— 产物台账里混着日志, 排障时也找不到。搬迁后:
  75. · 文件去 `logs/build/<场>/<原相对路径>`;
  76. · 台账里对应条目**删掉并重算计数**, 并写明"因日志归位而移出" (只搬文件不改台账 = 台账失真)。
  77. 返回 (moved, ledger_notes)。
  78. """
  79. moved, notes = [], []
  80. out = P.ROOT / 'outputs'
  81. if not out.is_dir():
  82. return moved, notes
  83. for f in sorted(out.rglob('*.log')):
  84. rel = f.relative_to(out) # 如 rudong/windscada/temp_build.log
  85. dst = L.LOGS / 'build' / rel
  86. moved.append((f, dst))
  87. if dry:
  88. continue
  89. dst.parent.mkdir(parents=True, exist_ok=True)
  90. if dst.exists():
  91. dst = dst.with_name(f'{dst.stem}_{int(dst.stat().st_mtime)}{dst.suffix}')
  92. f.replace(dst)
  93. if dry or not moved:
  94. return moved, notes
  95. for farm_dir in sorted(x for x in out.iterdir() if x.is_dir()):
  96. prov_p = farm_dir / '_provenance.json'
  97. if not prov_p.is_file():
  98. continue
  99. prov = json.loads(prov_p.read_text(encoding='utf-8'))
  100. files = prov.get('files') or {}
  101. drop = [k for k in files if str(k).endswith('.log')]
  102. for k in drop:
  103. files.pop(k, None)
  104. if drop:
  105. counts = {}
  106. for v in files.values():
  107. s = v.get('source') if isinstance(v, dict) else '?'
  108. counts[s] = counts.get(s, 0) + 1
  109. prov['counts'] = counts
  110. prov['files'] = files
  111. prov['note'] = (str(prov.get('note', '')) +
  112. f' | 2026-09-17 因「日志归位」(用户令 2) 移出 {len(drop)} 个 .log 条目: '
  113. f'那批是构建日志, 现位于 logs/build/{farm_dir.name}/ 下')
  114. prov_p.write_text(json.dumps(prov, ensure_ascii=False, indent=1), encoding='utf-8')
  115. notes.append(f'{farm_dir.name}/_provenance.json: 台账移出 {len(drop)} 个 .log 条目并重算计数')
  116. return moved, notes
  117. def audit() -> tuple[int, list, dict]:
  118. res: list[tuple[str, str, str, int]] = []
  119. L.ensure_dirs()
  120. n_svc = n_ops = n_audit = n_build = 0
  121. # L1 只在 logs/ 下
  122. for f in scan_outside():
  123. rel = f.relative_to(ROOT).as_posix()
  124. if any(r.match(rel) for r, _ in OUTSIDE_OK):
  125. why = next(w for r, w in OUTSIDE_OK if r.match(rel))
  126. res.append(('i', rel, f'白名单: {why}', 0))
  127. else:
  128. res.append(('X', rel, '日志跑到 logs/ 外面了 (用户令 2: 运行日志只在 logs/)', 5))
  129. # L2/L3/L4/L5
  130. for f in sorted(L.LOGS.rglob('*')):
  131. if not f.is_file():
  132. continue
  133. rel = f.relative_to(ROOT).as_posix()
  134. if 'legacy' in f.parts:
  135. continue
  136. if f.suffix == '.jsonl':
  137. n_audit += 1
  138. for i, ln in enumerate(tail_lines(f), 1):
  139. if not ln.strip():
  140. continue
  141. try:
  142. json.loads(ln)
  143. except Exception:
  144. res.append(('X', rel, f'审计流水第 {i} 行不是合法 JSON (jsonl 一行一条)', 7))
  145. break
  146. if f.parent != L.AUDIT_DIR:
  147. res.append(('!', rel, '审计流水应放 logs/audit/ 下', 6))
  148. continue
  149. if f.suffix != '.log':
  150. if f.name.endswith('.json'):
  151. n_audit += 1
  152. if f.parent != L.AUDIT_DIR:
  153. res.append(('!', rel, '审计/报告类 JSON 应放 logs/audit/ 下', 6))
  154. continue
  155. if f.parent == L.OPS_DIR:
  156. n_ops += 1
  157. if not re.match(r'^[\w\-]+_\d{8}-\d{6}\.log$', f.name):
  158. res.append(('!', rel, '动作日志命名应为 logs/ops/<动作>_<YYYYmmdd-HHMMSS>.log', 6))
  159. elif L.BUILD_DIR in f.parents:
  160. n_build += 1
  161. # 构建日志: 允许按产物子路径归档 (logs/build/<场>/<原相对路径>.log)。
  162. # 这些是**从产物目录归位过来的历史文件**, 由未随包的构建器写成, 无法追溯重排格式 ⇒ 只提示。
  163. lines = [ln for ln in tail_lines(f) if ln.strip()]
  164. bad = [ln for ln in lines if not L.LINE_RE.match(ln)]
  165. if bad:
  166. res.append(('?', rel, f'历史构建日志 {len(bad)}/{len(lines)} 行不是统一格式 '
  167. f'(写它的人不在本包; 新构建器请用 logfile.build_log() + logfile.line())', 0))
  168. continue # 历史构建日志不再套"服务日志"那套严格检查 (没法追溯重排)
  169. elif f.parent == L.LOGS:
  170. n_svc += 1
  171. if not re.match(r'^[\w\u4e00-\u9fff.\-]+\.log$', f.name):
  172. res.append(('!', rel, '服务日志命名应为 logs/<组件>.log', 6))
  173. else:
  174. res.append(('!', rel, 'logs/ 下只允许 服务日志(顶层)、ops/、audit/、build/ 四处', 6))
  175. raw = f.read_bytes()
  176. if b'\r\n' in raw:
  177. res.append(('?', rel, f'含 CRLF ({raw.count(bytes([13, 10]))} 处) —— 新写日志统一 LF', 0))
  178. if L.ANSI_RE.search(raw.decode('utf-8', 'replace')):
  179. res.append(('?', rel, '含 ANSI 颜色码 (历史行; 重定向到文件后是垃圾字节)', 0))
  180. lines = [ln for ln in tail_lines(f) if ln.strip()]
  181. if lines:
  182. bad = [ln for ln in lines if not L.LINE_RE.match(ln)]
  183. if len(bad) == len(lines):
  184. res.append(('X', rel, f'末尾 {len(lines)} 行都不符合统一格式 (应为 "时间戳 级别 组件 消息")', 7))
  185. elif bad:
  186. res.append(('?', rel, f'末尾 {len(lines)} 行里 {len(bad)} 行不符格式 (多为启用前的旧行)', 0))
  187. # L5 保留策略
  188. over = L.prune_actions(dry=True)
  189. if over:
  190. res.append(('X', f'{P.rel(L.OPS_DIR)}', f'{len(over)} 份动作日志超出保留策略 (最近 {L.ACTION_KEEP} 份 / {L.ACTION_DAYS} 天)', 9))
  191. info = dict(service_logs=n_svc, ops_logs=n_ops, audit_files=n_audit, build_logs=n_build,
  192. ops_over_quota=len(over), logs_dir=str(L.LOGS.relative_to(ROOT)))
  193. rc_map = {r[3] for r in res if r[0] not in ('OK', 'i', '?') and r[3]}
  194. return (max(rc_map) if rc_map else 0), res, info
  195. def main() -> int:
  196. ap = argparse.ArgumentParser()
  197. ap.add_argument('--prune', action='store_true', help='就地清理超限的动作日志')
  198. ap.add_argument('--migrate', action='store_true', help='把产物目录里的日志搬到 logs/build/ 并同步台账')
  199. ap.add_argument('--yes', action='store_true', help='--migrate 时真正执行 (默认只预演)')
  200. ap.add_argument('--json', action='store_true')
  201. a = ap.parse_args()
  202. rc, res, info = audit()
  203. if a.migrate:
  204. moved, notes = migrate_product_logs(dry=not a.yes)
  205. print(f'{"预演" if not a.yes else "已执行"}日志归位: {len(moved)} 个文件 '
  206. f'(产物目录 → logs/build/)')
  207. for src, dst in moved[:5]:
  208. print(f' {src.relative_to(ROOT).as_posix()} → {dst.relative_to(ROOT).as_posix()}')
  209. if len(moved) > 5:
  210. print(f' … 另有 {len(moved) - 5} 个')
  211. for n in notes:
  212. print(f' 台账: {n}')
  213. if not a.yes:
  214. print(' (要真搬请加 --yes)')
  215. rc, res, info = audit()
  216. if a.prune:
  217. gone = L.prune_actions()
  218. print(f'已清理动作日志 {len(gone)} 份: {[g.name for g in gone[:5]]}{" …" if len(gone) > 5 else ""}')
  219. rc, res, info = audit()
  220. if a.json:
  221. print(json.dumps(dict(rc=rc, results=[dict(level=r[0], item=r[1], note=r[2], rc=r[3]) for r in res],
  222. info=info), ensure_ascii=False, indent=1))
  223. return rc
  224. print('== 日志口径 (src/logfile.py, 用户令 2) ==')
  225. print(f' 服务日志 {info["service_logs"]} 份 (logs/<组件>.log) · 动作日志 {info["ops_logs"]} 份 '
  226. f'(logs/ops/, 保留 {L.ACTION_KEEP} 份/{L.ACTION_DAYS} 天) · 审计流水 {info["audit_files"]} 份 (logs/audit/) · '
  227. f'构建日志 {info["build_logs"]} 份 (logs/build/<场>/…)')
  228. print(f' 行格式 {L.line("INFO", "示例", "一行长这样")}')
  229. lvl = {}
  230. for level, item, note, r in res:
  231. lvl[level] = lvl.get(level, 0) + 1
  232. print(f'\n== 检查: 不一致 {lvl.get("X", 0)} · 待处理 {lvl.get("!", 0)} · 提示 {lvl.get("?", 0)} · 白名单 {lvl.get("i", 0)} ==')
  233. for level, item, note, r in res:
  234. if level in ('X', '!'):
  235. print(f' [{level}] {item}: {note}')
  236. seen = set()
  237. for level, item, note, r in res:
  238. if level == '?' and note not in seen:
  239. seen.add(note)
  240. print(f' [?] {item}: {note}')
  241. print(f'结论: {"全部符合口径" if rc == 0 else "见上"}; 退出码 {rc}')
  242. return rc
  243. if __name__ == '__main__':
  244. from src import console
  245. console.soft()
  246. sys.exit(main())