commit 0827b2399cd106a4d49abb6f0f89a98a13519f27 Author: lou Date: Wed Sep 16 21:03:40 2026 +0800 内核框架 v0.1 首次提交: 引导器 / 内核 / 驱动 + 日志系统 引导器 UEFI.boot.py (纯 stdlib, 内核的管家) 体检 6 项 (解释器/venv/包/驱动目录/PG) / venv 重建 / 包台账 / 内核进程启停记账 / 参数原样透传 内核日志 / 引导器日志 / 内核 stdio 三条道的查看入口都归它 内核 内核/ (常驻调度 = 甲) 命令消费 (PG commands + LISTEN/NOTIFY) / 驱动调用仲裁六条 (越权·成环·无人提供·按需拉起·同锁排队·超时收权) 依赖链巡检 10s / 心跳 300s / 独一份调度锁 (pg_try_advisory_lock 0x65666901) 扫描: 9 条校验 + 契约匹配 + 拓扑排序; 状态: 快照原子写 + 断电收尸判定 db.py 是唯一碰 SQL 的文件 (drivers/driver_state/events/scans/commands/calls/kernel_env/kernel_runs) 日志系统 (2026-09-16 完整化, 设计/04-日志系统.md) 三条道一个文件一种内容: 内核.log (结构化行) / 内核.out.log (命令输出 + 崩溃原文) / 引导器.log (引导器动作) 驱动日志每轮启动前插分隔头; 门槛 log_level / 轮转 log_max_mb + log_keep / -n · -f · --级别 · -g · --json · --全部 实现 内核/日志.py (内核与引导器共用一份); 修掉"命令输出混进日志文件" (实证存档 归档/20260916-日志系统重做前/) 驱动样板 (也是写驱动的示范): Json解码 (oneshot) / 样板常驻 (provides 样板:心跳) / 样例消费器 (needs 只发契约名) 代码标点统一为 ASCII (保留界面用的框线 ─│◄▶ 与表格占位符 —); 已用 AST 等价对比证明逻辑零改动 自测 (真机, 不 mock): 进程 (真起进程真收子树) / 内核 58 项 / 配置 / db 182 项 / 日志 86 项 验收: 试跑引导器.py PASS 11 / FAIL 0 / 残留无; uvx pyright 与 uvx basedpyright 均 0 errors 0 warnings diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..58d1feb --- /dev/null +++ b/.gitignore @@ -0,0 +1,12 @@ +# 运行时产物不进 git (驱动作者写的 配置.efi.json 才是源码) +环境状态.efi.json +运行.efi.json +*/运行.efi.json +**/logs/ +__pycache__/ +*.pyc +.venv/ +.venv.bak-*/ +# 模型产出物 (不是源码; web.json 是 Json解码 驱动的输入样本, 1.json 是 Skill 层样本) +web.json +1.json diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..21a4e76 --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,4 @@ +{ + "python.analysis.extraPaths": ["内核"], + "python.analysis.diagnosticMode": "workspace" +} diff --git a/UEFI.boot.py b/UEFI.boot.py new file mode 100644 index 0000000..1189101 --- /dev/null +++ b/UEFI.boot.py @@ -0,0 +1,1591 @@ +#!/usr/bin/env python3 +"""UEFI.boot.py -- 引导器 (内核的管家). + +[它干什么] + 三件事: 管内核环境 / 管包状态 / 管内核进程. + 驱动不归它管 -- 那是内核的活.分层是一层管一层: 引导器管内核, 内核管驱动. + 它只保证"内核活着,跑得起来": 拉起 / 判活 / 重启 / 收日志, 不干预内核怎么调度驱动. + +[铁律](改这个文件前先读一遍) + 1. 零第三方依赖 -- 引导器自己要是依赖某个包, 那包坏了引导器也起不来, 就**没人来报错了** + (鸡生蛋问题: 所以它只用 stdlib, 连读写 PG 都是 try import psycopg2 的降级写法). + 2. 引导器自己的命令 (自检 / 环境 / 包) PG 连不上**只 WARN** + 照旧写 json 快照, 不算失败. + 透传给内核的命令**不降级** -- 内核的内存就是 PG, 它自己会报错退出. + 一句话分界: "引导器自己的命令 PG 降级, 内核的命令不降级". + 3. 不自动修复 / 不自动装包 / 不自动升级 -- 只有 `环境 重建`,`包 安装` 这两个**显式敲**的命令才动手. + (老板口径: "一搞就炸", 手动控制.) + 4. 顶层只有 4 个词归引导器 (自检 / 环境 / 包 / 内核), 其余 argv **原样透传**内核: + 所以 `启动 <驱动>` 归内核,`内核 启动` 归引导器, 重名不冲突. + +[文件与外部依赖] + 环境.efi.json 项目级唯一配置 (引导器是它唯一的写者, 内核只读) + 环境状态.efi.json 每次体检的快照 (原子替换; .gitignore 盖住) + 内核/内核.py 被移交的内核入口 (引导器不实现它的子命令) + 内核/进程.py 通用进程库 (管内核进程用它, 内核管驱动也用它 -- 只此一份实现) + 内核/db.py 唯一碰 SQL 的文件 (这里只调函数, 不写 SQL) + 内核/文本.py CJK 宽度对齐的表格输出 + +[命令一览](完整帮助见 用法()) + 体检类: (无参数) / --check | 自检 / 环境 [--json] / 环境 重建 + 包类: 包 [--json] / 包 安装 [名...] + 内核类: 内核 状态 | 启动 [--守护] | 停止 | 重启 | 日志 [-n N] [-f] + 其余: 原样透传内核 (列表 / 扫描 / 启动 <驱动> / 停止 <驱动> / 重启 <驱动> / + 状态 / 日志 <驱动> / 事件 / 清单) + +[退出码约定] + 0 正常 + 1 体检有阻塞项 / 内核文件缺失 / 停不干净 + 2 命令不认识 + 其他 内核自己的退出码 (原样返回) +""" + +from __future__ import annotations + +import json +import os +import re +import shutil +import sys +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +# ─────────────────────────────── 路径与常量 ─────────────────────────────── + +# 项目根 = 本文件所在目录 (所以从哪敲都能跑, 不依赖 cwd) +项目根: Path = Path(__file__).resolve().parent +内核目录: Path = 项目根 / "内核" +# 把 内核/ 加进模块搜索路径, 这样能直接 `import 进程` 这种中文模块名 +# (目录名 内核 是中文, 当不了包名, 所以不写 `import 内核.进程`). +sys.path.insert(0, str(内核目录)) + +import db # noqa: E402 (内核/ 下的公用模块) +import 进程 # noqa: E402 +import 文本 # noqa: E402 +import 日志 # noqa: E402 + +环境文件: Path = 项目根 / "环境.efi.json" # 唯一一份配置 (引导器写, 内核只读) +状态文件: Path = 项目根 / "环境状态.efi.json" # 体检快照 (落盘副本, PG 台账才是活真相) +内核入口: Path = 内核目录 / "内核.py" # 被移交的内核入口 +内核日志路径: Path = 日志.内核日志路径(项目根) # 内核的结构化日志行 (内核自己写, 不靠 fd 重定向) +内核输出路径: Path = 日志.内核输出路径(项目根) # 内核 stdout/stderr 原始流 (命令输出 + 崩溃原文) +引导器日志路径: Path = 日志.引导器日志路径(项目根) # 引导器自己的动作 (体检/包/移交/内核启停) +# 注意: 这几个常量**不能**叫"内核日志" -- 那会被下面 `def 内核日志()` 同名遮蔽, 打印出来是 (踩过). + +引导器版本: str = "引导器 v0.1" +内核版本: str = "内核 v0.1" + +左: str = 文本.左 # 表格对齐用的字面量, 转发一份少写几个字 +右: str = 文本.右 + +# 环境.efi.json 缺失时用的默认模板 (引导器是配置的唯一写者, 所以它能生成一份). +# 这几段是"第一次跑起来"的最低要求: 解释器要求 / venv 路径 / 国内源 / 内核必需包 / PG 连接 / 驱动目录. +# db 段的默认值优先吃 PG* 环境变量, 再兜底本机常见位置 -- 换机器不用改代码. +默认环境: dict[str, Any] = { + "efi": 1, + "python": ">=3.11", + "venv": ".venv", + "index": "https://pypi.tuna.tsinghua.edu.cn/simple", # 校网必须走国内源 (GFW) + "packages": [ + { + "name": "psycopg2-binary", + "version": ">=2.9.9", + "import": "psycopg2", + "required": True, + } + ], + "db": { + "name": "efi_kernel", + "host": os.environ.get("PGHOST") or str(Path.home() / "pgdata" / "socket"), + "port": int(os.environ.get("PGPORT") or 5432), + "user": os.environ.get("PGUSER") or os.environ.get("USER") or "lou", + }, + "driver_root": "驱动", + "stop_timeout": 10, # 停进程: SIGTERM 之后等几秒再 SIGKILL + "log_lines": 200, # 日志命令默认显示多少行 + "log_level": "INFO", # 日志门槛: DEBUG / INFO / WARN / ERROR (低于它的不写) + "log_max_mb": 5, # 单份日志超过它就轮转 (改成 <文件>.1, 旧的往后挤) + "log_keep": 3, # 轮转后留几份历史 +} + + +# ─────────────────────────────── 小工具 ─────────────────────────────── + + +def 现在() -> str: + """当前时间, ISO8601 带时区 (+08:00).给人看,写进 json 都用这个格式.""" + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def 本地时间(时间戳: float) -> str: + """unix 时间戳 -> 本机时区的 ISO8601 (进程启动时刻显示用).""" + return datetime.fromtimestamp(时间戳).astimezone().isoformat(timespec="seconds") + + +def 原子写json(路径: Path, 内容: dict[str, Any]) -> None: + """原子写 json: 先写 `<名字>.tmp` 再 os.replace 顶上. + + 参数: + 路径: 目标文件 (父目录不存在会自动建). + 内容: 要序列化的 dict (ensure_ascii=False, 中文不转义, 老板要能直接看). + + 为什么必须原子: + os.replace 在同文件系统里是原子的, 所以读的人永远看到"完整的旧版"或"完整的新版", + 不会看到写了一半的残缺 json (快照文件被读到半截 = 下游把无快照当"没有状态"处理). + """ + 路径.parent.mkdir(parents=True, exist_ok=True) + 临时 = 路径.with_name(路径.name + ".tmp") + 临时.write_text(json.dumps(内容, ensure_ascii=False, indent=2) + "\n", encoding="utf-8") + os.replace(临时, 路径) + + +def 说(行: list[str]) -> None: + """整块打印 (转发给 文本.打印, 一次写完不逐行挤).""" + 文本.打印(行) + + +def 日志上限(配置: dict[str, Any]) -> int: + """单份日志的轮转阈值, 单位字节 (log_max_mb, 默认 5 MB; 0 = 不轮转).""" + 原始 = 配置.get("log_max_mb") + if 原始 is None: + return 日志.默认上限字节 + try: + return max(int(float(str(原始)) * 1024 * 1024), 0) + except (TypeError, ValueError): + return 日志.默认上限字节 + + +def 日志保留(配置: dict[str, Any]) -> int: + """轮转后留几份历史 (log_keep, 默认 3; 0 = 不轮转).""" + try: + return max(int(配置.get("log_keep") or 日志.默认保留份数), 0) + except (TypeError, ValueError): + return 日志.默认保留份数 + + +def 记日志(级别: str, 消息: str, 安静: bool = False) -> None: + """写一条引导器日志: 落 内核/logs/引导器.log, 默认同时打 stderr (带 [级别] 前缀). + + 为什么要有它: 引导器以前只打 stderr, 跑完就没了 -- "上次体检几点,谁改了配置, + 内核是不是它起的"查不到历史. 现在 `日志` 就能翻出来 (引导器自己的动作也是审计对象). + + 参数: + 级别: INFO / WARN / ERROR. + 消息: 内容. + 安静: True 只落文件不打 stderr (给"每次跑都有的流水账"用, 如开始/收工两条; + 这样 stderr 只在真有警告时说话, 不吵着 stdout 的输出). + + 引导器是短命进程 (敲一次跑一次), 所以不设门槛 (全记) + 每次跑前轮转一次 (起日志). + """ + 日志.记(引导器日志路径, 级别, "引导器", 消息, 门槛="", 控制台=False) + if not 安静: + print(f"[{级别}] {消息}", file=sys.stderr, flush=True) + + +def 起日志(配置: dict[str, Any]) -> None: + """引导器开跑第一件事: 轮转自己的日志 (超上限就改名 .1, 别让它无限长).""" + 日志.轮转(引导器日志路径, 日志上限(配置), 日志保留(配置)) + + +def db段(配置: dict[str, Any]) -> dict[str, Any]: + """取配置里的 db 段 (不是对象就给空字典, 让 db.从配置() 去兜默认值, 不在这崩).""" + return 取对象(配置.get("db")) + + +def 取对象(值: Any) -> dict[str, Any]: + """把 json 里取出来的"对象"统一成 dict[str, Any]; 不是字典就给空字典. + + 为什么需要这么一个函数: + json.loads 出来的是 Any (允许), 但 isinstance(x, dict) 窄化出来的只是 dict[Unknown, Unknown] -- + 对窄化后的 dict 取成员, 严格模式会报"类型部分未知". 在这里 cast 一次, 后面所有 .get() 都干净. + 顺便把 None / 字符串这类脏值挡成空字典, 调用方少写一个 if. + """ + if isinstance(值, dict): + return cast(dict[str, Any], 值) + return {} + + +def 取清单(值: Any) -> list[Any]: + """把 json 里取出来的"数组"统一成 list[Any]; 不是数组就给空表 (同上, 为了不留 Unknown).""" + if isinstance(值, list): + return cast(list[Any], 值) + return [] + + +def 驱动目录(配置: dict[str, Any]) -> Path: + """驱动根目录的绝对路径 (配置里写的是相对项目根的路径, 如 "驱动").""" + return (项目根 / str(配置.get("driver_root", "驱动") or "驱动")).resolve() + + +def venv路径(配置: dict[str, Any]) -> Path: + """内核 venv 的绝对路径 (配置里的 venv 相对项目根, 默认 .venv).""" + return (项目根 / str(配置.get("venv", ".venv") or ".venv")).resolve() + + +# ─────────────────────────────── 配置 ─────────────────────────────── + + +def 读环境() -> tuple[dict[str, Any], list[str]]: + """读项目根的 环境.efi.json. + + 返回: + (配置, 警告列表).配置 = 默认模板 被文件内容覆盖后的字典; 警告是要打印给老板的提示. + + 容错策略 (都是"能继续就继续, 但要说清楚"): + * 文件不存在 -> 按默认模板生成一份 (引导器是它唯一写者), 并 WARN 提醒检查 db 段 + * 读不了 / 不是合法 JSON / 顶层不是对象 -> **不覆盖用户文件**, 用默认值继续 + WARN + 顶层 merge 而不是全量替换: 用户少写了某几个键也能跑 (缺的吃默认值), 不会 KeyError. + """ + 警告: list[str] = [] + if not 环境文件.exists(): + 原子写json(环境文件, 默认环境) + 警告.append(f"环境.efi.json 不存在, 已按默认模板生成 (请检查 db 段): {环境文件}") + return dict(默认环境), 警告 + try: + 原文 = 环境文件.read_text(encoding="utf-8") + except OSError as 错: + 警告.append(f"环境.efi.json 读不了 ({错}), 按默认模板继续, 没有覆盖你的文件") + return dict(默认环境), 警告 + try: + 配置 = json.loads(原文) + except json.JSONDecodeError as 错: + 警告.append(f"环境.efi.json 不是合法 JSON ({错}), 按默认模板继续, 没有覆盖你的文件") + return dict(默认环境), 警告 + if not isinstance(配置, dict): + 警告.append("环境.efi.json 顶层不是对象, 按默认模板继续") + return dict(默认环境), 警告 + 合并: dict[str, Any] = dict(默认环境) + 合并.update(取对象(配置)) + return 合并, 警告 + + +def 打印警告(警告: list[str]) -> None: + """把配置层的警告打到 stderr 并记进引导器日志 (不污染 stdout 的表格输出, 重定向时能分清).""" + for 条 in 警告: + 记日志("WARN", 条) + + +# ─────────────────────────────── 版本比较 / 包 ─────────────────────────────── +# 这一段是"自己撸一个够用的版本比较", 不引 packaging 包 -- 引导器零第三方依赖是硬要求. + + +def 解析版本(文本: str) -> tuple[int, ...]: + """把版本串拆成数字段: "2.9.11" -> (2, 9, 11). + + 参数: + 文本: 版本串, 可以带前缀/后缀 (如 "v1.2.3-rc1"). + 返回: + 数字元组; 遇到第一个不是数字开头的段就停. + + 够用不超配: + 只比数字段, 不吃 alpha/beta/rc 的语义 (本项目依赖只用 >= 一种约束). + 这样实现 20 行搞定, 也不用为它引一个第三方库. + """ + 出: list[int] = [] + for 段 in re.split(r"[._\-+]", 文本.strip()): + 命中 = re.match(r"^(\d+)", 段) + if 命中 is None: + break + 出.append(int(命中.group(1))) + return tuple(出) + + +def 比版本(左值: tuple[int, ...], 右值: tuple[int, ...]) -> int: + """比两个已拆好的版本元组 (短的补 0 再比).返回 -1 / 0 / 1.""" + 长 = max(len(左值), len(右值)) + 左补 = 左值 + (0,) * (长 - len(左值)) + 右补 = 右值 + (0,) * (长 - len(右值)) + if 左补 < 右补: + return -1 + if 左补 > 右补: + return 1 + return 0 + + +def 满足要求(实装: str, 要求: str) -> bool: + """判断实装版本是否满足要求串. + + 参数: + 实装: 实际装上的版本 (如 "2.9.13"); 空串 = 没装 = 不满足. + 要求: 约束串, 支持 > >= < <= == != ~= 与逗号分隔的多条件 (如 ">=2.9.9,<3"); 空 = 不校验. + 返回: + 满足 True / 不满足 False. + + 说明: + ~= 按"大于等于 + 主版本相同"处理 (PEP 440 的完全语义没实现, 本项目用不到). + 要求串里没有可识别的比较符时按"不校验"处理 (返回 True), 避免把写错的配置判成缺包. + """ + if not 要求.strip(): + return True + if not 实装.strip(): + return False + 实 = 解析版本(实装) + 命中过 = False + for 条 in 要求.split(","): + 条 = 条.strip() + if not 条: + continue + 命中 = re.match(r"^(>=|<=|==|!=|~=|>|<)\s*(.+)$", 条) + if 命中 is None: + continue + 命中过 = True + 符 = 命中.group(1) + 期 = 解析版本(命中.group(2)) + 关系 = 比版本(实, 期) + if 符 == ">=" and 关系 < 0: + return False + if 符 == ">" and 关系 <= 0: + return False + if 符 == "<=" and 关系 > 0: + return False + if 符 == "<" and 关系 >= 0: + return False + if 符 == "==" and 关系 != 0: + return False + if 符 == "!=" and 关系 == 0: + return False + if 符 == "~=": + if 关系 < 0: + return False + if 实 and 期 and 实[0] != 期[0]: + return False + return True if 命中过 else True + + +def 规范包名(名: str) -> str: + """包名归一化 (PEP 503): 小写 + 把 -/_/. 都折成 -. + + 为什么需要: + `uv pip list` 报 `psycopg2-binary`, 配置里可能写 `psycopg2_binary`, 大小写也可能不同. + 两边都归一化再对表, 不然会出现"明明装了却说缺失". + """ + return re.sub(r"[-_.]+", "-", 名).strip().lower() + + +def 读解释器版本(解释器: Path) -> str: + """问一个解释器它是几版 (跑 `<解释器> -c ...`). + + 返回: + "3.11.15" 这样的串; 跑不起来/读不到返回空串 (调用方按"版本不明"处理, 不在这抛). + """ + 结果 = 进程.跑命令( + [str(解释器), "-c", "import sys;print('%d.%d.%d' % sys.version_info[:3])"], + 超时=15, + ) + if 结果.code != 0 or not 结果.lines: + return "" + return 结果.lines[-1].strip() + + +def 首选python版本(要求: str) -> str: + """从要求串里抠出重建 venv 要用的版本号: ">=3.11" -> "3.11"; 抠不到给 "python3".""" + 命中 = re.search(r"(\d+)\.(\d+)", 要求) + if 命中 is None: + return "python3" + return f"{命中.group(1)}.{命中.group(2)}" + + +@dataclass +class 包要求: + """环境.efi.json 里 packages 的一项 (声明侧).""" + + 名: str # pip 安装名 (如 psycopg2-binary) + 要求: str # 版本约束串; 空 = 不校验 + 导入名: str # import 时用的名字 (给人看/将来校验用) + 必需: bool # True: 缺了就阻塞; False: 只 WARN (可选依赖) + + +@dataclass +class 包状态: + """一项包的核对结果 (台账一行).""" + + 名: str + 要求: str + 实装: str # 实际装上的版本; 空 = 没装 + 状态: str # 一致 / 缺失 / 版本不符 + 必需: bool + + @property + def 一致(self) -> bool: + """装上了且满足约束.""" + return self.状态 == "一致" + + +def 读包要求(配置: dict[str, Any]) -> list[包要求]: + """把配置里的 packages 段解析成 包要求 列表 (跳过没写 name 的脏项).""" + 出: list[包要求] = [] + 原始 = 配置.get("packages") + for 项 in 取清单(原始): + 条目 = 取对象(项) + 名 = str(条目.get("name", "") or "").strip() + if not 名: + continue + 出.append( + 包要求( + 名=名, + 要求=str(条目.get("version", "") or ""), + 导入名=str(条目.get("import", "") or 名), + 必需=bool(条目.get("required", False)), + ) + ) + return 出 + + +def 读实装包(解释器: Path) -> tuple[dict[str, str], str]: + """读 venv 里实际装了哪些包. + + 参数: + 解释器: venv 的 python 路径. + 返回: + (规范包名 -> 版本, 数据来源说明).读不到就返回 ({}, 原因). + + 两条路: + 1. 有 uv 就 `uv pip list --python --format json` (快, 还能读没装 pip 的 venv) + 2. 没 uv 就借解释器自己的 importlib.metadata (纯 stdlib, 慢一点但一定能用) + 为什么两条都要: + 校网/新机器上 uv 不一定在; 而"读包状态"是引导器的核心职责, 不能因为缺 uv 就废掉. + """ + uv = shutil.which("uv") + if uv is not None: + 结果 = 进程.跑命令( + [uv, "pip", "list", "--python", str(解释器), "--format", "json"], 超时=90 + ) + if 结果.code == 0: + 表 = _解析包表("\n".join(结果.lines)) + if 表 is not None: + return 表, f"uv pip list ({uv})" + 记日志("WARN", f"uv pip list 失败 ({结果.detail or 结果.code}), 回落 importlib.metadata") + 代码 = ( + "import importlib.metadata as m, json;" + "print(json.dumps([[d.metadata['Name'] or '', d.version or ''] for d in m.distributions()]))" + ) + 结果 = 进程.跑命令([str(解释器), "-c", 代码], 超时=90) + if 结果.code == 0: + 表 = _解析包表("\n".join(结果.lines)) + if 表 is not None: + return 表, f"importlib.metadata ({解释器})" + return {}, f"读不到包列表: {结果.detail or f'退出码 {结果.code}'}" + + +def _解析包表(原文: str) -> dict[str, str] | None: + """把上面两条路拿到的 json 数组统一成 {规范包名: 版本}.解析失败返回 None. + + 两种格式都吃: [{"name":..., "version":...}] (uv) 和 [[名字, 版本]] (importlib 那条). + """ + try: + 表 = json.loads(原文.strip() or "null") + except json.JSONDecodeError: + return None + if not isinstance(表, list): + return None + 出: dict[str, str] = {} + for 项 in cast(list[Any], 表): + 条目 = 取对象(项) + 名 = str(条目.get("name", "") or "") + if not 名: + continue + 出[规范包名(名)] = str(条目.get("version", "") or "") + return 出 + + +def 核包(要求表: list[包要求], 实装表: dict[str, str]) -> list[包状态]: + """逐条核对: 清单里的包, 实际装上没有,版本对不对. + + 参数: + 要求表: 配置里的 packages. + 实装表: 读实装包() 的结果 (规范包名 -> 版本). + 返回: + 包状态 列表 (顺序跟清单一致), 状态取值: 一致 / 缺失 / 版本不符. + """ + 出: list[包状态] = [] + for 要 in 要求表: + 实 = 实装表.get(规范包名(要.名), "") + if not 实: + 情况 = "缺失" + elif 满足要求(实, 要.要求): + 情况 = "一致" + else: + 情况 = "版本不符" + 出.append(包状态(名=要.名, 要求=要.要求, 实装=实, 状态=情况, 必需=要.必需)) + return 出 + + +# ─────────────────────────────── 体检 ─────────────────────────────── + + +@dataclass +class 检查项: + """体检里的一项 (表格一行).""" + + 序号: int + 名称: str + 通过: bool + 阻塞: bool # 不过的时候要不要拦住"移交内核"; False = WARN 级, 只提示 + 详情: str # 说清楚"现在是什么情况" + 修复: str = "" # 给一条能直接敲的命令; 空串 = 不用修 + + +@dataclass +class 体检报告: + """一次体检的全部结果.既用来打印, 也用来落 环境状态.efi.json 和 PG 台账.""" + + 时间: str + 解释器: Path # 将要跑内核的那个解释器 + 解释器版本: str + python要求: str + venv路径: Path + venv健康: bool + 包: list[包状态] + 驱动目录: Path + 驱动目录通: bool + pg库: db.数据库 + pg通: bool + pg详情: str + 项: list[检查项] + + @property + def 阻塞项(self) -> list[检查项]: + """不过的阻塞项 (它们决定"能不能移交内核").""" + return [条 for 条 in self.项 if 条.阻塞 and not 条.通过] + + @property + def 警告项(self) -> list[检查项]: + """不过的非阻塞项 (PG 这类, 只 WARN).""" + return [条 for 条 in self.项 if not 条.阻塞 and not 条.通过] + + @property + def 通过(self) -> bool: + """可以移交内核 (阻塞项全过即可, PG 不通不拦).""" + return not self.阻塞项 + + @property + def 全通(self) -> bool: + """六项全绿 (用来决定打一行摘要还是打整张表).""" + return all(条.通过 for 条 in self.项) + + +def 检查venv健康(venv: Path, 解释器: Path) -> tuple[bool, str, str]: + """体检第 3 项: venv 是不是**真能用** (不只是"目录在"). + + 参数: + venv: venv 根目录. + 解释器: venv 里的 python. + 返回: + (健康?, 详情, 修复命令). + + 查四件事: + 1. `python -c "import sys;print(sys.prefix)"` 的输出必须等于 venv 绝对路径 + -- 治"目录搬走后解释器还指旧路径"这个病 + 2. 有 pyvenv.cfg (标准 venv 的标志) + 3. cfg 里的 home 指向存在的解释器 (基础解释器被删了就废了) + 4. cfg 记的版本跟实际解释器版本对得上 + -- 治"venv 被换过/被 uv 重建过, 元数据和实际解释器不是一套"这个病 + (本项目真踩过: cfg 还写着老路径建的 3.14.4, 实际被换成 3.11.15; + 只看 sys.prefix 会误判成健康) + """ + 修复 = "环境 重建" + if not 解释器.exists(): + return False, f"没有 {解释器}", 修复 + 结果 = 进程.跑命令([str(解释器), "-c", "import sys;print(sys.prefix)"], 超时=20) + if 结果.code != 0: + 尾巴 = " | ".join(结果.lines[-2:]) or 结果.detail + return False, f"跑不起来 (退出码 {结果.code}): {尾巴}", 修复 + 前缀 = 结果.lines[-1].strip() if 结果.lines else "" + 期望 = str(venv) + if 前缀 != 期望: + return False, f"sys.prefix={前缀} != {期望} (目录搬走过的典型症状: 解释器还指旧路径)", 修复 + cfg = 读pyvenvcfg(venv / "pyvenv.cfg") + if not cfg: + return False, "没有 pyvenv.cfg (不是标准 venv)", 修复 + 家 = cfg.get("home", "") + if not 家 or not Path(家).exists(): + return False, f"pyvenv.cfg 的 home={家 or '(空)'} 不存在", 修复 + 实际 = 读解释器版本(解释器) + cfg版本 = cfg.get("version", "") or cfg.get("version_info", "") + 规整cfg = 解析版本(cfg版本) + 规整实 = 解析版本(实际) + if 规整cfg and 规整实 and 规整实[: len(规整cfg)] != 规整cfg: + return ( + False, + f"pyvenv.cfg 记的版本 {cfg版本} 与实际解释器 {实际} 对不上 (venv 被换过/被搬过)", + 修复, + ) + return True, f"sys.prefix 正确, cfg home={家}, 版本 {实际}", "" + + +def 读pyvenvcfg(路径: Path) -> dict[str, str]: + """读 venv 的 pyvenv.cfg (key = value 的纯文本, 一行一条).读不到给空字典.""" + 出: dict[str, str] = {} + try: + 原文 = 路径.read_text(encoding="utf-8", errors="replace") + except OSError: + return 出 + for 行 in 原文.splitlines(): + if "=" not in 行: + continue + 键, _, 值 = 行.partition("=") + 出[键.strip()] = 值.strip() + return 出 + + +def 检查驱动目录(路径: Path) -> tuple[bool, str]: + """体检第 5 项: 驱动根目录在不在,能不能写. + + 返回: + (通过?, 详情). + + 为什么真写一个探针文件: + "目录存在"和"能写"是两回事 (权限/只读挂载).真建一个再删, 才敢说可读写. + 为什么不自动建空目录: + 驱动目录不存在 = 项目没初始化好, 这是要人看的事.自动建个空的会掩盖问题 (静默假成功). + """ + if not 路径.exists(): + return False, f"{路径} 不存在 (不自动建空目录)" + if not 路径.is_dir(): + return False, f"{路径} 不是目录" + 探针 = 路径 / f".efi-写测试-{os.getpid()}.tmp" + try: + 探针.write_text("probe", encoding="utf-8") + 探针.unlink() + except OSError as 错: + return False, f"{路径} 不可写: {错}" + return True, f"{路径} 可读写" + + +def 检查PG(库: db.数据库) -> tuple[bool, str, str]: + """体检第 6 项: PG 能连吗 (实例在 + 库存在 + 真连一次). + + 参数: + 库: 从 环境.efi.json 的 db 段来的连接信息. + 返回: + (通?, 详情, 修复命令). + + 三级降级检查 (引导器必须永远能报出个结论): + 1. 有 psycopg2 -> 连 postgres 库查 pg_database (库不存在时目标库根本连不上去), + 再对目标库真连一次 (光"库在"不代表权限对) + 2. 没 psycopg2 但系统有 psql -> 用 psql 子进程试连, 靠报错里有没有 "does not exist" 区分 + "实例没起"和"库没建" (两者修复命令不同) + 3. 都没有 -> 只报 socket 文件在不在, 明确说"没法真连" (不假装通过) + """ + 起库 = f"pg_ctl -D {Path(库.host).parent} start" + 建库 = f"createdb -h {库.host} -p {库.port} -U {库.user} {库.name}" + if not db.有psycopg2: + psql = shutil.which("psql") + if psql is not None: + 结果 = 进程.跑命令( + [psql, "-h", 库.host, "-p", str(库.port), "-U", 库.user, "-d", 库.name, "-tAc", "select 1"], + 超时=15, + ) + if 结果.code == 0: + return True, f"{库.描述()} 真连通过 (psql)", "" + if "does not exist" in " ".join(结果.lines): + return False, f"实例通了, 但库 {库.name} 不存在", 建库 + return False, f"psql 连不上: {' | '.join(结果.lines[-2:]) or 结果.detail}", 起库 + 套接字 = Path(库.host) / f".s.PGSQL.{库.port}" + if 套接字.exists(): + return False, f"缺 psycopg2 / psql, 只看到 socket 在 ({套接字}), 没法真连", "包 安装" + return False, f"没有 socket ({套接字}), 也没 psycopg2", 起库 + try: + 连接 = db.连(库, 超时=5.0, 库名="postgres") + except Exception as 错: + return False, f"连不上实例: {错}", 起库 + try: + 存在 = db.库存在(连接, 库.name) + except Exception as 错: + return False, f"查库存在失败: {错}", "" + finally: + 连接.close() + if not 存在: + return False, f"实例通了, 但库 {库.name} 不存在", 建库 + try: + 连接 = db.连(库, 超时=5.0) + except Exception as 错: + return False, f"库在, 但连不上: {错}", "" + finally: + 连接.close() + return True, f"{库.描述()} 真连通过", "" + + +def 体检(配置: dict[str, Any]) -> 体检报告: + """跑完六项检查, 汇总成一份报告 (只读不写, 落盘由调用方决定). + + 参数: + 配置: 读环境() 的结果. + 返回: + 体检报告. + + 六项的阻塞级别 (设计 03 §5 + §10 的分界): + 1 解释器版本 阻塞 -- 解释器不对, 内核跑不起来 + 2 venv 存在 阻塞 + 3 venv 健康 阻塞 + 4 包状态 阻塞 (只有**必需**包缺失才阻塞; 可选包缺失只写进详情) + 5 驱动目录可读写 阻塞 + 6 PG 能连 不阻塞 -- 引导器自己的命令要能在 PG 挂了时报出原因, 不能自己也起不来; + 真需要 PG 的是内核, 它自己会报错退出 + 解释器取哪个: + venv 在就用 venv 的 (内核实际跑在它下面); venv 不在就用系统 python3 先判版本对不对. + """ + venv = venv路径(配置) + venv解释器 = venv / "bin" / "python" + 系统python = shutil.which("python3") + 系统路径 = Path(系统python) if 系统python is not None else Path(sys.executable) + 用解释器 = venv解释器 if venv解释器.exists() else 系统路径 + 实际版本 = 读解释器版本(用解释器) + 要求 = str(配置.get("python", "") or "") + 项: list[检查项] = [] + + # 1 解释器版本 + 过1 = bool(实际版本) and 满足要求(实际版本, 要求) + 详情1 = ( + f"{用解释器} = {实际版本} (要求 {要求 or '不限'})" + if 过1 + else f"需要 {要求 or '不限'}, 当前 {用解释器} = {实际版本 or '读不到版本'}" + ) + 项.append(检查项(1, "解释器版本", 过1, True, 详情1, "" if 过1 else f"uv python install {首选python版本(要求)}")) + + # 2 venv 存在 + 存在 = venv解释器.exists() + 项.append( + 检查项(2, "venv 存在", 存在, True, str(venv解释器) if 存在 else f"没有 {venv解释器}", "" if 存在 else "环境 重建") + ) + + # 3 venv 健康 + 健康, 详情3, 修复3 = 检查venv健康(venv, venv解释器) + 项.append(检查项(3, "venv 健康", 健康, True, 详情3, 修复3)) + + # 4 包状态 (venv 不在就没法读清单, 直接判不过) + 要求表 = 读包要求(配置) + 实装表: dict[str, str] = {} + 来源 = "venv 不在, 没法读包" + if 存在: + 实装表, 来源 = 读实装包(venv解释器) + 包表 = 核包(要求表, 实装表) + 缺必需 = [条 for 条 in 包表 if 条.必需 and not 条.一致] + 缺可选 = [条 for 条 in 包表 if not 条.必需 and not 条.一致] + if not 要求表: + 过4 = True + 详情4 = "清单为空 (环境.efi.json 的 packages)" + elif not 存在: + 过4 = False + 详情4 = f"venv 不在, {len(要求表)} 项没法核" + elif 缺必需: + 过4 = False + 详情4 = "必需包不满足: " + ", ".join( + f"{条.名}(要 {条.要求 or '任意'}, 实装 {条.实装 or '无'})" for 条 in 缺必需 + ) + else: + 过4 = True + 详情4 = f"{len(包表)} 项全部一致 ({来源})" + if 过4 and 缺可选: + 详情4 += "; 可选缺失: " + ", ".join(f"{条.名}({条.状态})" for 条 in 缺可选) + 项.append(检查项(4, "包状态", 过4, True, 详情4, "" if 过4 else "包 安装")) + + # 5 driver_root 可读写 + 驱动 = 驱动目录(配置) + 驱动通, 详情5 = 检查驱动目录(驱动) + 项.append(检查项(5, "驱动目录可读写", 驱动通, True, 详情5, "" if 驱动通 else f"mkdir -p {驱动}")) + + # 6 PG 能连 (不阻塞, 见函数头的说明) + 库 = db.从配置(db段(配置)) + pg通, 详情6, 修复6 = 检查PG(库) + 项.append(检查项(6, "PG 能连", pg通, False, 详情6, 修复6)) + + return 体检报告( + 时间=现在(), + 解释器=用解释器, + 解释器版本=实际版本, + python要求=要求, + venv路径=venv, + venv健康=健康, + 包=包表, + 驱动目录=驱动, + 驱动目录通=驱动通, + pg库=库, + pg通=pg通, + pg详情=详情6, + 项=项, + ) + + +# ─────────────────────────────── 渲染 ─────────────────────────────── + + +def 打印体检(报告: 体检报告) -> list[str]: + """把体检报告渲染成表格行 (不打印, 由调用方决定跟别的输出怎么拼). + + 结果列取值: 通过 / 不过 (阻塞项) / WARN (非阻塞项). + 末尾一定会打印"判定"行 + 阻塞项清单 (带修复命令), 让老板知道下一步敲什么. + """ + 表: list[list[str]] = [] + for 条 in 报告.项: + 结果 = "通过" if 条.通过 else ("不过" if 条.阻塞 else "WARN") + 表.append([str(条.序号), 条.名称, 结果, 条.详情, 条.修复 or "—"]) + 行: list[str] = [f"环境体检 {报告.时间} ({引导器版本} / {内核版本})"] + 行.extend(文本.表格(["#", "检查", "结果", "说明", "修复命令"], 表, [右, 左, 左, 左, 左])) + 行.append("") + 判定 = "通过" if 报告.通过 else "不过" + 行.append( + f"判定: {判定} 阻塞项 {len(报告.阻塞项)} / 警告 {len(报告.警告项)} " + f"解释器 {报告.解释器版本 or '?'} venv {'健康' if 报告.venv健康 else '不健康'} " + f"PG {'通' if 报告.pg通 else '不通'}" + ) + if 报告.阻塞项: + 行.append("阻塞项不拦引导器自己的命令, 但拦移交内核 (修好再透传):") + for 条 in 报告.阻塞项: + 行.append(f" #{条.序号} {条.名称}: {条.详情}" + (f" -> {条.修复}" if 条.修复 else "")) + return 行 + + +def 摘要行(报告: 体检报告) -> str: + """全绿时的一行摘要 (透传前打这行, 不刷整张表 -- 老板要能一眼看到"能跑").""" + 包好 = sum(1 for 条 in 报告.包 if 条.一致) + return ( + f"体检通过: 解释器 {报告.解释器版本} | venv 健康 | 包 {包好}/{len(报告.包)} | " + f"驱动目录 可写 | PG {'通' if 报告.pg通 else '不通(WARN)'}" + ) + + +def 报告快照(报告: 体检报告) -> dict[str, Any]: + """报告 -> 落盘的 json 结构 (键一律英文给机器读, 内容中文给人读). + + 这份结构同时写进: 环境状态.efi.json (文件副本) 和 PG 的 kernel_env 表 (台账). + 一份数据两处落, 所以字段名在 db.记体检() 里也按这套键取. + """ + return { + "efi": 1, + "ts": 报告.时间, + "generator": 引导器版本, + "kernel": 内核版本, + "blocking_ok": 报告.通过, # 阻塞项全过 = 可以移交内核 + "all_ok": 报告.全通, # 六项全绿 + "python": {"path": str(报告.解释器), "version": 报告.解释器版本, "require": 报告.python要求}, + "venv": {"path": str(报告.venv路径), "healthy": 报告.venv健康}, + "packages": [ + {"name": 条.名, "want": 条.要求, "got": 条.实装, "ok": 条.一致, "required": 条.必需} + for 条 in 报告.包 + ], + "driver_root": {"path": str(报告.驱动目录), "ok": 报告.驱动目录通}, + "pg": { + "name": 报告.pg库.name, + "host": 报告.pg库.host, + "port": 报告.pg库.port, + "user": 报告.pg库.user, + "ok": 报告.pg通, + "detail": 报告.pg详情, + }, + "checks": [ + { + "no": 条.序号, + "name": 条.名称, + "ok": 条.通过, + "blocking": 条.阻塞, + "detail": 条.详情, + "fix": 条.修复, + } + for 条 in 报告.项 + ], + } + + +# ─────────────────────────────── 记账 (PG) ─────────────────────────────── + +# 进程内缓存一条 PG 连接: 一次引导器调用里要写两次台账 (体检 + 运行), 没必要连两遍. +_PG连接: dict[str, Any] = {} + + +def PG连接(配置: dict[str, Any]) -> tuple[Any, str]: + """拿一条 PG 连接 (尽力而为, 不给就返回 None + 原因). + + 参数: + 配置: 读环境() 的结果. + 返回: + (连接 或 None, 人读说明).说明不管成功失败都拿来说给老板听. + + 为什么"失败不抛": + 引导器自己的命令 (自检/环境/包) 在 PG 挂了的时候也必须能跑,能报错,能给出修复命令. + 连上后顺手把引导器自己那两张台账表建好 (db.建引导器表, 幂等, 不碰驱动程序的地盘). + """ + if "连接" in _PG连接: + return _PG连接["连接"], _PG连接["说明"] + if not db.有psycopg2: + 说明 = f"缺 psycopg2 (装: 包 安装) -- 引导器降级, 只写 json 快照" + _PG连接["连接"], _PG连接["说明"] = None, 说明 + return None, 说明 + 库 = db.从配置(db段(配置)) + try: + 连接 = db.连(库, 超时=5.0) + except Exception as 错: + 说明 = f"连不上 PG ({库.描述()}): {错}" + _PG连接["连接"], _PG连接["说明"] = None, 说明 + return None, 说明 + try: + db.建引导器表(连接) + 说明 = f"已连 {库.描述()}" + except Exception as 错: + 说明 = f"连上了 {库.描述()}, 但建引导器表失败: {错}" + _PG连接["连接"], _PG连接["说明"] = 连接, 说明 + return 连接, 说明 + + +def 写快照(报告: 体检报告) -> None: + """写 环境状态.efi.json (必做, 原子替换; 失败会抛, 由调用方决定怎么办).""" + 原子写json(状态文件, 报告快照(报告)) + + +def PG记体检(配置: dict[str, Any], 报告: 体检报告) -> None: + """写 kernel_env 台账一行 (尽力而为: 连不上/写不进只 WARN, 不算失败). + + 这条就是"引导器自己的命令 PG 降级"的落地: 体检结论永远落到 json 快照, PG 有就再记一份台账. + """ + 连接, 说明 = PG连接(配置) + if 连接 is None: + 记日志("WARN", f"{说明}; 只写了 {状态文件.name}") + return + try: + db.记体检(连接, 报告快照(报告)) + except Exception as 错: + 记日志("WARN", f"写 kernel_env 失败: {错}") + + +# ─────────────────────────────── 内核解释器 / 例行体检 ─────────────────────────────── + + +def 内核解释器(配置: dict[str, Any]) -> tuple[Path, str]: + """决定用哪个解释器跑内核. + + 返回: + (解释器路径, 警告文本).警告非空时调用方会打出来. + + 顺序: venv 的 python -> 系统 python3 -> 当前解释器 (一路降级但都明说). + 为什么宁可降级也不要直接失败: + 内核缺依赖会自己报错 (它连不上 PG 就退出), 引导器的职责是"把它拉起来 + 如实报告". + """ + venv解释器 = venv路径(配置) / "bin" / "python" + if venv解释器.exists(): + return venv解释器, "" + 系统python = shutil.which("python3") + if 系统python is not None: + return Path(系统python), f"venv 不在, 回落系统 python3 ({系统python}) -- 先 `环境 重建`" + return Path(sys.executable), f"连 python3 都没有, 回落当前解释器 ({sys.executable})" + + +def 例行体检(配置: dict[str, Any], 详细: bool) -> 体检报告: + """标准三件套: 体检 -> 写快照 -> 记 PG 台账, 然后按需打印. + + 参数: + 配置: 读环境() 的结果. + 详细: True 一定打整张表; False 只在"有问题"时打表, 全绿就打一行摘要. + 返回: + 体检报告 (调用方拿 报告.通过 决定要不要往下走). + """ + 报告 = 体检(配置) + 写快照(报告) + PG记体检(配置, 报告) + if 详细 or not 报告.全通: + 说(打印体检(报告)) + else: + print(摘要行(报告)) + return 报告 + + +# ─────────────────────────────── 命令: 自检 / 环境 / 包 ─────────────────────────────── + + +def 命令自检(配置: dict[str, Any]) -> int: + """`--check` / `自检`: 只体检, 不启内核.退出码 = 有没有阻塞项 (方便脚本用).""" + 报告 = 例行体检(配置, 详细=True) + return 0 if 报告.通过 else 1 + + +def 命令环境(参数: list[str], 配置: dict[str, Any]) -> int: + """`环境 [--json]` 体检; `环境 重建` 重建 venv (显式命令). + + --json 走单独分支: 机器读的输出里不能混人读的表格 (否则 `环境 --json > x.json` 解析不了). + 两条路都落快照 + 记台账 (体检结论永远留痕). + """ + if 参数 and 参数[0] == "重建": + return 环境重建(配置) + if "--json" in 参数: + 报告 = 体检(配置) + 写快照(报告) + PG记体检(配置, 报告) + print(json.dumps(报告快照(报告), ensure_ascii=False, indent=2)) + return 0 if 报告.通过 else 1 + 报告 = 例行体检(配置, 详细=True) + return 0 if 报告.通过 else 1 + + +def 环境重建(配置: dict[str, Any]) -> int: + """`环境 重建`: 按 python 要求重建 venv (只在这个显式命令里动手). + + 步骤: + 1. 旧 venv **改名留退路** (`.venv.bak-<时间戳>`), 不直接删 -- 万一新的更糟还能退回去 + 2. uv 在就 `uv venv --python <版本>`, 不在就回落 `python -m venv` (并 WARN) + 3. 输出实时转发 (老板要看得见进度), 跑完再体检一遍 + 4. 有包没装 -> 只提示 `包 安装`, **不自动装** (手动控制口径) + + 返回: + 退出码: 改名失败 1 / 建 venv 失败 1 / 体检有阻塞项 1 / 否则 0. + """ + venv = venv路径(配置) + uv = shutil.which("uv") + 要求 = str(配置.get("python", "") or "") + 首选 = 首选python版本(要求) + if venv.exists(): + 备份 = venv.with_name(f"{venv.name}.bak-{datetime.now().strftime('%Y%m%dT%H%M%S')}") + try: + os.replace(venv, 备份) + except OSError as 错: + print(f"[错误] 旧 venv 改名失败 ({错}), 不动它") + return 1 + print(f"[信息] 旧 venv 已改名留退路: {备份}") + if uv is not None: + 命令 = [uv, "venv", "--python", 首选, str(venv)] + else: + print(f"[WARN] 没有 uv, 回落 {sys.executable} -m venv") + 命令 = [sys.executable, "-m", "venv", str(venv)] + print("$ " + " ".join(命令)) + 结果 = 进程.跑并转发(命令, cwd=项目根, 尾部行数=40) + print(f"退出码 {结果.code} 耗时 {结果.seconds:.1f}s") + if 结果.code != 0: + print(f"[错误] 重建失败: {' | '.join(结果.lines[-3:])}") + return 1 + 报告 = 例行体检(配置, 详细=True) + 缺 = [条 for 条 in 报告.包 if not 条.一致] + if 缺: + print(f"还有 {len(缺)} 个包没装, 显式敲: 包 安装") + return 0 if 报告.通过 else 1 + + +def 命令包(参数: list[str], 配置: dict[str, Any]) -> int: + """`包 [--json]` 打台账表; `包 安装 [名...]` 装包 (显式命令). + + 返回: + 退出码: 有**必需**包不满足 1 (可选包缺失不算失败) / 否则 0. + """ + if 参数 and 参数[0] == "安装": + return 包安装(参数[1:], 配置) + 报告 = 体检(配置) # 这里只要包状态, 不落快照/台账 (那是 环境/自检 的活) + if "--json" in 参数: + 数据 = [ + {"name": 条.名, "want": 条.要求, "got": 条.实装, "ok": 条.一致, "required": 条.必需} + for 条 in 报告.包 + ] + print(json.dumps(数据, ensure_ascii=False, indent=2)) + return 0 + 表 = [ + [条.名, 条.要求 or "—", 条.实装 or "—", 条.状态, "必需" if 条.必需 else "可选"] + for 条 in 报告.包 + ] + 行 = [f"包状态 venv {报告.venv路径}"] + 行.extend(文本.表格(["包名", "要求", "实装", "状态", "必需性"], 表, [左, 左, 左, 左, 左])) + 未满足 = [条 for 条 in 报告.包 if not 条.一致] + 行.append("") + 行.append( + f"共 {len(报告.包)} 项, 不满足 {len(未满足)} 项" + + (" 显式敲: 包 安装 (引导器不自动装)" if 未满足 else "") + ) + 说(行) + return 1 if [条 for 条 in 报告.包 if 条.必需 and not 条.一致] else 0 + + +def 包安装(名字: list[str], 配置: dict[str, Any]) -> int: + """`包 安装 [名...]`: 装包 (去掉名字 = 按配置清单全装). + + 命令怎么拼: + uv 在: `uv pip install --python /bin/python --index-url <国内源> <名字...>` + uv 不在: `/bin/python -m pip install -i <国内源> <名字...>` + 为什么带 --index-url: + 校网 GFW 下直连 PyPI 基本不通, 必须走 环境.efi.json 里配的清华源. + 输出实时转发: + 装包要几十秒到几分钟, 老板明确要求能看进度, 不接受黑盒等待. + + 返回: + 退出码 = 装包命令自己的退出码 (0 成功). + """ + venv解释器 = venv路径(配置) / "bin" / "python" + if not venv解释器.exists(): + print(f"[错误] venv 不在 ({venv解释器}) -- 先 `环境 重建`") + return 1 + 要求表 = 读包要求(配置) + if not 名字: + 名字 = [条.名 for 条 in 要求表] + if not 名字: + print("[错误] 没给包名, 环境.efi.json 的 packages 也是空的") + return 1 + 索引 = str(配置.get("index", "") or "") + uv = shutil.which("uv") + 命令: list[str] + if uv is not None: + 命令 = [uv, "pip", "install", "--python", str(venv解释器)] + if 索引: + 命令 += ["--index-url", 索引] + 命令 += 名字 + else: + print("[WARN] 没有 uv, 回落 venv 的 pip") + 命令 = [str(venv解释器), "-m", "pip", "install"] + if 索引: + 命令 += ["-i", 索引] + 命令 += 名字 + print("$ " + " ".join(命令)) + 结果 = 进程.跑并转发(命令, cwd=项目根, 尾部行数=60) + print(f"退出码 {结果.code} 耗时 {结果.seconds:.1f}s") + if 结果.code != 0: + print(f"[错误] 装包失败: {' | '.join(结果.lines[-3:])}") + return 结果.code + + +# ─────────────────────────────── 命令: 内核进程 ─────────────────────────────── + + +def 表行(键: str, 值: str) -> list[str]: + """两列表格的一行 (键值对输出用).""" + return [键, 值] + + +def 内核状态(配置: dict[str, Any]) -> int: + """`内核 状态`: 内核进程在不在 + 上次运行 + 今日次数. + + 两个信息源交叉着用: + /proc 扫入口 (进程.按入口找进程) -- 判"现在在不在跑", 不依赖 PG + PG kernel_runs -- 判"上次跑了什么/多久/退出码", 以及断电残留的没收尾记录 + 只读不写 (收尸是 `内核 停止` 的事), 所以退出码恒 0. + """ + 找 = 进程.按入口找进程(内核入口) + pid = 找[0] if 找 else None + 信息 = 进程.读进程信息(pid) if pid is not None else None + 连接, 连接说明 = PG连接(配置) + 上次: dict[str, Any] | None = None + 未结束: list[dict[str, Any]] = [] + 今日 = 0 + if 连接 is not None: + try: + 上次 = db.最近运行(连接) + 未结束 = db.未结束运行(连接) + 今日 = db.今日运行次数(连接) + except Exception as 错: + 连接说明 += f", 查台账失败: {错}" + 表: list[list[str]] = [] + if pid is not None and 信息 is not None: + 表.append(表行("状态", f"运行中 (pid {pid}, 进程组 {信息.pgid})")) + 表.append(表行("启动时间", 本地时间(信息.启动时刻))) + 表.append(表行("运行时长", 信息.存活文本)) + 表.append(表行("内存 / CPU", f"{信息.内存文本} / {信息.cpu秒:.1f}s")) + 表.append(表行("命令行", 信息.cmdline文本)) + elif pid is not None: + 表.append(表行("状态", f"pid {pid} 在, 但读不到 /proc 详情")) + else: + 表.append(表行("状态", "已停止")) + 表.append(表行("入口", str(内核入口))) + if 上次 is not None: + 起 = str(上次.get("started_at") or "")[:19] + 止 = str(上次.get("finished_at") or "")[:19] or "(没结束)" + 秒 = 上次.get("seconds") + 秒文本 = f"{float(秒):.1f}s" if isinstance(秒, (int, float)) else "—" + 表.append( + 表行( + "上次运行", + f"{起} -> {止} 共 {秒文本} 退出码 {上次.get('exit_code')} " + f"{上次.get('mode') or '?'} {'ok' if 上次.get('ok') else '未标 ok'}", + ) + ) + 详情 = str(上次.get("detail") or "") + if 详情: + 表.append(表行("上次详情", 详情[:160])) + 表.append(表行("今日运行", f"{今日} 次")) + if 未结束: + # 只有"记着在跑但从 /proc 看不到对应 pid"的才算残留 (正在跑的那条本来就还没收尾) + pid集 = {int(条.get("pid") or 0) for 条 in 未结束} + if pid is None or pid not in pid集: + 表.append( + 表行( + "收尸提示", + f"PG 里 {len(未结束)} 条运行记录没写 finished_at (断电/被杀留下) -- `内核 停止` 会收尾", + ) + ) + 行 = ["内核进程状态"] + 行.extend(文本.表格(["项", "值"], 表, [左, 左])) + 行.append("") + 行.append(f"台账来源: {连接说明}") + 说(行) + return 0 + + +def 跑内核(argv: list[str], 配置: dict[str, Any]) -> int: + """移交: fork 内核进程 (argv 原样透传) -> 实时转发输出 -> 记 kernel_runs -> 返回它的退出码. + + 参数: + argv: 要原样交给内核的参数 (如 ["列表"] / ["扫描"] / []). + 配置: 读环境() 的结果. + 返回: + 内核的退出码 (原样返回, 引导器不解释). + + 几个细节: + * 空文件检测: 内核入口如果还是 0 字节 (还没实现), 明确 WARN, 免得让人以为"跑成功了" + * PYTHONUNBUFFERED=1: 内核往管道写, 不加这个它的输出会憋到退出才一起出来 (实时进度没了) + * 记账失败只 WARN: 台账是尽力而为, 不能因为 PG 抖动就让内核跑不起来 + """ + if not 内核入口.exists(): + 记日志("ERROR", f"内核入口不存在: {内核入口}") + return 1 + if 内核入口.stat().st_size == 0: + 记日志("WARN", "内核入口是空文件 (0 字节) -- 本次只验证移交通路") + 解释器, 警告 = 内核解释器(配置) + if 警告: + 记日志("WARN", 警告) + env = dict(os.environ) + env["PYTHONUNBUFFERED"] = "1" + 命令 = [str(解释器), str(内核入口)] + argv + 连接, _ = PG连接(配置) + 运行id = 0 + if 连接 is not None: + try: + 运行id = db.记运行开始(连接, " ".join(命令), "oneshot", None) + except Exception as 错: + 记日志("WARN", f"kernel_runs 写不进去: {错}") + print(f"移交内核: {' '.join(命令)}") + 结果 = 进程.跑并转发(命令, cwd=项目根, env=env) + 说明 = " | ".join(结果.lines[-3:]) # 失败时台账里有日志尾巴, 不吞错 + if 连接 is not None and 运行id: + try: + db.记运行结束(连接, 运行id, 结果.code, 结果.seconds, 结果.code == 0, 说明) + except Exception as 错: + 记日志("WARN", f"kernel_runs 写不进去: {错}") + print(f"内核退出码 {结果.code} 耗时 {结果.seconds:.1f}s") + 记日志( + "INFO" if 结果.code == 0 else "WARN", + f"移交内核 退出码 {结果.code} 耗时 {结果.seconds:.1f}s 命令={' '.join(命令)}", + ) + return 结果.code + + +def 内核启动(参数: list[str], 配置: dict[str, Any]) -> int: + """`内核 启动 [--守护] [参数...]`. + + 两种形态: + 不带 --守护 -> **前台跑一次**: 走 移交() 的路子, 输出实时可见, 内核退出就返回它的码 + (内核默认动作通常是"列表", 所以这条适合"起一下看看") + 带 --守护 -> **后台常驻**: 进程库 启动() detached 拉起, stdout/stderr 进 + 内核/logs/内核.out.log (命令输出 + 崩溃原文; 结构化日志内核自己写 + 内核/logs/内核.log, 见 设计/04), + 记账 mode=daemon, pid 回填; 引导器不等待, 保证它活着就是全部职责 + + `--守护` 是从参数里**摘掉**的 (它是引导器的开关, 不转给内核), 其余参数原样透传. + 幂等: 已经在跑就不重复起 (同一时间只允许一份). + """ + 守护 = "--守护" in 参数 + 透传参数 = [条 for 条 in 参数 if 条 != "--守护"] + 现有 = 进程.按入口找进程(内核入口) + if 现有: + print(f"内核已经在跑 (pid {现有[0]}) -- 幂等, 不重复起; 看状态用 `内核 状态`") + return 0 + if not 守护: + return 跑内核(透传参数, 配置) + if not 内核入口.exists(): + 记日志("ERROR", f"内核入口不存在: {内核入口}") + return 1 + 解释器, 警告 = 内核解释器(配置) + if 警告: + 记日志("WARN", 警告) + env = dict(os.environ) + env["PYTHONUNBUFFERED"] = "1" + # 守护模式关掉日志行的 stderr 副本: stdio 都重定向进 内核.out.log, 日志行再从 stderr 走一遍 + # 就成了同一份文件里的重复内容 (内核.log 才是日志行该待的地方, 内核对这个开关有解释) + env["EFI_LOG_CONSOLE"] = "0" + 命令 = [str(解释器), str(内核入口)] + 透传参数 + 连接, _ = PG连接(配置) + 运行id = 0 + if 连接 is not None: + try: + 运行id = db.记运行开始(连接, " ".join(命令), "daemon", None) + except Exception as 错: + 记日志("WARN", f"kernel_runs 写不进去: {错}") + print(f"[守护] {' '.join(命令)}") + print(f"[守护] 日志: {内核日志路径}") + 日志.轮转(内核输出路径, 日志上限(配置), 日志保留(配置)) # 起内核前轮转它的输出文件 + 结果 = 进程.启动( + 命令, + cwd=项目根, + 日志=内核输出路径, + env=env, + 入口=内核入口, + 分隔=f"启动内核 {' '.join(命令)}", + ) + print(f"[守护] {结果.detail}") + if 结果.ok: + 记日志("INFO", f"内核 启动 --守护 pid={结果.pid} 命令={' '.join(命令)}") + else: + 记日志("ERROR", f"内核 起不来: {结果.detail}") + if 连接 is not None and 运行id: + try: + if 结果.ok: + db.记内核pid(连接, 运行id, 结果.pid) # 常驻成功: 补 pid, 不收尾 + else: + db.记运行结束(连接, 运行id, 结果.exit_code, 结果.seconds, False, 结果.detail) + except Exception as 错: + 记日志("WARN", f"kernel_runs 写不进去: {错}") + return 0 if 结果.ok else 1 + + +def 内核停止(配置: dict[str, Any]) -> int: + """`内核 停止`: 按 pid 逐个停 (进程库 停止() 会校验 cmdline + 收子树), 再统一收尾台账. + + 为什么逐个 pid 而不是一把梭: + 一次只动一个目标, 每步都有判据可查; 停完再扫一遍 /proc 确认干净 (复查才算数). + 为什么要收尾台账: + 断电/被 SIGKILL 的内核留下的 kernel_runs 行没有 finished_at, 会在 状态 里显示成 + "还有 N 条没收尾", 这里统一补上并写明是谁收的尾. + + 返回: + 0 (没在跑也返回 0, 幂等) / 1 (有残留或有残留进程). + """ + 连接, _ = PG连接(配置) + 找 = 进程.按入口找进程(内核入口) + if not 找: + print("内核没在跑 (幂等)") + if 连接 is not None: + try: + 收 = db.收尾未结束(连接, "引导器停止内核时收尾") + if 收: + print(f"[收尸] 补了 {收} 条没写 finished_at 的运行记录") + except Exception as 错: + 记日志("WARN", f"收尾失败: {错}") + return 0 + 超时 = float(配置.get("stop_timeout", 10) or 10) + 失败 = 0 + for pid in 找: + 结果 = 进程.停止(pid, 入口=内核入口, 超时=超时) + print(f"pid {pid}: {结果.detail}") + if not 结果.ok: + 失败 += 1 + 剩 = 进程.按入口找进程(内核入口) # 复查: 必须为空才算干净 + if 剩: + print(f"[错误] 还有 {len(剩)} 个没停掉: {剩}") + 失败 += 1 + if 连接 is not None: + try: + 收 = db.收尾未结束(连接, f"引导器停止内核 (退出: {'有残留' if 失败 else '干净'})") + if 收: + print(f"[收尸] 补了 {收} 条没写 finished_at 的运行记录") + except Exception as 错: + 记日志("WARN", f"收尾失败: {错}") + 结论 = "干净" if not 失败 else f"{失败} 处没停干净" + 记日志("INFO" if not 失败 else "WARN", f"内核 停止: {结论}") + return 1 if 失败 else 0 + + +def 内核重启(参数: list[str], 配置: dict[str, Any]) -> int: + """`内核 重启`: 先停干净, 再启动.没停干净就**不启** (一次只动一件事, 免得留孤儿互抢).""" + 停 = 内核停止(配置) + if 停 != 0: + print("[错误] 没停干净, 不接着启 (一次只动一件事)") + return 停 + return 内核启动(参数, 配置) + + +def 打日志(路径: Path, 选: 日志.选项, 称呼: str) -> int: + """打一份日志 (带 -n/-f/--级别/-g/--json); 引导器看的三种来源共用这一份实现.""" + if 选.跟随: + print(f"[跟 {称呼}] {路径} (Ctrl+C 停)", flush=True) + return 日志.跟(路径, 选.级别, 选.关键词) + 行表 = 日志.尾(路径, 选.行数, 选.级别, 选.关键词) + if not 行表: + if not 路径.exists(): + print(f"还没有 {称呼} 日志: {路径}") + else: + print(f"{路径} 里没有符合条件的行 (级别 {选.级别 or '全部'} / 关键词 {选.关键词 or '无'})") + return 0 + 日志.打印(行表, 选.json输出) + return 0 + + +def 内核日志(参数: list[str], 配置: dict[str, Any]) -> int: + """`内核 日志 [-n N] [-f] [--级别 X] [-g 关键词] [--json] [--输出]`: 看内核的日志. + + 默认看**结构化日志** (内核/logs/内核.log, 一行一条固定字段); 加 `--输出` 改看 + 内核/logs/内核.out.log (内核进程的 stdout/stderr 原始流: 命令输出 + 崩溃原文). + + 参数: + 参数: 命令行剩下的部分 (解析交给 日志.解析选项, 与内核那边的日志命令同一套开关). + 配置: 提供 log_lines 默认行数. + 返回: + 0 (没有日志文件也算正常: 内核还没跑过) / 1 (参数写错). + """ + 选, 问题 = 日志.解析选项(参数, int(配置.get("log_lines", 200) or 200)) + if 问题: + print(f"日志参数有问题: {问题}") + print("用法: 内核 日志 [-n 200] [-f] [--级别 X] [-g 关键词] [--json] [--输出]") + return 1 + return 打日志(内核输出路径 if 选.输出 else 内核日志路径, 选, "内核输出" if 选.输出 else "内核") + + +def 命令日志(参数: list[str], 配置: dict[str, Any]) -> int: + """`日志 [选项]`: 引导器自己的动作日志 (体检 / 包 / 移交 / 内核启停). + + 引导器以前只打 stderr, 跑完就查不到历史; 现在每次动作都落 内核/logs/引导器.log, + 这条命令就是它的查看入口 (默认看引导器自己的, --内核/--输出 转看内核那两份). + """ + 选, 问题 = 日志.解析选项(参数, int(配置.get("log_lines", 200) or 200)) + if 问题: + print(f"日志参数有问题: {问题}") + print(用法日志()) + return 1 + if 选.跟随 and (选.内核 or 选.输出): + print("-f 一次只能跟一个来源 (引导器 / --内核 / --输出)") + return 1 + if 选.输出: + return 打日志(内核输出路径, 选, "内核输出") + if 选.内核: + return 打日志(内核日志路径, 选, "内核") + return 打日志(引导器日志路径, 选, "引导器") + + +def 用法日志() -> str: + """`日志` 命令的用法一行 (参数写错时打).""" + return ("用法: 日志 [-n 200] [-f] [--级别 DEBUG|INFO|WARN|ERROR] [-g 关键词] " + "[--json] [--内核] [--输出]") + + +def 命令内核(参数: list[str], 配置: dict[str, Any]) -> int: + """`内核 ...` 的分发 (默认子命令 = 状态). + + 启动/重启这类"会动进程"的动作先做例行体检, 有阻塞项就拦住不让动 (跟透传一个门槛). + 状态/日志/停止不做门槛: 那是"看/收尾", PG 或 venv 坏了也得能停能看. + """ + 子 = 参数[0] if 参数 else "状态" + 剩 = 参数[1:] + if 子 == "状态": + return 内核状态(配置) + if 子 == "日志": + return 内核日志(剩, 配置) + if 子 == "停止": + return 内核停止(配置) + if 子 in ("启动", "重启"): + 报告 = 例行体检(配置, 详细=False) + if not 报告.通过: + 说(打印体检(报告)) + print("体检不过 -- 先修好再动内核 (引导器不自动改)") + return 1 + return 内核启动(剩, 配置) if 子 == "启动" else 内核重启(剩, 配置) + print(f"[错误] 内核子命令不认识: {子}\n") + print(用法()) + return 2 + + +# ─────────────────────────────── 透传 ─────────────────────────────── + + +def 透传(参数: list[str], 配置: dict[str, Any]) -> int: + """把 argv 原样交给内核 (引导器只做体检门槛和记账, 不实现内核的子命令). + + 流程 (设计 03 §9 的启动时序): + ① 例行体检 (全绿打一行摘要, 有问题打整张表) + ② 阻塞项不过 -> 打印原因 + 修复命令, 退出 1 (**不自动改**) + ③ 过了 -> 跑内核(参数), 内核的退出码原样返回 + 这里不判断 PG 通不通: PG 是内核的内存, 它自己会报错退出 (引导器不替它降级). + """ + 报告 = 例行体检(配置, 详细=False) + if not 报告.通过: + 说(打印体检(报告)) + print("体检不过 -- 修好再移交 (引导器不自动改,不自动装,不自动重建)") + return 1 + return 跑内核(参数, 配置) + + +# ─────────────────────────────── 用法 ─────────────────────────────── + + +def 用法() -> str: + """帮助文本 (-h / 子命令认不出来时打).""" + return "\n".join( + [ + "用法: python3 UEFI.boot.py <命令>", + "", + " 引导器自己的命令 (顶层固定这 5 个词):", + " (无参数) 体检 -> 移交内核的默认动作 (列表)", + " --check | 自检 只体检, 不启内核 (有阻塞项退出 1)", + " 环境 [--json] 体检 6 项, 逐项给结论 + 修复命令", + " 环境 重建 按 python 要求重建 venv (显式, 旧 venv 改名留退路)", + " 包 [--json] 包状态台账 (读 uv pip list / importlib.metadata)", + " 包 安装 [名...] 装包 (显式, 走 环境.efi.json 的国内源)", + " 日志 [选项] 引导器自己的动作日志 (--内核 / --输出 转看内核那两份)", + "", + " 内核进程管理 (内核通常常驻, 生命周期归引导器):", + " 内核 状态 有没有在跑 + 上次运行 + 今日次数 (PG + /proc 交叉验证)", + " 内核 启动 [参数...] 前台跑一次, 输出实时可见", + " 内核 启动 --守护 后台常驻 (结构化日志 内核/logs/内核.log,", + " 命令输出与崩溃原文 内核/logs/内核.out.log)", + " 内核 停止 先校验 cmdline 再 SIGTERM 进程组, 不收则 SIGKILL 收残", + " 内核 重启 停干净了才启 (一次只动一件事)", + " 内核 日志 [选项] 默认看结构化日志; --输出 看命令输出/崩溃原文", + "", + " 日志选项 (引导器 日志 / 内核 日志 / 透传的 日志 都认同一套):", + " -n 200 尾部行数 -f 实时跟 (Ctrl+C 停)", + " --级别 WARN 只看这级别及以上 -g 关键词 只看含它的行", + " --json 输出 JSON Lines --全部/--内核/--引导器/--输出 换来源", + "", + " 其余命令原样透传内核 (内核自己认, 见 设计/02):", + " 列表 | 扫描 | 启动 <驱动> | 停止 <驱动> | 重启 <驱动> | 状态 | 日志 <驱动> | 事件 | 清单", + "", + " 分界: 引导器自己的命令 PG 不通只 WARN (降级到 json 快照); 透传内核的命令不降级.", + ] + ) + + +# ─────────────────────────────── 入口 ─────────────────────────────── + + +def main(argv: list[str]) -> int: + """总入口: 读配置 -> 轮转日志 -> 分发 -> 落一条收工记录. + + 分发规则 (顺序就是优先级, 越具体的越靠前): + -h/--help/帮助 -> 用法 + 版本 -> 打版本号 + --check/自检 -> 只体检 (不启内核) + 环境/包/内核/日志 -> 引导器自己 5 个词 + 其余 -> 原样透传内核 + argv 空 (直接跑 UEFI.boot.py) 等于"体检 + 内核默认动作". + + 为什么要记"开始/收工"两条: 引导器是短命进程, 不记就等于没有历史 -- + 这两条是**审计对角线** (跑了什么命令 / 退出码几), 只落文件不打 stderr (不吵 stdout 的输出). + """ + 配置, 警告 = 读环境() + 起日志(配置) # 第一件事: 轮转引导器日志 (超上限改名 .1, 不让它无限长) + 记日志("INFO", f"{引导器版本} 开始: {' '.join(argv) if argv else '(无参数)'}", 安静=True) + 打印警告(警告) + 码 = 分发(argv, 配置) + 记日志("INFO", f"收工: 退出码 {码}", 安静=True) + return 码 + + +def 分发(argv: list[str], 配置: dict[str, Any]) -> int: + """命令分发 (main 只管开始/收工两条日志, 具体分支都在这).""" + if not argv: + return 透传([], 配置) + 头 = argv[0] + if 头 in ("-h", "--help", "帮助", "help"): + print(用法()) + return 0 + if 头 == "版本": + print(f"{引导器版本} / {内核版本}") + return 0 + if 头 in ("--check", "自检", "检查"): + return 命令自检(配置) + if 头 == "环境": + return 命令环境(argv[1:], 配置) + if 头 == "包": + return 命令包(argv[1:], 配置) + if 头 == "内核": + return 命令内核(argv[1:], 配置) + if 头 == "日志": + return 命令日志(argv[1:], 配置) + return 透传(argv, 配置) + + +if __name__ == "__main__": + try: + sys.exit(main(sys.argv[1:])) + except KeyboardInterrupt: + print("\n[中断]") + sys.exit(130) diff --git a/pyrightconfig.json b/pyrightconfig.json new file mode 100644 index 0000000..763ef83 --- /dev/null +++ b/pyrightconfig.json @@ -0,0 +1,6 @@ +{ + "include": ["."], + "exclude": ["**/__pycache__", "**/.venv*", "**/*.bak-*", "设计"], + "extraPaths": ["内核"], + "typeCheckingMode": "strict" +} diff --git a/内核/db.py b/内核/db.py new file mode 100644 index 0000000..92ea9ce --- /dev/null +++ b/内核/db.py @@ -0,0 +1,892 @@ +"""唯一碰 SQL 的文件: 连接 / 建表 / 事件 / 台账. + +[为什么只有这一个文件写 SQL (设计 02 §3 定死)] + SQL 散在全项目 = 表结构一改就得满地找.集中在这里, 谁想读写库就调这里的函数. + +[谁用哪部分] + * 内核 -> drivers / driver_state / events / scans / commands / calls (驱动全生命周期) + * 引导器 -> kernel_env (体检台账) / kernel_runs (内核运行台账), 它只建自己这两张, + 不碰驱动程序的地盘 (分层: 引导器管内核, 内核管驱动) + * 驱动 -> 只写 events / calls (约定 + 内核校验; v0.2 再上 PG 角色做硬隔离) + +[连接从哪来] + 项目根的 环境.efi.json 的 db 段: {name, host, port, user}. + host 填的是 **unix socket 目录** (如 /home/lou/pgdata/socket), 不走 TCP -- 本机库没必要绕网络栈. + 驱动侧的连接串由内核经环境变量 EFI_DB 注入, 不落盘 (密钥不落盘口径). + +[降级约定 (很重要)] + psycopg2 是**可选**依赖: + * 引导器 缺它 -> 只 WARN, 降级成"只写 json 快照", 不算失败 (引导器必须永远能跑起来报错) + * 内核 缺它 -> 直接报错退出 (它的内存就是 PG, 没有内存没法干活, 不许静默降级) + `有psycopg2` 这个开关就是给调用方判这个的. +""" + +from __future__ import annotations + +import json +import re +from dataclasses import dataclass +from typing import Any + +try: + # 这两行带 pyright: ignore[reportMissingModuleSource]: + # psycopg2 是编译版轮子 (只有 .so 没有 .py 源码), pyright 因此报"无法从源码解析导入" -- + # 对 C 扩展模块这是误报. 类型来自 .venv 里装的 psycopg2-stubs (PEP 561, 见 环境.efi.json). + import psycopg2 as _pg # pyright: ignore[reportMissingModuleSource] + import psycopg2.extras as _pgx # pyright: ignore[reportMissingModuleSource] +except ImportError: # 引导器零第三方依赖, 缺了就降级 + _pg = None + _pgx = None + +# 给调用方判"要不要走降级分支" +有psycopg2: bool = _pg is not None + +# ─────────────────────────────── 建表语句 ─────────────────────────────── +# 全部 CREATE TABLE IF NOT EXISTS: 幂等, 跑多少次都一样, 所以内核每次 boot 都可以直接调 建表(). +# 每句都带 IF NOT EXISTS, _建() 靠它回一个"建/查了哪张表"的清单给人看. + +# 内核的表 (8 张: 司机 + 状态 + 总线 + 扫描批次 + 命令 + 调用请求 + 引导器两张台账) +内核表: list[str] = [ + # ① 驱动注册表: 内核扫描后 upsert. + # config_hash 是 配置.efi.json 的 sha256 -- 跟 driver_state.boot_hash 一比就知道 + # "配置改过但还没重启" (不用 diff 内容).valid/error 是配置校验的结果, 隔离失败用. + """CREATE TABLE IF NOT EXISTS drivers ( + name text PRIMARY KEY, + dir text NOT NULL, + runtime text NOT NULL, + entry text NOT NULL, + interpreter text, + args jsonb DEFAULT '[]', + env jsonb DEFAULT '{}', + deps text[] DEFAULT '{}', + provides text[] DEFAULT '{}', + needs text[] DEFAULT '{}', + autostart boolean DEFAULT false, + restart text DEFAULT 'no', + mode text DEFAULT 'resident', + config_hash text, + entry_hash text, + valid boolean DEFAULT true, + error text, + note text, + scanned_at timestamptz DEFAULT now() +)""", + # ② 运行时状态: 一行一驱动, 内核每次动作刷新.pid 只是记录, 判活一律回 /proc 复核 + # (pid 会被系统复用).boot_hash = 起进程那一刻的配置指纹. + """CREATE TABLE IF NOT EXISTS driver_state ( + name text PRIMARY KEY REFERENCES drivers(name) ON DELETE CASCADE, + state text NOT NULL DEFAULT 'stopped', + pid integer, + pgid integer, + started_at timestamptz, + stopped_at timestamptz, + exit_code integer, + restarts integer DEFAULT 0, + boot_hash text, + list_version bigint, + last_error text, + updated_at timestamptz DEFAULT now() +)""", + # ③ 事件流 = 总线: 内核写, 驱动也写.kind 约定 start|stop|exit|log|produce|heartbeat|error. + # level 约定 info|warn|error.data 是 jsonb, 装不确定的载荷 (表结构就是消息格式, 不另设协议). + """CREATE TABLE IF NOT EXISTS events ( + id bigserial PRIMARY KEY, + ts timestamptz DEFAULT now(), + source text NOT NULL, + driver text, + level text DEFAULT 'info', + kind text, + message text, + data jsonb +)""", + "CREATE INDEX IF NOT EXISTS events_ts_idx ON events (ts DESC)", + "CREATE INDEX IF NOT EXISTS events_driver_idx ON events (driver, ts DESC)", + # ④ 扫描批次: list_version 自增, 用来判"这份状态是不是本次扫描的". + """CREATE TABLE IF NOT EXISTS scans ( + list_version bigserial PRIMARY KEY, + started_at timestamptz DEFAULT now(), + kernel text, + total int, + valid int, + invalid int, + running int +)""", + # ⑤ 命令表: 内核常驻(甲)才需要 -- CLI 客户端把命令写进来, 常驻内核 LISTEN/NOTIFY 消费. + """CREATE TABLE IF NOT EXISTS commands ( + id bigserial PRIMARY KEY, + ts timestamptz DEFAULT now(), + source text, + cmd text NOT NULL, + args jsonb DEFAULT '{}', + state text DEFAULT 'pending', + result jsonb, + started_at timestamptz, + finished_at timestamptz +)""", + # ⑥ 调用请求: 驱动要别人的产出时写这里 (want 填**契约名**, 不填驱动名). + # provider 是内核自己记的账, 请求方看不到 -- 所以驱动之间永远不认识彼此. + # lock_key 防同时写同一份数据; deadline 是超时线 (内核收权用); 调用链成环会被 denied. + """CREATE TABLE IF NOT EXISTS calls ( + id bigserial PRIMARY KEY, + ts timestamptz DEFAULT now(), + caller text NOT NULL, + want text NOT NULL, + args jsonb DEFAULT '{}', + state text DEFAULT 'pending', + provider text, + lock_key text, + result jsonb, + error text, + deadline timestamptz, + started_at timestamptz, + finished_at timestamptz +)""", +] + +# 引导器的两张台账 (设计 03 §7).引导器只建这两张, 内核建全部. +引导器表: list[str] = [ + # 环境/包体检台账: 每次 自检/环境/透传 之前落一行, 留痕给"环境什么时候变坏的"用. + # packages 存 jsonb 数组: [{name, want, got, ok, required}] + """CREATE TABLE IF NOT EXISTS kernel_env ( + id bigserial PRIMARY KEY, + ts timestamptz DEFAULT now(), + python_version text, + venv_path text, + venv_healthy boolean, + packages jsonb, + pg_ok boolean, + driver_root_ok boolean, + ok boolean, + detail text +)""", + # 内核运行台账: 每次运行一行.finished_at IS NULL = 还没收尾 (进程在跑, 或者断电/被杀留下的). + # mode: oneshot (前台跑一次) | daemon (后台常驻).detail 存失败原因 + 日志尾巴. + """CREATE TABLE IF NOT EXISTS kernel_runs ( + id bigserial PRIMARY KEY, + started_at timestamptz DEFAULT now(), + finished_at timestamptz, + argv text, + mode text, + pid integer, + exit_code integer, + seconds real, + ok boolean, + detail text +)""", +] + + +# ─────────────────────────────── 连接 ─────────────────────────────── + + +@dataclass +class 数据库: + """一份 PG 连接信息 (来自 环境.efi.json 的 db 段). + + 字段: + name: 库名 (本项目 = efi_kernel). + host: **unix socket 目录** (不是主机名/IP), 如 /home/lou/pgdata/socket. + port: 端口 (5432); socket 文件名就是 .s.PGSQL.. + user: 系统用户名 (PG 用 peer 认证, 所以写本机用户名). + """ + + name: str + host: str + port: int + user: str + + def 连接参数(self, 库名: str = "") -> dict[str, Any]: + """psycopg2.connect(**参数) 用的字典.库名传空 = 连 self.name.""" + return { + "dbname": 库名 or self.name, + "user": self.user, + "host": self.host, + "port": self.port, + } + + def 描述(self) -> str: + """人读的一行连接描述 (报错,日志里贴这个, 别只写"连不上").""" + return f"{self.name} @ {self.host}:{self.port} (user={self.user})" + + +def 从配置(段: dict[str, Any] | None) -> 数据库: + """把 环境.efi.json 的 db 段转成 数据库. + + 参数: + 段: 配置里的 db 字典; None 或字段缺失都给保守默认 (不让缺配置直接崩). + 返回: + 数据库 实例. + + 默认值的来路: + 优先吃环境变量 PGHOST / PGPORT / PGUSER, 再兜底到本机常见位置 (~/pgdata/socket, 5432, 当前用户). + 这样同一个项目挪到别的机器上, 改环境变量或改配置都行, 不用动代码. + """ + 源: dict[str, Any] = 段 if 段 is not None else {} + 端口原始 = 源.get("port", 5432) + try: + 端口 = int(端口原始) + except (TypeError, ValueError): + 端口 = 5432 + return 数据库( + name=str(源.get("name", "efi_kernel")), + host=str(源.get("host", "/home/lou/pgdata/socket")), + port=端口, + user=str(源.get("user", "lou")), + ) + + +def _游标工厂() -> Any: + """字典游标工厂 (RealDictCursor). + + 为什么统一用它: + 默认游标 fetch 出来是 tuple, 取列得按下标 (行[0]) -- 加一列就全乱. + 字典游标能按列名取 (行["id"]), 表结构改了也不容易错 (踩过: RETURNING id 用默认游标报 + "tuple indices must be integers or slices, not str"). + """ + if _pgx is None: + raise RuntimeError("没装 psycopg2 -- 这一层不可用 (引导器请走降级分支)") + return _pgx.RealDictCursor + + +def 连(库: 数据库, 超时: float = 5.0, 库名: str = "") -> Any: + """开一条连接.连不上就抛异常, 由调用方决定是降级还是退出 (本层不做决定). + + 参数: + 库: 连接信息. + 超时: 连接超时秒 (下限 1s). + 库名: 传了就改连这个库 (如查库存在时先连 postgres 管理库). + 返回: + psycopg2 连接 (autocommit = True). + + 为什么 autocommit: + 台账是一行一落的小写操作, 不该被调用方的事务卡住 (体检落库失败也不该回滚掉别的). + 真需要事务的复杂流程 (内核的调度) 自己显式开事务. + """ + if _pg is None: + raise RuntimeError("没装 psycopg2") + 参数 = dict(库.连接参数(库名)) + 参数["connect_timeout"] = max(int(超时), 1) + 连接 = _pg.connect(**参数) + 连接.autocommit = True + return 连接 + + +# ─────────────────────────────── 建表 ─────────────────────────────── + + +def 建表(连接: Any) -> list[str]: + """内核用: 八张表全建 (幂等).返回"建/查了哪些对象"的清单.""" + return _建(连接, 内核表 + 引导器表) + + +def 建引导器表(连接: Any) -> list[str]: + """引导器用: 只动它自己那两张 (不碰驱动程序的地盘, 分层不越界).""" + return _建(连接, 引导器表) + + +def _建(连接: Any, 语句表: list[str]) -> list[str]: + """逐句执行 DDL; 从句子里的 IF NOT EXISTS 后面抠出对象名回给调用方. + + 返回: + 对象名列表 (表名 / 索引名), 顺序跟语句一致.执行出错直接抛 (建表失败不能装看不见). + """ + 出: list[str] = [] + with 连接.cursor() as 游标: + for 语句 in 语句表: + 对象 = re.search(r"IF NOT EXISTS\s+(\S+)", 语句) + 游标.execute(语句) + 出.append(对象.group(1) if 对象 is not None else 语句[:40]) + return 出 + + +def 库存在(连接: Any, 库名: str) -> bool: + """查这个库在不在 (引导器体检第 6 项用: 实例通了但库没建是最常见的半通状态). + + 参数: + 连接: 建议连到 postgres 管理库再查 (目标库不存在时连不上去). + 库名: 要查的库名. + 返回: + 存在 True / 不存在 False. + """ + with 连接.cursor() as 游标: + 游标.execute("SELECT 1 FROM pg_database WHERE datname = %s", (库名,)) + return 游标.fetchone() is not None + + +# ─────────────────────────────── 事件 (总线) ─────────────────────────────── + + +def 写事件( + 连接: Any, + source: str, + kind: str, + message: str, + driver: str | None = None, + level: str = "info", + data: dict[str, Any] | None = None, +) -> None: + """往 events 表插一行 -- 这是"没有通信协议"的落地方式: 谁想汇报就插一行, 内核不用解析 stdout. + + 参数: + 连接: PG 连接. + source: 谁写的 (kernel / 引导器 / <驱动名>). + kind: 事件类型约定 start|stop|exit|log|produce|heartbeat|error. + message: 人读的一句话. + driver: 关联的驱动名 (可空). + level: info|warn|error. + data: 结构化载荷 (jsonb), 不确定的内容放这里. + 返回: + None.写失败会抛异常 (事件丢了不该静默). + """ + with 连接.cursor() as 游标: + 游标.execute( + "INSERT INTO events (source, driver, level, kind, message, data)" + " VALUES (%s, %s, %s, %s, %s, %s::jsonb)", + (source, driver, level, kind, message, json.dumps(data or {}, ensure_ascii=False)), + ) + + +# ─────────────── 内核: 注册表 / 运行时状态 / 扫描批次 / 命令 / 调用 ─────────────── +# 内核三块地盘的读写全在这儿 (唯一碰 SQL 的文件, 别处一律不写 SQL). +# 约定: text[] 列传 list, jsonb 列传 json.dumps 出来的串 + ::jsonb 强转. + + +def 记驱动(连接: Any, 驱动: dict[str, Any]) -> None: + """把一个驱动的扫描结果 upsert 进注册表 (drivers 表). + + 参数: + 连接: PG 连接. + 驱动: 扫描出来的记录, 键名跟 drivers 表的列名一致. + 必需: name / dir / runtime / entry; + 可选: interpreter / args / env / provides / needs / autostart / restart / mode / + config_hash / entry_hash / valid / error / note. + 返回: + 无. + + 说明: + 驱动名是主键, 所以 ON CONFLICT(name) 覆盖 -- 每次扫描都以磁盘为准刷一遍. + valid=false 的记录**照样入库** (带 error 原因): 列表要能看见"这个驱动坏了", + 而不是假装它不存在 (假成功比报错更坏). + """ + with 连接.cursor() as 游标: + 游标.execute( + "INSERT INTO drivers (" + " name, dir, runtime, entry, interpreter, args, env, provides, needs," + " autostart, restart, mode, config_hash, entry_hash, valid, error, note, scanned_at)" + " VALUES (%s, %s, %s, %s, %s, %s::jsonb, %s::jsonb, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, now())" + " ON CONFLICT (name) DO UPDATE SET" + " dir = EXCLUDED.dir, runtime = EXCLUDED.runtime, entry = EXCLUDED.entry," + " interpreter = EXCLUDED.interpreter, args = EXCLUDED.args, env = EXCLUDED.env," + " provides = EXCLUDED.provides, needs = EXCLUDED.needs, autostart = EXCLUDED.autostart," + " restart = EXCLUDED.restart, mode = EXCLUDED.mode, config_hash = EXCLUDED.config_hash," + " entry_hash = EXCLUDED.entry_hash, valid = EXCLUDED.valid, error = EXCLUDED.error," + " note = EXCLUDED.note, scanned_at = now()", + ( + str(驱动.get("name", "")), + str(驱动.get("dir", "")), + str(驱动.get("runtime", "python")), + str(驱动.get("entry", "")), + 驱动.get("interpreter"), + json.dumps(驱动.get("args") or [], ensure_ascii=False), + json.dumps(驱动.get("env") or {}, ensure_ascii=False), + list(驱动.get("provides") or []), + list(驱动.get("needs") or []), + bool(驱动.get("autostart", False)), + str(驱动.get("restart", "no")), + str(驱动.get("mode", "resident")), + 驱动.get("config_hash"), + 驱动.get("entry_hash"), + bool(驱动.get("valid", True)), + 驱动.get("error"), + 驱动.get("note"), + ), + ) + + +def 清不在(连接: Any, 保留: list[str]) -> list[str]: + """把"磁盘上已经没有"的驱动从注册表删掉, 返回被删的名字表. + + 为什么要删: + 文件夹没了但注册表留着 -> 列表里冒出幽灵驱动, 启动它必然失败. + driver_state 是 ON DELETE CASCADE, 状态行跟着走, 不留孤儿状态. + 注意: + 只删 drivers, 不碰 events -- 事件是历史, 历史不删. + """ + with 连接.cursor(cursor_factory=_游标工厂()) as 游标: + 游标.execute("DELETE FROM drivers WHERE NOT (name = ANY(%s)) RETURNING name", (保留,)) + return [str(行["name"]) for 行 in 游标.fetchall()] + + +def 取驱动(连接: Any, name: str) -> dict[str, Any] | None: + """按名字取一个驱动 (注册表); 取不到返回 None.""" + 行 = _查(连接, "SELECT * FROM drivers WHERE name = %s", (name,)) + return 行[0] if 行 else None + + +def 取全部驱动(连接: Any) -> list[dict[str, Any]]: + """取全部驱动, 按名字排序 (列表 / 扫描 / 契约匹配都要用).""" + return _查(连接, "SELECT * FROM drivers ORDER BY name", ()) + + +# driver_state 允许被 写状态() 改动的列 (动态 UPDATE 的白名单, 挡住拼错的列名) +状态列: set[str] = { + "state", + "pid", + "pgid", + "started_at", + "stopped_at", + "exit_code", + "restarts", + "boot_hash", + "list_version", + "last_error", +} + + +def 确保状态行(连接: Any, name: str, 清单版本: int | None = None) -> None: + """保证 driver_state 里有这一行 (没有就插一行 stopped). + + 扫描时对每个驱动调一次; 之后内核的所有改动都走 UPDATE (不用每次都想"要不要 INSERT"). + """ + with 连接.cursor() as 游标: + 游标.execute( + "INSERT INTO driver_state (name, state, list_version, updated_at)" + " VALUES (%s, 'stopped', %s, now()) ON CONFLICT (name) DO NOTHING", + (name, 清单版本), + ) + + +def 写状态(连接: Any, name: str, 改动: dict[str, Any]) -> None: + """改 driver_state 的若干列 (只改传进来的列, 其它列一个都不动). + + 为什么动态拼 SQL: + 内核一次动作只改一部分 (启动改 pid/pgid/started_at; 停止改 state/exit_code/stopped_at). + 写死列会逼调用方把整行传一遍, 传漏就把别人的字段抹成 NULL -- 状态就是这么丢的. + 护栏: + 列名必须在 状态列 白名单里, 不在就抛 ValueError; 值一律走 %s 参数, 不拼进 SQL. + """ + 坏 = sorted(k for k in 改动 if k not in 状态列) + if 坏: + raise ValueError(f"driver_state 没有这些列: {坏}") + 列 = list(改动.keys()) + if not 列: + return + 片段 = ", ".join(f"{k} = %s" for k in 列) + 值 = tuple(改动[k] for k in 列) + with 连接.cursor() as 游标: + 游标.execute(f"UPDATE driver_state SET {片段}, updated_at = now() WHERE name = %s", (*值, name)) + + +def 取状态(连接: Any, name: str) -> dict[str, Any] | None: + """按名字取一个驱动的运行时状态; 取不到返回 None.""" + 行 = _查(连接, "SELECT * FROM driver_state WHERE name = %s", (name,)) + return 行[0] if 行 else None + + +def 取全部状态(连接: Any) -> list[dict[str, Any]]: + """取全部运行时状态, 按名字排序 (列表 / 收尸巡检用).""" + return _查(连接, "SELECT * FROM driver_state ORDER BY name", ()) + + +def 记扫描批次(连接: Any, 内核版本: str, 总数: int, 有效: int, 无效: int, 在跑: int) -> int: + """记一次扫描, 返回本批次的 list_version (自增主键). + + list_version 的用处: 判断"手里这份状态是不是本次扫描的" -- 旧版本的状态行一眼能认出来. + """ + with 连接.cursor(cursor_factory=_游标工厂()) as 游标: + 游标.execute( + "INSERT INTO scans (kernel, total, valid, invalid, running)" + " VALUES (%s, %s, %s, %s, %s) RETURNING list_version", + (内核版本, 总数, 有效, 无效, 在跑), + ) + 行 = 游标.fetchone() + return int(行["list_version"]) if 行 is not None else 0 + + +def 取最近扫描(连接: Any) -> dict[str, Any] | None: + """取最近一次扫描批次 (列表页脚要显示 扫描 #N 时间); 一次都没扫过返回 None.""" + 行 = _查( + 连接, + "SELECT list_version, started_at, kernel, total, valid, invalid, running" + " FROM scans ORDER BY list_version DESC LIMIT 1", + (), + ) + return 行[0] if 行 else None + + +def 读事件(连接: Any, 条数: int = 50, driver: str | None = None) -> list[dict[str, Any]]: + """读事件流 (新的在前). driver 给了就只看这个驱动的.""" + if driver: + return _查( + 连接, + "SELECT * FROM events WHERE driver = %s ORDER BY ts DESC, id DESC LIMIT %s", + (driver, 条数), + ) + return _查(连接, "SELECT * FROM events ORDER BY ts DESC, id DESC LIMIT %s", (条数,)) + + +def 记命令( + 连接: Any, + source: str, + cmd: str, + args: dict[str, Any] | None = None, + state: str = "pending", +) -> int: + """往 commands 表写一条命令 (审计 + 给常驻内核排队), 返回命令 id. + + state='pending' = 交给常驻内核去执行 (脚本/自动化的用法); + CLI 自己执行的命令用 state='running' 落行, 免得被常驻内核重复领走. + """ + with 连接.cursor(cursor_factory=_游标工厂()) as 游标: + 游标.execute( + "INSERT INTO commands (source, cmd, args, state, started_at)" + " VALUES (%s, %s, %s::jsonb, %s, CASE WHEN %s = 'pending' THEN NULL ELSE now() END)" + " RETURNING id", + (source, cmd, json.dumps(args or {}, ensure_ascii=False), state, state), + ) + 行 = 游标.fetchone() + return int(行["id"]) if 行 is not None else 0 + + +def 领命令(连接: Any) -> dict[str, Any] | None: + """常驻内核领一条待执行的命令 (pending -> running), 没有就返回 None. + + 为什么用 FOR UPDATE SKIP LOCKED: + 万一有两个内核进程 (手滑起了两份), 它们不会领到同一条命令 -- 一条命令只被执行一次. + """ + with 连接.cursor(cursor_factory=_游标工厂()) as 游标: + 游标.execute( + "UPDATE commands SET state = 'running', started_at = now()" + " WHERE id = (SELECT id FROM commands WHERE state = 'pending'" + " ORDER BY id LIMIT 1 FOR UPDATE SKIP LOCKED)" + " RETURNING *" + ) + 行 = 游标.fetchone() + return dict(行) if 行 is not None else None + + +def 记命令结果(连接: Any, 命令id: int, state: str, result: dict[str, Any] | None = None) -> None: + """回填命令结果 (state: done | failed), CLI 轮询这个字段取结果.""" + with 连接.cursor() as 游标: + 游标.execute( + "UPDATE commands SET state = %s, result = %s::jsonb, finished_at = now() WHERE id = %s", + (state, json.dumps(result or {}, ensure_ascii=False), 命令id), + ) + + +def 读命令(连接: Any, 命令id: int) -> dict[str, Any] | None: + """读一条命令的当前状态 (CLI 轮询等结果用).""" + 行 = _查(连接, "SELECT * FROM commands WHERE id = %s", (命令id,)) + return 行[0] if 行 else None + + +# calls 允许被 写调用() 改动的列 (同 状态列 的护栏思路) +调用列: set[str] = { + "state", + "provider", + "lock_key", + "result", + "error", + "deadline", + "started_at", + "finished_at", +} + + +def 领调用(连接: Any) -> list[dict[str, Any]]: + """常驻内核领走所有待处理的驱动调用请求 (pending -> waiting), 返回这些行. + + 驱动只写 calls 表, 不跟内核握手 -- 内核轮询这一张表就等于收请求 (没有协议). + 注意 SQL 写法: UPDATE 不能直接带 ORDER BY (PG 会报 syntax error at or near "ORDER"); + 要排序 + 跳锁就得用子查询 (跟 领命令 同一个套路, 这里踩过一次). + """ + with 连接.cursor(cursor_factory=_游标工厂()) as 游标: + 游标.execute( + "UPDATE calls SET state = 'waiting'" + " WHERE id IN (SELECT id FROM calls WHERE state = 'pending'" + " ORDER BY id FOR UPDATE SKIP LOCKED)" + " RETURNING *" + ) + return [dict(行) for 行 in 游标.fetchall()] + + +def 写调用(连接: Any, 调用id: int, 改动: dict[str, Any]) -> None: + """改 calls 的若干列 (state/provider/lock_key/result/error/deadline/...); 列名走白名单. + + result 是 jsonb 列: 给 dict / list 自动 dumps + ::jsonb 强转 (跟 记命令结果 同一个口径), + 给字符串就按原样进. 之前不转 -- 直接塞 dict 会被 psycopg2 顶回来 + ("can't adapt type 'dict'"), 2026-09-16 由 内核/自测db.py 抓出来 (当时内核只写 + state/error/deadline, 没踩到, 但调用方迟早会拿它写 result). + """ + 坏 = sorted(k for k in 改动 if k not in 调用列) + if 坏: + raise ValueError(f"calls 没有这些列: {坏}") + 列 = list(改动.keys()) + if not 列: + return + 片段 = ", ".join(f"{k} = %s" + ("::jsonb" if k == "result" else "") for k in 列) + 值: list[Any] = [] + for k in 列: + v = 改动[k] + 值.append(json.dumps(v, ensure_ascii=False) if k == "result" and isinstance(v, (dict, list)) else v) + with 连接.cursor() as 游标: + 游标.execute(f"UPDATE calls SET {片段} WHERE id = %s", (*值, 调用id)) + + +def 读调用(连接: Any, 调用id: int) -> dict[str, Any] | None: + """读一条调用请求 (转发后查它的状态).""" + 行 = _查(连接, "SELECT * FROM calls WHERE id = %s", (调用id,)) + return 行[0] if 行 else None + + +def 取调用(连接: Any, state: str, 条数: int = 200) -> list[dict[str, Any]]: + """按状态取一批调用请求 (内核巡检用: 找超时的 running, 找排队的 waiting).""" + return _查( + 连接, + "SELECT * FROM calls WHERE state = %s ORDER BY id LIMIT %s", + (state, 条数), + ) + + +def 同锁在跑(连接: Any, lock_key: str, 排除id: int) -> bool: + """同一个 lock_key 上是不是已经有别的调用在跑 / 排队 (防打架第 1 条: 同一份数据串行化).""" + 行 = _查( + 连接, + "SELECT id FROM calls WHERE lock_key = %s AND state IN ('waiting', 'running') AND id <> %s LIMIT 1", + (lock_key, 排除id), + ) + return bool(行) + + +def 监听(连接: Any, 通道: str) -> None: + """LISTEN 一个通道 (内核用它等"有命令/有调用"的唤醒信号). + + 通道名在 SQL 里是标识符, 不能参数化 -> 先按白名单校验 (只允许字母/数字/下划线/中文) 再拼. + """ + if not 通道 or not all(字符.isalnum() or 字符 == "_" for 字符 in 通道): + raise ValueError(f"通道名不合法: {通道!r}") + with 连接.cursor() as 游标: + 游标.execute(f"LISTEN {通道}") + + +def 收通知(连接: Any) -> list[tuple[str, str]]: + """把 PG 攒着的通知全取出来, 返回 (通道, 载荷) 列表. + + 用法: 每轮先 连接.poll() (psycopg2 自带的, 把网络上的通知收进内存), 再调这个取走. + """ + 出: list[tuple[str, str]] = [] + while 连接.notifies: + 通知 = 连接.notifies.pop(0) + 出.append((str(通知.channel), str(通知.payload or ""))) + return 出 + + +def 通知(连接: Any, 通道: str, 载荷: str) -> None: + """pg_notify 发一条通知: 只是把睡着的常驻进程叫醒, 真实数据在表里 (通知不落盘). + + 这是 PG 自带的机制, 不是自造协议 (老板定调第 2 条: 不设计通信协议). + 约定: 驱动侧的通道名是 driver_<驱动名>, 所以驱动名别用连字符/空格 (那会让 LISTEN 的 + 标识符写法失效); 一律用字母 / 数字 / 中文 / 下划线. + """ + with 连接.cursor() as 游标: + 游标.execute("SELECT pg_notify(%s, %s)", (通道, 载荷)) + + +def 试锁(连接: Any, 键: int) -> bool: + """抢一把 PG **会话级**咨询锁: 拿到 True / 别人拿着 False. + + 用途: 常驻调度内核必须是**独一份** -- 两个调度器同时巡检 / 同时拉驱动就是打架, + 而且它们改的是同一份 PG 内存 (设计 02 第 3 节: 内存只有一份, 谁都不能自作主张). + 会话级锁的妙处: 进程一死连接就断, 锁自动放掉 -- 断电 / 被杀都不会留下死锁, + 下一次启动照样能起来 (不需要手工清锁). + """ + 行 = _查(连接, "SELECT pg_try_advisory_lock(%s) AS 拿到", (键,)) + return bool(行[0]["拿到"]) if 行 else False + + +def 收尸命令(连接: Any, 详情: str) -> int: + """把"上一次内核被断电/杀掉时没写完"的命令收尾 (state running -> failed), 返回条数. + + 跟引导器收 kernel_runs 是一个道理: 断电留下的 running 行会永远挂着, 没人认领. + """ + with 连接.cursor() as 游标: + 游标.execute( + "UPDATE commands SET state = 'failed', result = %s::jsonb, finished_at = now()" + " WHERE state = 'running' AND finished_at IS NULL", + (json.dumps({"detail": 详情}, ensure_ascii=False),), + ) + return int(游标.rowcount) + + +# ─────────────────────────────── 引导器两张台账 ─────────────────────────────── + + +def 记体检(连接: Any, 快照: dict[str, Any]) -> None: + """把一份体检快照写进 kernel_env (引导器 自检/环境/透传 时调). + + 参数: + 连接: PG 连接. + 快照: 就是写进 环境状态.efi.json 的那份 dict (同一份数据两处落盘: 文件 + 库). + 返回: + None.取值走 取bool() 容错 (快照是 json, 值可能是 None 或字符串). + """ + 包 = 快照.get("packages", []) + with 连接.cursor() as 游标: + 游标.execute( + "INSERT INTO kernel_env" + " (python_version, venv_path, venv_healthy, packages, pg_ok, driver_root_ok, ok, detail)" + " VALUES (%s, %s, %s, %s::jsonb, %s, %s, %s, %s)", + ( + str(快照.get("python", {}).get("version", "")), + str(快照.get("venv", {}).get("path", "")), + 取bool(快照.get("venv", {}).get("healthy")), + json.dumps(包, ensure_ascii=False), + 取bool(快照.get("pg", {}).get("ok")), + 取bool(快照.get("driver_root", {}).get("ok")), + 取bool(快照.get("blocking_ok")), + str(快照.get("detail", "")), + ), + ) + + +def 取bool(值: Any) -> bool: + """把 json 里的值统一成 bool. + + 为什么需要: + 快照是 json 序列化过的, 字段可能是 None / 字符串 "true" / 真 bool. + PG 的 boolean 列不认字符串, 先在这边归好再插. + """ + if 值 is None: + return False + if 值 is True or 值 is False: + return 值 + return str(值).strip().lower() in ("1", "true", "yes", "ok") + + +def 记运行开始(连接: Any, argv: str, mode: str, pid: int | None) -> int: + """内核要起进程了: 先插一行 (finished_at 留空 = 还在跑), 返回行 id 给后面收尾用. + + 参数: + 连接: PG 连接. + argv: 完整命令行原文 (出事时能看出到底跑的什么). + mode: oneshot (前台跑一次) | daemon (后台常驻). + pid: 常驻进程的 pid; 前台透传还没起时给 None. + 返回: + kernel_runs.id; 拿不到返回 0 (调用方按 0 当"没记成"). + """ + 工厂 = _游标工厂() + with 连接.cursor(cursor_factory=工厂) as 游标: + 游标.execute( + "INSERT INTO kernel_runs (argv, mode, pid) VALUES (%s, %s, %s) RETURNING id", + (argv, mode, pid), + ) + 行 = 游标.fetchone() + if 行 is None: + return 0 + return int(行["id"]) + + +def 记运行结束( + 连接: Any, + 运行id: int, + exit_code: int | None, + seconds: float, + ok: bool, + detail: str = "", +) -> None: + """给某次运行收尾: 补 finished_at / 退出码 / 耗时 / ok / detail. + + 参数: + 运行id: 记运行开始() 返回的 id. + exit_code: 退出码 (None = 没拿到). + seconds: 耗时秒. + ok: 成功与否. + detail: 一句话结论, 失败时贴日志尾巴 (不吞错). + """ + with 连接.cursor() as 游标: + 游标.execute( + "UPDATE kernel_runs SET finished_at = now(), exit_code = %s, seconds = %s, ok = %s, detail = %s" + " WHERE id = %s", + (exit_code, seconds, ok, detail, 运行id), + ) + + +def 记内核pid(连接: Any, 运行id: int, pid: int | None) -> None: + """补上常驻内核的 pid (启动成功后才知道, 所以单独一次 UPDATE).""" + with 连接.cursor() as 游标: + 游标.execute("UPDATE kernel_runs SET pid = %s WHERE id = %s", (pid, 运行id)) + + +def 最近运行(连接: Any) -> dict[str, Any] | None: + """最后一次运行记录 (内核 状态 用: 上次什么时候跑的,多久,退出码,ok 还是没标).""" + 行表 = _查(连接, "SELECT * FROM kernel_runs ORDER BY id DESC LIMIT 1", ()) + if not 行表: + return None + return 行表[0] + + +def 未结束运行(连接: Any) -> list[dict[str, Any]]: + """所有 finished_at 为空的记录 (断电/被杀留下的"假运行中"). + + 用途: 内核 状态 提示"PG 里有 N 条没收尾", 内核 停止 时统一收尾. + """ + return _查(连接, "SELECT * FROM kernel_runs WHERE finished_at IS NULL ORDER BY id", ()) + + +def 收尾未结束(连接: Any, detail: str) -> int: + """把所有没写 finished_at 的行补上 (标 ok=false + 写明是谁收的尾). + + 参数: + 连接: PG 连接. + detail: 收尾原因 (如 "引导器停止内核时收尾"). + 返回: + 补了几行 (调用方打印出来, 让老板知道有没有断电残留). + """ + with 连接.cursor() as 游标: + 游标.execute( + "UPDATE kernel_runs SET finished_at = now(), ok = false, detail = %s" + " WHERE finished_at IS NULL", + (detail,), + ) + return int(游标.rowcount) + + +def 今日运行次数(连接: Any) -> int: + """今天 (PG 所在时区的当天 0 点起) 跑了几次内核.""" + 行表 = _查( + 连接, + "SELECT count(*) AS 次数 FROM kernel_runs WHERE started_at >= date_trunc('day', now())", + (), + ) + if not 行表: + return 0 + return int(行表[0]["次数"]) + + +def 查(连接: Any, sql: str, 参数: tuple[Any, ...] = ()) -> list[dict[str, Any]]: + """公开的只读查询 (自测 / 诊断用; 生产流程请走上面那些有名字的函数). + + 存在的唯一理由: 自测要按下标核对**真落库**的值 (行数 / 某一列), 而 _查 是私有的 -- + 跨模块碰私有符号会被类型检查判 reportPrivateUsage. 这一步纯转手, 没有任何额外逻辑. + """ + return _查(连接, sql, 参数) + + +def _查(连接: Any, sql: str, 参数: tuple[Any, ...]) -> list[dict[str, Any]]: + """内部: 跑一条 SELECT 并把结果转成 dict 列表 (字典游标 + dict() 一次到位). + + 参数: + 连接: PG 连接. + sql: 带 %s 占位符的语句 (**永不用字符串拼参数**, 走驱动层的参数绑定). + 参数: 占位符的值. + 返回: + [{列名: 值, ...}, ...]; 没结果返回空表. + """ + 工厂 = _游标工厂() + with 连接.cursor(cursor_factory=工厂) as 游标: + 游标.execute(sql, 参数) + 行表 = 游标.fetchall() + 出: list[dict[str, Any]] = [] + for 行 in 行表: + 出.append(dict(行)) + return 出 diff --git a/内核/内核.py b/内核/内核.py new file mode 100644 index 0000000..18001f0 --- /dev/null +++ b/内核/内核.py @@ -0,0 +1,1272 @@ +#!/usr/bin/env python3 +"""内核: 总调度 + 纯 CLI + PostgreSQL 当内存. + +[它是什么] + 四步流程 (老板 内核设计.md 那 4 行) 的落地: + ① 遍历驱动目录认 配置.efi.json -> 扫描.py + ② 遍历驱动配置 + 校验 -> 扫描.py + ③ 生成驱动 json (注册表 + 快照) -> 扫描.py + db.py + ④ 管驱动进程 (linux 命令) -> 本文件 (复用 内核/进程.py) + 外加它是**总调度**: 契约匹配 / 启动排序 / 数据路由 / 级联启停 / 崩溃处理全归它. + +[为什么必须有内核 (而不是让驱动互相调用)] + 驱动之间零耦合: 不 import 对方, 配置里也不写对方的名字, 只声明 needs (我要什么) / provides + (我产出什么).那么"谁给谁,什么顺序,谁先谁后"就只能由唯一知道全局的一方来配 -- 内核. + 驱动要别人的东西时不直连, 而是往 calls 表插一行 (want 写契约名); 内核校验后转发.这样驱动之间 + 连对方是谁都不知道, 自然打不起来架. + +[四条定调 (老板 2026-09-15 亲定, 动手前对齐)] + 1. 界面 = 纯 CLI + 日志.没有 TUI; 输出要能 `>` 重定向成文件. + 2. 不设计通信协议: 驱动与内核读写同一个库, events 表就是总线. + 3. 内核常驻 (甲): 运行时看着依赖链 (契约满足唤醒下游 / 上游崩级联 / 驱动请求转发仲裁). + 命令走 PG commands 表 + LISTEN/NOTIFY (PG 自带的, 不是自造协议). + 4. 驱动 = 一个文件夹: 里面有源码 + venv 或 exec 文件; 内核只认根目录的 配置.efi.json. + +[命令一览] + (无参数) | 调度 常驻调度内核 (甲): 命令 + 调用仲裁 + 依赖巡检 + 列表 驱动清单 (默认动作) + 扫描 只扫不启: 刷新注册表 + 快照 + 收尸 + 启动 <名> / 停止 <名> / 重启 <名> + 状态 [名] [--json] 进程状态 (直读 /proc, 不信库里的旧 pid) + 日志 <名> [-n 200] [-f] 驱动日志 (stdout/stderr 都在这一个文件里) + 事件 [-n 50] 全局事件流 (events 表 = 总线) + 清单 输出清单 JSON (重定向就是文件) + +[判活一律回 /proc 复核] + driver_state 里的 pid 只是记账, **绝不当依据**: pid 会被系统复用, 拿旧 pid 发信号可能杀到别人 + (设计 02 第 4 节三条铁律之一).所以每次动作/渲染前都调 状态.复核() -> 进程.判活(). + +[本版不做 (留 v0.2, 设计 02 第 9 节)] + 程序/Skill 层加载,PG 角色级硬隔离 (现在是"约定 + 内核校验"),开机自启,TUI,资源限额. +""" + +from __future__ import annotations + +import json +import os +import signal +import sys +import time +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, cast + +项目根 = Path(__file__).resolve().parent.parent +# 先把 内核/ 塞进模块搜索路径, 再 import 兄弟模块 (和 UEFI.boot.py 一个套路) +sys.path.insert(0, str(项目根 / "内核")) + +import db +import 扫描 +import 状态 +import 日志 +import 进程 +import 文本 + +版本 = "内核 v0.1" +环境文件 = 项目根 / "环境.efi.json" +内核日志路径 = 日志.内核日志路径(项目根) # 结构化日志行 (内核自己写; 引导器不再往这重定向 fd) +内核输出路径 = 日志.内核输出路径(项目根) # 进程 stdout/stderr 原始流 (命令输出 + 崩溃原文) +引导器日志路径 = 日志.引导器日志路径(项目根) # 引导器写, 内核只读 (日志 --引导器 用) +心跳秒 = 300.0 # 常驻内核多久写一条 heartbeat 事件 (断电时间线用; 太密会把总线刷满) +巡检秒 = 10.0 # 依赖链巡检间隔 +调用默认时限秒 = 60.0 # 驱动调用没写 deadline 时给多久 (超时内核收权) +通知通道 = "内核" # 命令到达的唤醒通道 (LISTEN/NOTIFY 的通道名) +调度锁键 = 0x65666901 # 常驻内核的"独一份"咨询锁 (PG 会话级; 进程一死自动放, 不留死锁) + +# 日志开关 (两处最容易搞错的地方之一): 门槛挡"写不写", 控制台挡"要不要同时打 stderr". +# 值从 环境.efi.json 来 (引导器写, 内核读), main() 一进来就用 用环境() 设好. +日志门槛: str = 日志.默认门槛 +日志控制台: bool = True + + +# ─────────────────────────────── 日志与配置 ─────────────────────────────── + + +def 说(级别: str, 消息: str) -> None: + """写一条内核日志 (设计 02 §6): 时间 级别 [内核] 内容. + + 落 内核/logs/内核.log (结构化行, 只有这一种内容); 是否同时打 stderr 看 日志控制台 + (守护模式关掉: 那时 stdout/stderr 都被重定向进 内核.out.log, 再从 stderr 走一遍就是副本). + 写失败不拦内核干活 -- 但绝不"静默吞错": 错误内容本身照原文写出去. + """ + 日志.记(内核日志路径, 级别, "内核", 消息, 门槛=日志门槛, 控制台=日志控制台) + + +def 用环境(环境: dict[str, Any]) -> None: + """从环境配置装日志开关 (门槛 / 控制台). + + 为什么单独一个函数: 读环境() 自己也要写日志 (配置坏了就是 ERROR), 而门槛得在它之后才有-- + 所以 ERROR 级别的报错走默认门槛 INFO 先写出去, 装好开关再按配置过滤. + """ + global 日志门槛, 日志控制台 + 日志门槛 = 日志.规范化级别(环境.get("log_level") or 日志.默认门槛) + 日志控制台 = 日志.控制台开() + + +def 读环境() -> dict[str, Any]: + """读项目根的 环境.efi.json (内核**只读**; 写它的是引导器). + + 读不了 / 不是合法 JSON -> 报错退出.内核不像引导器那样能降级: + 它的内存就是 PG, 而连接信息就在这份配置里, 猜不出来就别硬跑. + """ + try: + 原文 = 环境文件.read_text(encoding="utf-8") + except OSError as 错: + 说("ERROR", f"环境配置读不了: {错} (先跑 UEFI.boot.py --check 看环境)") + raise SystemExit(1) from 错 + try: + 数据: Any = json.loads(原文) + except json.JSONDecodeError as 错: + 说("ERROR", f"环境配置不是合法 JSON: {错}") + raise SystemExit(1) from 错 + if not isinstance(数据, dict): + 说("ERROR", "环境配置顶层必须是对象 ({...})") + raise SystemExit(1) + return cast(dict[str, Any], 数据) + + +def 驱动根(环境: dict[str, Any]) -> Path: + """驱动根目录 (环境.efi.json 的 driver_root; 相对路径按项目根解析).""" + 值 = str(环境.get("driver_root") or "驱动") + 路径 = Path(值) + return 路径 if 路径.is_absolute() else (项目根 / 路径) + + +def 停超时(环境: dict[str, Any]) -> float: + """SIGTERM 之后等多久升级 SIGKILL (环境配置 stop_timeout, 默认 10s).""" + try: + return max(float(环境.get("stop_timeout") or 10), 0.5) + except (TypeError, ValueError): + return 10.0 + + +def 日志行数(环境: dict[str, Any]) -> int: + """日志默认显示多少行 (环境配置 log_lines, 默认 200).""" + try: + return max(int(环境.get("log_lines") or 200), 1) + except (TypeError, ValueError): + return 200 + + +def 日志上限(环境: dict[str, Any]) -> int: + """单份日志的轮转阈值, 单位字节 (环境配置 log_max_mb, 默认 5 MB; 0 = 不轮转).""" + 原始 = 环境.get("log_max_mb") + if 原始 is None: + return 日志.默认上限字节 + try: + return max(int(float(str(原始)) * 1024 * 1024), 0) + except (TypeError, ValueError): + return 日志.默认上限字节 + + +def 日志保留(环境: dict[str, Any]) -> int: + """轮转后留几份历史 (环境配置 log_keep, 默认 3; 0 = 不轮转).""" + try: + return max(int(环境.get("log_keep") or 日志.默认保留份数), 0) + except (TypeError, ValueError): + return 日志.默认保留份数 + + +def 轮转日志(路径: Path, 环境: dict[str, Any], 称呼: str) -> None: + """拉起进程之前轮转一次日志 (常驻进程的日志不能让它无限长). + + 为什么在启动前: 运行中的进程按 fd 追加写, 中途改名会让它继续写老 inode (等于日志丢了); + 所以只在"还没起它"的时候轮转, 简单且不出错. + """ + if 日志.轮转(路径, 日志上限(环境), 日志保留(环境)): + 说("WARN", f"{称呼}: 日志超过上限, 已轮转 (旧份改名 .1)") + + +def 连接串(环境: dict[str, Any]) -> str: + """PG 连接串 (libpq 关键字式), 注入给驱动当环境变量 EFI_DB. + + 为什么走环境变量不落盘: 连接信息不落盘是老板定过的口径 (设计 01 第 3 节); + 驱动拿 os.environ["EFI_DB"] 直连, 不需要问内核要地址. + """ + 库 = db.从配置(环境.get("db")) + return f"host='{库.host}' port={库.port} dbname='{库.name}' user='{库.user}'" + + +def 字符串表(值: Any) -> list[str]: + """把库里取出来的"字符串数组"统一成 list[str] (provides / needs / args 都是这种). + + 为什么要它: + `驱动.get("needs") or []` 的类型是 `Any | list[Unknown]` -- 直接迭代, 元素是 Unknown, + 严格检查会满屏"类型部分未知".在这里 cast 一次, 后面到处都干净 (和引导器里 取对象/取清单 + 是一套思路). 顺手挡脏值: 不是数组给空表, 空串 / 非字符串项丢掉. + """ + 清单 = cast(list[Any], 值) if isinstance(值, list) else [] + return [文本项 for 文本项 in (str(项).strip() for 项 in 清单) if 文本项] + + +# ─────────────────────────────── 连库 ─────────────────────────────── + + +def 连库(环境: dict[str, Any]) -> Any: + """连 PG 并建表 (内核建全部 8 张, CREATE TABLE IF NOT EXISTS 幂等). + + PG 连不上 -> 报错退出, **不降级到文件模式** (内存不在就没法干活; 两份真相更坏). + """ + 库 = db.从配置(环境.get("db")) + try: + 连接 = db.连(库) + except Exception as 错: # psycopg2 的异常类由 db.py 那层管, 这里只关心"连不上" + 说("ERROR", f"连不上 PG ({库.描述()}): {错}") + raise SystemExit(1) from 错 + db.建表(连接) + return 连接 + + +def 重连(环境: dict[str, Any], 旧连接: Any) -> Any: + """常驻内核的断线自愈: 关掉旧连接再连一次 (10 次, 每次隔 3 秒). + + 为什么要自愈: 常驻进程不能因为一次网络/数据库抖动就得人去敲命令重起 (跑稳了别动它的反面 + 是"别让它动不动就死").10 次还连不上就认输退出, 让引导器看门狗报出来. + """ + try: + 旧连接.close() + except Exception: + pass + for 次 in range(1, 11): + try: + return 连库(环境) + except SystemExit: + 说("WARN", f"重连 PG 失败 (第 {次}/10 次), 3 秒后再试") + time.sleep(3) + 说("ERROR", "重连 PG 连续失败 10 次, 退出 (让引导器看门狗发现)") + raise SystemExit(1) + + +# ─────────────────────────────── 拼命令 ─────────────────────────────── + + +def 拼命令(驱动: dict[str, Any], 环境: dict[str, Any]) -> tuple[list[str], dict[str, str], list[str]]: + """拼出启动命令行 + 子进程环境, 返回 (argv, env, 警告表). + + python 形态 (设计 01 第 2 节的解释器解析顺序): + 绝对路径 -> 用它 + "venv" -> <驱动根>/.venv/bin/python; 不存在 -> 回落 system 并**记警告** (不假死) + "system" -> python3 + exec 形态: [入口] + args (不看后缀, 看 x 位 -- 扫描阶段已经查过 x 位) + + env = 内核进程的环境 (基线) + EFI_DB (PG 连接串, 不落盘) + 驱动配置里的 env (可覆盖). + 返回的警告表由调用方记进日志/事件 -- 这里不自己写库 (拼命令行这层不碰 SQL). + """ + 入口 = 状态.入口路径(驱动) + args = 字符串表(驱动.get("args")) + 警告表: list[str] = [] + argv: list[str] = [] + + if str(驱动.get("runtime") or "python") == "exec": + argv = [str(入口), *args] + else: + 声明 = str(驱动.get("interpreter") or "venv") + if 声明.startswith("/"): + 解释器 = 声明 + if not Path(解释器).exists(): + 警告表.append(f"配置里写的解释器不存在: {解释器} (起不起来会直接暴露)") + elif 声明 == "system": + 解释器 = "python3" + else: + venv解释器 = 状态.驱动根(驱动) / ".venv" / "bin" / "python" + if venv解释器.exists(): + 解释器 = str(venv解释器) + else: + 解释器 = "python3" + 警告表.append(f"声明用 venv 但没找到 {venv解释器}, 回落 system python3") + argv = [解释器, str(入口), *args] + + env = dict(os.environ) + env["EFI_DB"] = 连接串(环境) + 追加 = 驱动.get("env") + if isinstance(追加, dict): + for 键, 值 in cast(dict[str, Any], 追加).items(): + env[str(键)] = str(值) + return argv, env, 警告表 + + +# ─────────────────────────────── 单驱动动作 ─────────────────────────────── + + +def 收僵尸(pid: int) -> int | None: + """如果这个死掉的进程是**我们自己**的孩子, wait 掉它 (不留僵尸), 顺手取出退出码. + + 为什么必须收: + 父进程不 wait, 死掉的子进程会挂着 Z 态一直占一个 pid 名额 (停止就会永远报"还有 N 个 + 没收掉", 实际它早死了) -- 这个坑在引导器的进程库里踩过一次, 这里不留第二次. + 只有生它的进程能 wait; 不是自己的孩子会抛 ChildProcessError, 当没这回事 (交给 init 收). + 返回: + 退出码 (被信号杀的是负数); 不是我们的孩子 / 还没死透 -> None. + """ + try: + 结果 = os.waitpid(pid, os.WNOHANG) + except (ChildProcessError, OSError): + return None + if 结果[0] == 0: + return None + 状态位 = 结果[1] + if os.WIFEXITED(状态位): + return os.WEXITSTATUS(状态位) + if os.WIFSIGNALED(状态位): + return -os.WTERMSIG(状态位) + return None + + +def 拉起一个( + 连接: Any, + 环境: dict[str, Any], + 驱动: dict[str, Any], + 行: dict[str, Any] | None, + 理由: str = "", +) -> tuple[bool, str]: + """把一个驱动拉起来并记账 (启动 / 重启 / 按需 / 自动重拉 全走这一份实现). + + 参数: + 连接 / 环境: PG 与项目配置. + 驱动: drivers 表一行. + 行: driver_state 表一行 (用来读上一次的 restarts / pid). + 理由: 非空时写进事件, 说明"为什么这次是内核主动拉的" (如 on-failure / autostart). + 返回: + (起没起来, 一句话结论). + """ + 名 = str(驱动.get("name")) + argv, env, 警告表 = 拼命令(驱动, 环境) + for 警告 in 警告表: + 说("WARN", f"{名}: {警告}") + db.写事件(连接, "内核", "log", f"{名}: {警告}", driver=名, level="warn") + + 上次次数 = int(行.get("restarts") or 0) if 行 else 0 + 驱动日志 = 状态.日志路径(驱动) + 轮转日志(驱动日志, 环境, 名) + 结果 = 进程.启动( + argv, + cwd=状态.驱动根(驱动), + 日志=驱动日志, + env=env, + 入口=状态.入口路径(驱动), + 分隔=f"启动 {名} {' '.join(argv)}", + ) + + if 结果.ok: + db.写状态( + 连接, + 名, + { + "state": 状态.运行, + "pid": 结果.pid, + "pgid": 结果.pgid, + "started_at": 状态.现在文本(), + "stopped_at": None, + "exit_code": None, + "restarts": 上次次数 + 1, + "boot_hash": str(驱动.get("config_hash") or ""), + "last_error": None, + }, + ) + 尾巴 = f" ({理由})" if 理由 else "" + db.写事件(连接, "内核", "start", f"{名} 起来了 pid={结果.pid} pgid={结果.pgid}{尾巴}", driver=名) + return True, f"已拉起 pid={结果.pid}" + + # oneshot (批处理型): 跑完就退是**正常**的 (设计 01 第 2 节), 不算失败 + if str(驱动.get("mode") or "resident") == "oneshot" and 结果.exit_code == 0: + db.写状态( + 连接, + 名, + { + "state": 状态.已退出, + "pid": None, + "pgid": None, + "exit_code": 0, + "restarts": 上次次数 + 1, + "boot_hash": None, + "stopped_at": 状态.现在文本(), + "last_error": None, + }, + ) + db.写事件(连接, "内核", "exit", f"{名} oneshot 跑完就退 (退出码 0) -- 正常", driver=名) + return True, "oneshot 跑完就退 (退出码 0)" + + # 其余秒退 / spawn 失败: 记 failed + 退出码 + 本次启动的日志尾巴 (不吞错) + db.写状态( + 连接, + 名, + { + "state": 状态.失败, + "pid": None, + "pgid": None, + "exit_code": 结果.exit_code, + "restarts": 上次次数 + 1, + "boot_hash": None, + "last_error": 结果.detail, + }, + ) + db.写事件(连接, "内核", "error", f"{名} 起不来: {结果.detail}", driver=名, level="error") + return False, 结果.detail + + +def 停一个(连接: Any, 环境: dict[str, Any], 名: str, 理由: str = "") -> tuple[bool, str]: + """停一个驱动 (先校验 cmdline, 再 SIGTERM 进程组; 幂等). + + 步骤 (设计 02 第 4 节): + 1. 没有 pid / /proc 里没了 -> 直接置 stopped (幂等, 不算错); + 2. pid 在但 cmdline 对不上 -> 判"pid 被复用", **一个信号都不发** (宁可不杀, 不可误杀); + 3. SIGTERM 给进程组 (连子树一起收), 超时升级 SIGKILL (在 进程.停止 里); + 4. 停干净才置 stopped + stopped_at, 顺手收自己的僵尸孩子. + 返回: + (干净没干净, 一句话结论). + """ + 驱动 = db.取驱动(连接, 名) + if 驱动 is None: + return False, f"没这个驱动: {名}" + 行 = db.取状态(连接, 名) or {} + pid值 = 行.get("pid") + pid = int(pid值) if isinstance(pid值, int) else None + 入口 = 状态.入口路径(驱动) + 活 = 进程.判活(pid, 入口) + + if 活 in (进程.已停止, 进程.已崩): + db.写状态( + 连接, + 名, + { + "state": 状态.停止 if 活 == 进程.已停止 else 状态.崩了, + "pid": None, + "pgid": None, + "stopped_at": 状态.现在文本(), + }, + ) + db.写事件(连接, "内核", "stop", f"{名} 本来就不在 (幂等)", driver=名) + return True, "本来就没在跑 (幂等)" + + if 活 == 进程.僵尸: + if pid is not None: + 收僵尸(pid) + db.写状态(连接, 名, {"state": 状态.停止, "pid": None, "pgid": None, "stopped_at": 状态.现在文本()}) + db.写事件(连接, "内核", "stop", f"{名} 已经是僵尸了 (等回收), 当它停了", driver=名) + return True, "已经是僵尸 (已死)" + + if 活 == 进程.被复用 or pid is None: + db.写状态( + 连接, + 名, + { + "state": 状态.已退出, + "pid": None, + "pgid": None, + "stopped_at": 状态.现在文本(), + "last_error": f"pid {pid} 已被系统分给别人, 没发信号", + }, + ) + db.写事件(连接, "内核", "error", f"{名}: pid {pid} 被复用, 没敢杀", driver=名, level="warn") + return False, f"pid {pid} 被复用, 没敢杀 (宁可不杀, 不可误杀)" + + 结果 = 进程.停止(pid, 入口=入口, 超时=停超时(环境)) + 收僵尸(pid) + 说明 = 结果.detail + (f" ({理由})" if 理由 else "") + if 结果.ok: + db.写状态( + 连接, + 名, + { + "state": 状态.停止, + "pid": None, + "pgid": None, + "stopped_at": 状态.现在文本(), + "last_error": None, + }, + ) + db.写事件(连接, "内核", "stop", f"{名} 已停: {说明}", driver=名) + return True, 说明 + + db.写状态( + 连接, + 名, + {"state": 状态.失败, "stopped_at": 状态.现在文本(), "last_error": 结果.detail}, + ) + db.写事件(连接, "内核", "error", f"{名} 停不干净: {结果.detail}", driver=名, level="error") + return False, 结果.detail + + +def 下游名(全部: dict[str, dict[str, Any]], 名: str) -> list[str]: + """谁依赖我: needs 里出现了我 provides 的契约的驱动 (驱动之间不认识, 这层关系只有内核知道).""" + 目标 = 全部.get(名) + if 目标 is None: + return [] + 我的契约 = set(字符串表(目标.get("provides"))) + if not 我的契约: + return [] + 出: list[str] = [] + for 别的名, 别的 in sorted(全部.items()): + if 别的名 == 名: + continue + 要的 = set(字符串表(别的.get("needs"))) + if 我的契约 & 要的: + 出.append(别的名) + return 出 + + +def 停序(连接: Any, 名: str, 已处理: set[str]) -> list[str]: + """算出"停 名 的时候要按什么顺序停谁": 先递归停掉下游 (消费者), 最后停它自己. + + 这就是设计里的"级联生命周期": 停上游 -> 依赖它的下游一并停, 顺序是消费者先走 + (别让下游在上游已经没了之后还在消费空气). + """ + if 名 in 已处理: + return [] + 已处理.add(名) + 全部 = {str(d.get("name")): d for d in db.取全部驱动(连接)} + 顺序: list[str] = [] + for 下 in 下游名(全部, 名): + 顺序.extend(停序(连接, 下, 已处理)) + 顺序.append(名) + return 顺序 + + +# ─────────────────────────────── 驱动调用仲裁 (防打架六条) ─────────────────────────────── + + +def 取链(行: dict[str, Any]) -> list[str]: + """从 calls.args 里取调用链 (驱动转发时带上, 内核用它挡 A->B->A 这种环). + + 约定: args = {"chain": ["驱动A", "驱动B", ...]} (可省).缺省就是空链, 只靠契约成环去挡. + """ + 参数 = 行.get("args") + if not isinstance(参数, dict): + return [] + 链 = cast(dict[str, Any], 参数).get("chain") + if not isinstance(链, list): + return [] + return [str(项) for 项 in cast(list[Any], 链)] + + +def 校验调用(连接: Any, 环境: dict[str, Any], 契约: dict[str, str], 行: dict[str, Any]) -> tuple[str, str]: + """给一条驱动调用请求做仲裁 (防打架六条), 返回 (判定, 说明). + + 判定三种: + denied 拒 (越权 / 成环 / 没人提供 / 提供方起不来) + waiting 排队 (同一把锁上已经有调用在跑) + running 放行转发 + """ + 调用id = int(行.get("id") or 0) + 调用方 = str(行.get("caller") or "") + 要的 = str(行.get("want") or "") + + # 第 5 条 越权: 只能要自己 needs 里声明过的契约 + 调用方驱动 = db.取驱动(连接, 调用方) + if 调用方驱动 is None: + return "denied", f"调用方 {调用方} 不是注册的驱动" + 声明 = set(字符串表(调用方驱动.get("needs"))) + if 要的 not in 声明: + return "denied", f"越权: {调用方} 的 needs 里没有 {要的}" + + # 第 2 条 成环: 契约成环在扫描期就拒载了, 这里再挡运行时链路成环. + # 链的含义是"这次请求已经走到的路" (发起方 + 它的上游), 所以只有**要的契约**或者 + # **匹配出来的提供方**已经在链上才算环; 不能拿调用方自己判 -- 它一定在链尾, 那会把 + # 第一次调用也拒掉 (2026-09-16 踩过: 消费器第一次请求就被判"成环"). + 链 = 取链(行) + 提供方 = 契约.get(要的, "") + if 要的 in 链 or (提供方 != "" and 提供方 in 链): + return "denied", "调用链成环: " + " -> ".join([*链, 提供方 or 要的]) + + # 第 3 条 想调一个没起来的驱动: 先按需拉起, 拉不起来才拒 + if not 提供方: + return "denied", f"契约没人提供: {要的}" + 提供驱动 = db.取驱动(连接, 提供方) + if 提供驱动 is None: + return "denied", f"契约 {要的} 的提供方 {提供方} 不在注册表里" + 提供行 = db.取状态(连接, 提供方) or {} + if 进程.判活(提供行.get("pid"), 状态.入口路径(提供驱动)) != 进程.运行中: + 好, 说明 = 拉起一个(连接, 环境, 提供驱动, 提供行, f"被调用 {调用id} 按需拉起") + if not 好: + return "denied", f"提供方 {提供方} 起不来: {说明}" + + # 第 1 条 同一 lock_key 串行化: 先到先执行, 后面的排队 + 锁 = str(行.get("lock_key") or "") + if 锁 and db.同锁在跑(连接, 锁, 调用id): + return "waiting", f"锁 {锁} 上已有调用在跑, 排队" + + return "running", f"转发给 {提供方}" + + +def 转发调用(连接: Any, 环境: dict[str, Any], 契约: dict[str, str], 行: dict[str, Any]) -> None: + """仲裁 + 转发一条调用请求 (结果字段留给驱动回填: 内核只做仲裁/唤醒/收权). + + 转发的含义: 把 state 改成 running, 记下 provider 和 deadline, 然后 pg_notify 唤醒提供方 + (常驻驱动自己 LISTEN driver_<名>).**没有任何自造协议** -- 用的都是 PG 自带的表和通知. + """ + 调用id = int(行.get("id") or 0) + 调用方 = str(行.get("caller") or "") + 要的 = str(行.get("want") or "") + 判定, 说明 = 校验调用(连接, 环境, 契约, 行) + + if 判定 == "denied": + db.写调用(连接, 调用id, {"state": "denied", "error": 说明, "finished_at": 状态.现在文本()}) + db.写事件(连接, "内核", "error", f"调用 {调用id} 被拒: {说明}", driver=调用方, level="warn") + return + + if 判定 == "waiting": + db.写调用(连接, 调用id, {"state": "waiting", "error": 说明}) + return + + 提供方 = 契约.get(要的, "") + 时限 = datetime.now().astimezone() + timedelta(seconds=调用默认时限秒) + db.写调用( + 连接, + 调用id, + { + "state": "running", + "provider": 提供方, + "started_at": 状态.现在文本(), + "deadline": 时限.isoformat(timespec="seconds"), + "error": None, + }, + ) + db.写事件( + 连接, + "内核", + "produce", + f"调用 {调用id}: {调用方} 要 {要的} -> 转发给 {提供方}", + driver=提供方, + ) + db.通知(连接, f"driver_{提供方}", str(调用id)) + + +def 收权超时(连接: Any) -> int: + """给超时的调用收权 (防打架第 4 条): 标 timeout + 释放锁, 免得一个卡死的驱动拖垮全局. + + 返回: + 收了几条. + """ + 现在 = datetime.now().astimezone() + 收 = 0 + for 行 in db.取调用(连接, "running"): + 时限值 = 行.get("deadline") + if 时限值 is None: + continue + 时刻 = 时限值 if isinstance(时限值, datetime) else None + if 时刻 is None: + continue + if 时刻.astimezone() < 现在: + db.写调用( + 连接, + int(行.get("id") or 0), + { + "state": "timeout", + "error": f"超过期限 {时刻.isoformat(timespec='seconds')}, 内核收权", + "finished_at": 状态.现在文本(), + }, + ) + db.写事件( + 连接, + "内核", + "error", + f"调用 {行.get('id')} 超时收权 (提供方 {行.get('provider')})", + driver=str(行.get("provider") or ""), + level="warn", + ) + 收 += 1 + return 收 + + +# ─────────────────────────────── 巡检 (常驻内核的看家活) ─────────────────────────────── + + +def 巡检(连接: Any, 环境: dict[str, Any]) -> None: + """周期性看一眼依赖链, 该动作的动作: + + 1. 收尸: 旧状态 + /proc 判现在真状态 (崩了的改库 + 记 exit 事件); + 2. 按策略重拉: autostart (断电后) / restart=on-failure (自己崩的); + 3. 级联: 上游不在了 -> 下游标"依赖失效" (不装作没事); + 4. 调用收权: 超时的 calls 标 timeout. + """ + 全部 = {str(d.get("name")): d for d in db.取全部驱动(连接)} + 契约表 = 扫描.取契约(list(全部.values())) + for 名, 驱动 in 全部.items(): + if not 驱动.get("valid"): + continue + 行 = db.取状态(连接, 名) or {} + 新状态, 原因 = 状态.复核(驱动, 行) + 旧状态 = str(行.get("state") or "") + if 新状态 != 旧状态: + 改动: dict[str, Any] = {"state": 新状态, "last_error": 原因 or None} + if 新状态 in (状态.已退出, 状态.崩了): + 改动["pid"] = None + 改动["pgid"] = None + 改动["stopped_at"] = 状态.现在文本() + 死pid = 行.get("pid") + if isinstance(死pid, int): + 退出码 = 收僵尸(死pid) + if 退出码 is not None: + 改动["exit_code"] = 退出码 + db.写状态(连接, 名, 改动) + db.写事件( + 连接, + "内核", + "exit", + f"{名}: {旧状态} -> {新状态} ({原因})" if 原因 else f"{名}: {旧状态} -> {新状态}", + driver=名, + level="warn", + ) + 行 = db.取状态(连接, 名) or 行 + 理由 = 状态.该拉起(驱动, 行, 新状态) + if 理由: + 拉起一个(连接, 环境, 驱动, 行, 理由) + # 级联: 上游没在跑 -> 下游标"依赖失效" (状态还是 running, 但把话说清楚) + if str(行.get("state") or "") == 状态.运行: + 缺: list[str] = [] + for 契约名 in 字符串表(驱动.get("needs")): + 上 = 契约表.get(契约名, "") + if not 上 or 上 == 名: + continue + 上行 = db.取状态(连接, 上) or {} + if 进程.判活(上行.get("pid"), 状态.入口路径(全部.get(上, {}))) == 进程.运行中: + continue + # 只有"上游崩了 / 起不来"才算下游依赖失效; 上游只是还没启动 (等着按需拉起) + # 属于正常状态, 不能天天报假警 (2026-09-16 踩过: 没启动也标"依赖失效"). + 上状态 = str(上行.get("state") or 状态.停止) + if 上状态 in (状态.崩了, 状态.失败): + 缺.append(f"{契约名}(提供方 {上}, 状态 {状态.显示状态(上状态)})") + if 缺: + 说明 = "依赖失效: 上游不在跑 " + ", ".join(缺) + if str(行.get("last_error") or "") != 说明: + db.写状态(连接, 名, {"last_error": 说明}) + db.写事件(连接, "内核", "error", f"{名}: {说明}", driver=名, level="warn") + 收权超时(连接) + + +# ─────────────────────────────── CLI 动作 ─────────────────────────────── + + +def 打印列表(结果: 扫描.扫描结果, 环境: dict[str, Any]) -> None: + """打印驱动清单表 + 页脚 (设计 02 第 6 节的输出样例).""" + 行表: list[list[str]] = [] + 状态表 = {str(行.get("name")): 行 for 行 in 结果.状态表} + 驱动表 = {str(d.get("name")): d for d in 结果.驱动} + for 名 in sorted(驱动表): + 驱动 = 驱动表[名] + 行 = 状态表.get(名) or {} + if not 驱动.get("valid"): + 行表.append([状态.显示状态(状态.无效), 名, str(驱动.get("runtime") or ""), "—", "—", "—", + str(驱动.get("error") or "")]) + continue + 活, 原因 = 状态.复核(驱动, 行) + pid值 = 行.get("pid") + 信息 = 进程.读进程信息(int(pid值)) if isinstance(pid值, int) else None + 时长 = 信息.存活文本 if (活 == 状态.运行 and 信息 is not None) else "—" + 配置列 = "待重启" if 状态.待重启(驱动, 行) else "一致" + 说明 = 原因 or (str(驱动.get("note") or "") or "—") + if 活 == 状态.运行 and 状态.待重启(驱动, 行): + 说明 = "配置已改, 需重启生效" + 行表.append([ + 状态.显示状态(活), + 名, + str(驱动.get("runtime") or ""), + str(pid值) if isinstance(pid值, int) and 活 == 状态.运行 else "—", + 时长, + 配置列, + 说明, + ]) + if not 行表: + 行表.append(["—", "(一个驱动都没有)", "—", "—", "—", "—", "驱动根: " + str(驱动根(环境))]) + 文本.打印(文本.表格(["状态", "驱动名", "形态", "PID", "运行时长", "配置", "说明"], 行表)) + print() + print(f"驱动 {结果.总数} / 有效 {结果.有效} / 运行 {结果.在跑} / 无效 {结果.无效}" + f" 扫描 #{结果.清单版本} {datetime.now().astimezone().strftime('%H:%M:%S')}") + for 警告 in 结果.问题: + print(f" [WARN] {警告}") + + +def 命令列表(连接: Any, 环境: dict[str, Any]) -> int: + """列表: 扫一遍再渲染 (列表要反映磁盘现状, 不能拿旧注册表糊弄).""" + 结果 = 扫描.扫描(连接, 驱动根(环境), 版本) + 打印列表(结果, 环境) + return 0 + + +def 命令扫描(连接: Any, 环境: dict[str, Any]) -> int: + """扫描: 只扫不启 (刷新注册表 + 快照 + 收尸), 打印一行汇总. + + "只扫不启"是有意的: 想启动谁就显式敲 `启动 <名>`; autostart 的拉起发生在常驻内核启动时. + """ + 结果 = 扫描.扫描(连接, 驱动根(环境), 版本) + print(f"扫描完成: 驱动 {结果.总数} / 有效 {结果.有效} / 运行 {结果.在跑} / 无效 {结果.无效}" + f" 扫描 #{结果.清单版本}") + for 名, 理由 in 结果.该拉起: + print(f" [建议] {名}: {理由} (敲 启动 {名} 或让常驻内核去拉)") + for 警告 in 结果.问题: + print(f" [WARN] {警告}") + return 0 + + +def 命令启动(连接: Any, 环境: dict[str, Any], 名: str) -> int: + """启动一个驱动 (幂等: 已经在跑就直说, 不重复起).""" + 驱动 = db.取驱动(连接, 名) + if 驱动 is None: + print(f"没这个驱动: {名} (先 扫描 看看驱动根里有什么)") + return 1 + if not 驱动.get("valid"): + print(f"驱动 {名} 配置不合法, 不能启动: {驱动.get('error')}") + return 1 + 行 = db.取状态(连接, 名) or {} + 入口 = 状态.入口路径(驱动) + if 进程.判活(行.get("pid"), 入口) == 进程.运行中: + print(f"{名} 已经在跑 (pid {行.get('pid')}) -- 幂等, 不重复起") + return 0 + 好, 说明 = 拉起一个(连接, 环境, 驱动, 行) + print(f"{名}: {说明}") + return 0 if 好 else 1 + + +def 命令停止(连接: Any, 环境: dict[str, Any], 名: str) -> int: + """停一个驱动 (级联: 依赖它的下游先停).""" + 驱动 = db.取驱动(连接, 名) + if 驱动 is None: + print(f"没这个驱动: {名}") + return 1 + 顺序 = 停序(连接, 名, set()) + 码 = 0 + for 目标 in 顺序: + 理由 = "" if 目标 == 名 else f"上游 {名} 要停" + 好, 说明 = 停一个(连接, 环境, 目标, 理由) + print(f"{目标}: {说明}") + if not 好: + 码 = 1 + return 码 + + +def 命令重启(连接: Any, 环境: dict[str, Any], 名: str) -> int: + """重启一个驱动: 先停干净 (含级联), 再起 (一次只动一件事).""" + 停码 = 命令停止(连接, 环境, 名) + if 停码 != 0: + print("停得不干净, 先不起了 (别在没停干净的进程上再叠一个)") + return 停码 + 行 = db.取状态(连接, 名) or {} + if 进程.判活(行.get("pid"), 状态.入口路径(db.取驱动(连接, 名) or {})) == 进程.运行中: + print("还有进程活着, 先不起了") + return 1 + return 命令启动(连接, 环境, 名) + + +def 命令状态(连接: Any, 环境: dict[str, Any], 参数: list[str]) -> int: + """状态 [名] [--json]: 不给名字就出整张清单 (跟 列表 一样), 给了就出单个详情. + + 判活走 状态.复核() -> 进程.判活() (直读 /proc//cmdline, 不信库里的旧 pid); + 跟库里的状态不一致时**如实标出来**, 但不改库 (这是只读命令, 收尸是 扫描/巡检 的活). + """ + 要json = "--json" in 参数 + 参数 = [项 for 项 in 参数 if not 项.startswith("--")] + if not 参数: + if 要json: + return 命令清单(连接, 环境) + 结果 = 扫描.扫描(连接, 驱动根(环境), 版本) + 打印列表(结果, 环境) + return 0 + + 名 = 参数[0] + 驱动 = db.取驱动(连接, 名) + if 驱动 is None: + print(f"没这个驱动: {名}") + return 1 + 行 = db.取状态(连接, 名) or {} + 活, 原因 = 状态.复核(驱动, 行) + pid值 = 行.get("pid") + 信息 = 进程.读进程信息(int(pid值)) if isinstance(pid值, int) else None + if 要json: + 出 = { + "efi": 1, + "kernel": 版本, + "ts": 状态.现在文本(), + "注册表": 驱动, + "状态": 状态.组装快照(驱动, 行, 版本), + "现场": { + "判定": 活, + "原因": 原因, + "存活秒": 信息.存活秒 if 信息 is not None else None, + "内存KB": 信息.rss_kb if 信息 is not None else None, + "cmdline": 信息.cmdline if 信息 is not None else [], + }, + } + print(json.dumps(出, ensure_ascii=False, indent=2, default=str)) + return 0 + + 行表 = [ + ["项", "值"], + ["状态", 状态.显示状态(活) + (f" ({原因})" if 原因 else "")], + ["形态", f"{驱动.get('runtime')} mode={驱动.get('mode')} restart={驱动.get('restart')}"], + ["入口", str(状态.入口路径(驱动))], + ["PID / 进程组", f"{pid值} / {行.get('pgid')}" if pid值 else "—"], + ["启动时间", str(行.get("started_at") or "—")], + ["运行时长", 信息.存活文本 if 信息 is not None else "—"], + ["内存 / CPU", f"{信息.内存文本} / {信息.cpu秒:.1f}s" if 信息 is not None else "—"], + ["命令行", 信息.cmdline文本 if 信息 is not None else "—"], + ["配置", ("待重启 (配置改过, 重启才生效)" if 状态.待重启(驱动, 行) else "一致")], + ["重启次数", str(行.get("restarts") or 0)], + ["契约", "提供: " + (", ".join(字符串表(驱动.get("provides"))) or "—") + + " 需要: " + (", ".join(字符串表(驱动.get("needs"))) or "—")], + ["上次错误", str(行.get("last_error") or "—")], + ] + 文本.打印(文本.表格(行表[0], 行表[1:], [文本.左, 文本.左])) + if 原因: + print(f"\n 注意: 现判 {状态.显示状态(活)} 而库里记的是 {状态.显示状态(行.get('state'))} -- " + f"库里的状态是上次记账, 以现场判定为准") + return 0 + + +def 命令日志(连接: Any, 环境: dict[str, Any], 参数: list[str]) -> int: + """日志 [驱动名] [-n 200] [-f] [--级别 X] [-g 关键词] [--json] [--全部] [--内核] [--引导器] + + 四种形态 (同一个日志子系统, 区别只是"看谁的"): + 日志 驱动日志台账 (谁有日志 / 多大 / 几行 / 最后改动) + 日志 <驱动名> ... 某个驱动的日志 (默认尾部 200 行) + 日志 --全部 ... 所有驱动的日志尾部 (每条前一行 == 驱动名 ==) + 日志 --内核 / --引导器 内核自己的结构化日志 / 引导器的动作日志 + + 驱动日志是**别人的原始 stdout** (内核只重定向不解析): 所以级别只能靠字样猜 (猜级别), + 内核/引导器日志是本库写的固定格式, 级别是真字段 (--json 出去也是拆好的字段). + """ + 选, 问题 = 日志.解析选项(参数, 日志行数(环境)) + if 问题: + print(f"日志参数有问题: {问题}") + print(用法日志()) + return 1 + if 选.跟随 and (选.全部 or 选.内核 or 选.引导器 or 选.输出): + print("-f 一次只能跟一个来源 (驱动名 / --内核 / --引导器 / --输出)") + return 1 + if 选.输出: + return 打印某个日志(内核输出路径, 选, "内核输出") + if 选.内核: + return 打印某个日志(内核日志路径, 选, "内核") + if 选.引导器: + return 打印某个日志(引导器日志路径, 选, "引导器") + if 选.全部: + return 打印全部驱动日志(连接, 选) + if not 选.名: + return 打印日志台账(连接) + 驱动 = db.取驱动(连接, 选.名) + if 驱动 is None: + print(f"没这个驱动: {选.名}") + return 1 + return 打印某个日志(状态.日志路径(驱动), 选, 选.名) + + +def 打印某个日志(路径: Path, 选: 日志.选项, 称呼: str) -> int: + """打一个日志文件 (带过滤选项); 内核/引导器/驱动共用这一份实现.""" + if 选.跟随: + print(f"[跟 {称呼}] {路径} (Ctrl+C 停)", flush=True) + return 日志.跟(路径, 选.级别, 选.关键词) + 行表 = 日志.尾(路径, 选.行数, 选.级别, 选.关键词) + if not 行表: + if not 路径.exists(): + print(f"还没有 {称呼} 的日志文件: {路径}") + else: + print(f"{路径} 里没有符合条件的行 (级别 {选.级别 or '全部'} / 关键词 {选.关键词 or '无'})") + return 0 + 日志.打印(行表, 选.json输出) + return 0 + + +def 打印全部驱动日志(连接: Any, 选: 日志.选项) -> int: + """`日志 --全部`: 每个驱动一段, 带 == 名字 == 分隔 (一眼看全, 不用一个个敲).""" + 驱动表 = db.取全部驱动(连接) + if not 驱动表: + print("还没有驱动") + return 0 + 没日志 = 0 + for 驱动 in 驱动表: + 名 = str(驱动.get("name")) + 路径 = 状态.日志路径(驱动) + print(f"\n== {名} ({路径}) ==") + 行表 = 日志.尾(路径, 选.行数, 选.级别, 选.关键词) + if not 行表: + print(" (没有日志 / 没有符合条件的行)") + 没日志 += 1 + continue + 日志.打印(行表, 选.json输出) + print(f"\n共 {len(驱动表)} 个驱动, {没日志} 个没有日志") + return 0 + + +def 打印日志台账(连接: Any) -> int: + """日志台账: 先看这张表, 再决定跟谁的日志 (以前只能靠猜哪个驱动有日志).""" + 驱动表 = db.取全部驱动(连接) + if not 驱动表: + print("还没有驱动") + return 0 + 行表: list[list[str]] = [] + for 驱动 in 驱动表: + 路径 = 状态.日志路径(驱动) + 文件表 = 日志.轮转名单(路径) + 总量 = 0 + for 文件 in 文件表: + try: + 总量 += 文件.stat().st_size + except OSError: + continue + 行数 = len(日志.读全部(路径)) + 最后 = "—" + try: + 最后 = datetime.fromtimestamp(路径.stat().st_mtime).astimezone().isoformat(timespec="seconds") + except OSError: + 最后 = "—" + 历史 = f" (+{len(文件表) - 1} 份历史)" if len(文件表) > 1 else "" + 行表.append([ + str(驱动.get("name")), + "有" if 文件表 else "无", + 日志.大小文本(总量), + str(行数), + f"{最后}{历史}", + ]) + 文本.打印(文本.表格(["驱动", "日志", "占用", "行数", "最后改动"], 行表)) + print("\n提示: 日志 <驱动名> [-n 200] [-f] [--级别 WARN] [-g 关键词] [--json]") + return 0 + + +def 用法日志() -> str: + """日志命令的用法一行 (参数写错时打).""" + return ("用法: 日志 [驱动名] [-n 200] [-f] [--级别 DEBUG|INFO|WARN|ERROR] " + "[-g 关键词] [--json] [--全部] [--内核] [--引导器] [--输出]") + + +def 命令事件(连接: Any, 环境: dict[str, Any], 参数: list[str]) -> int: + """事件 [-n 50]: 全局事件流 (events 表 = 总线; 内核写, 驱动也写).""" + 条数 = 50 + if "-n" in 参数: + 位置 = 参数.index("-n") + if 位置 + 1 < len(参数): + try: + 条数 = max(int(参数[位置 + 1]), 1) + except ValueError: + print(f"-n 后面要跟一个数字, 收到 {参数[位置 + 1]!r}") + return 1 + _ = 环境 + 事件表 = db.读事件(连接, 条数) + 行表: list[list[str]] = [] + for 行 in 事件表: + 时刻 = 行.get("ts") + 时刻文本 = 时刻.strftime("%m-%d %H:%M:%S") if isinstance(时刻, datetime) else str(时刻 or "") + 行表.append([ + 时刻文本, + str(行.get("level") or "info"), + str(行.get("kind") or ""), + str(行.get("source") or ""), + str(行.get("driver") or "—"), + str(行.get("message") or ""), + ]) + if not 行表: + print("还没有事件") + return 0 + 文本.打印(文本.表格(["时间", "级别", "类型", "来源", "驱动", "内容"], 行表)) + print(f"\n共 {len(行表)} 条 (最近的在上面)") + return 0 + + +def 命令清单(连接: Any, 环境: dict[str, Any]) -> int: + """清单: 输出 JSON (重定向就是文件; 机器读的, 不混人读的表格).""" + 状态表 = {str(行.get("name")): 行 for 行 in db.取全部状态(连接)} + 驱动表 = db.取全部驱动(连接) + 出 = { + "efi": 1, + "kernel": 版本, + "ts": 状态.现在文本(), + "driver_root": str(驱动根(环境)), + "驱动": [ + { + "注册表": 驱动, + "状态": 状态.组装快照(驱动, 状态表.get(str(驱动.get("name"))), 版本), + } + for 驱动 in 驱动表 + ], + } + print(json.dumps(出, ensure_ascii=False, indent=2, default=str)) + return 0 + + +# ─────────────────────────────── 常驻调度 (甲) ─────────────────────────────── + + +def 执行命令(连接: Any, 环境: dict[str, Any], 命令: str, 参数: list[str]) -> tuple[int, str]: + """按命令名执行一条命令, 返回 (退出码, 一句话结果). + + CLI 和常驻内核**共用这一份实现** (只此一份, 不复制): CLI 是短命的执行者, + 常驻内核是排队命令的执行者, 干的是同一件事. + """ + if 命令 in ("列表", "") and not 参数: + return 命令列表(连接, 环境), "已输出驱动清单" + if 命令 == "扫描": + return 命令扫描(连接, 环境), "已扫描" + if 命令 == "启动" and 参数: + return 命令启动(连接, 环境, 参数[0]), f"启动 {参数[0]}" + if 命令 == "停止" and 参数: + return 命令停止(连接, 环境, 参数[0]), f"停止 {参数[0]}" + if 命令 == "重启" and 参数: + return 命令重启(连接, 环境, 参数[0]), f"重启 {参数[0]}" + if 命令 == "状态": + return 命令状态(连接, 环境, 参数), "已输出状态" + if 命令 == "日志": + return 命令日志(连接, 环境, 参数), f"日志 {' '.join(参数)}".strip() + if 命令 == "事件": + return 命令事件(连接, 环境, 参数), "已输出事件" + if 命令 == "清单": + return 命令清单(连接, 环境), "已输出清单" + return 2, f"不认识这个命令: {命令} {参数}" + + +def 命令调度(连接: Any, 环境: dict[str, Any]) -> int: + """常驻调度内核 (甲): 命令消费 + 驱动调用仲裁 + 依赖巡检 + 心跳. + + 为什么必须常驻 (设计 02 第 4 节): 总调度要在**运行时**看着依赖链 -- 契约满足要唤醒下游, + 上游崩要级联,驱动请求要转发仲裁, 只在启动那一下算一遍不够. + + 退出时**不顺手停驱动**: 驱动进程是独立会话, 内核死了它们照跑; 下次启动时收尸逻辑会把它们 + 认领回来 (设计 03 说的"内核可以随时死"就是这个意思).要停驱动请显式敲 停止. + """ + 停止标志 = {"停": False} + + def 收工(号: int, 帧: object) -> None: + """收到 TERM/INT: 只立个旗子, 让主循环自己收尾 (信号处理函数里别干重活).""" + 停止标志["停"] = True + 说("INFO", f"收到信号 {号}, 准备退出 (驱动不动)") + + # 独一份: 抢 PG 会话级咨询锁, 抢不到说明已经有一个内核在跑 (两个调度器=打架) + if not db.试锁(连接, 调度锁键): + 说("ERROR", "已经有一个内核在跑了 (抢不到调度锁) -- 同一份 PG 内存只能有一个调度器, 别起第二个") + return 1 + signal.signal(signal.SIGTERM, 收工) + signal.signal(signal.SIGINT, 收工) + + 轮转日志(内核日志路径, 环境, "内核") # 常驻内核前先轮转, 别让它无限长 (改 fd 之前做) + 说("INFO", f"{版本} 常驻调度启动 pid={os.getpid()}") + 收了 = db.收尸命令(连接, "内核上次没跑完 (断电 / 被杀)") + if 收了: + 说("WARN", f"收尸: 补了 {收了} 条没写完的命令") + 结果 = 扫描.扫描(连接, 驱动根(环境), 版本) + 打印列表(结果, 环境) + db.写事件(连接, "内核", "start", f"{版本} 常驻调度启动 pid={os.getpid()}") + for 名, 理由 in 结果.该拉起: + 说("INFO", f"autostart: {名} -- {理由}") + 拉起一个(连接, 环境, db.取驱动(连接, 名) or {}, db.取状态(连接, 名), 理由) + db.监听(连接, 通知通道) + + 上次心跳 = 0.0 + 上次巡检 = 0.0 + 上次扫描 = time.monotonic() + while not 停止标志["停"]: + try: + 连接.poll() # psycopg2 自带: 把网上的 NOTIFY 收进内存 (不阻塞) + 通知表 = db.收通知(连接) + for 通道, 载荷 in 通知表: + if 通道 == 通知通道: + 说("INFO", f"收到命令唤醒 (id={载荷})") + + # 1) 领命令执行 (CLI 直接执行过的不在这里: 它们的 state 不是 pending) + 命令行 = db.领命令(连接) + while 命令行 is not None: + 名 = str(命令行.get("cmd") or "") + 参数原始 = 命令行.get("args") + 参数: list[str] = [] + if isinstance(参数原始, dict): + 值 = cast(dict[str, Any], 参数原始).get("参数") + if isinstance(值, list): + 参数 = [str(项) for 项 in cast(list[Any], 值)] + 说("INFO", f"执行排队命令 #{命令行.get('id')}: {名} {参数}") + 码, 说明 = 执行命令(连接, 环境, 名, 参数) + db.记命令结果(连接, int(命令行.get("id") or 0), "done" if 码 == 0 else "failed", + {"码": 码, "说明": 说明}) + 命令行 = db.领命令(连接) + + # 2) 领调用请求: 仲裁 + 转发 (驱动只写表, 不跟内核握手) + 契约表 = 扫描.取契约(db.取全部驱动(连接)) + for 调用 in db.领调用(连接): + 转发调用(连接, 环境, 契约表, 调用) + + # 3) 巡检 (每 巡检秒): 收尸 / 重拉 / 级联 / 超时收权 + 现在 = time.monotonic() + if 现在 - 上次巡检 >= 巡检秒: + 巡检(连接, 环境) + 上次巡检 = 现在 + + # 4) 心跳 (每 心跳秒): 断电时间线用 (库里最后一条 heartbeat 就是死的那一刻) + if 现在 - 上次心跳 >= 心跳秒: + db.写事件(连接, "内核", "heartbeat", f"{版本} 常驻中 (pid {os.getpid()})", + data={"驱动数": len(db.取全部驱动(连接))}) + 上次心跳 = 现在 + + # 5) 定期重扫 (每 10 分钟): 磁盘上新加了驱动/改了配置, 常驻内核也得看见 + if 现在 - 上次扫描 >= 600: + 扫描.扫描(连接, 驱动根(环境), 版本) + 上次扫描 = 现在 + + time.sleep(0.5) + except SystemExit: + raise + except Exception as 错: # 单轮出错不能让常驻内核死: 记下来, 重连, 继续 + 说("WARN", f"这一轮出错 (会继续跑): {错}") + time.sleep(2) + 连接 = 重连(环境, 连接) + + 说("INFO", "常驻调度退出 (驱动没动, 下次启动会认领它们)") + try: + db.写事件(连接, "内核", "exit", f"{版本} 常驻调度退出 (收到停止信号)") + except Exception as 错: + 说("WARN", f"退出事件没写进去 (PG 可能已经断了): {错}") + return 0 + + +# ─────────────────────────────── 入口 ─────────────────────────────── + + +def 用法() -> str: + """命令一览 (没参数/敲错的时候打给老板看).""" + return "\n".join([ + "用法: python3 内核/内核.py <命令>", + "", + " (无参数) | 调度 常驻调度内核 (甲): 命令 + 调用仲裁 + 依赖巡检 (引导器用它守护)", + " 列表 驱动清单 (默认动作)", + " 扫描 只扫不启: 刷新注册表 + 快照 + 收尸", + " 启动 <驱动名> 拉起一个驱动 (幂等: 在跑就直说)", + " 停止 <驱动名> 停一个驱动 (先校验 cmdline 再 SIGTERM 进程组; 级联停下游)", + " 重启 <驱动名> 停干净了才启 (一次只动一件事)", + " 状态 [驱动名] [--json] 进程状态 (直读 /proc, 不信库里的旧 pid)", + " 日志 [驱动名] [选项] 日志 (不写名字 = 台账; --全部 / --内核 / --引导器)", + " -n 200 尾部行数", + " -f 实时跟 (先吐 10 行, Ctrl+C 停)", + " --级别 WARN 只要这个级别及以上 (驱动日志按字样猜级别)", + " -g 关键词 只要含它的行", + " --json 输出 JSON Lines (机器读; 结构化日志拆成字段)", + " 事件 [-n 50] 全局事件流 (events 表 = 总线)", + " 清单 输出清单 JSON (重定向就是文件)", + "", + " 判活一律回 /proc 复核; 停之前先校验 cmdline (pid 会被系统复用, 宁可不杀不可误杀)", + " 日志分三条道: 内核.log(结构化) / 内核.out.log(命令输出+崩溃原文) / 驱动/<名>/logs(驱动原始输出)", + ]) + + +def main(argv: list[str]) -> int: + """CLI 入口: 短命进程执行一条命令; 无参数 = 常驻调度. + + 每条命令都往 commands 表落一行 (审计): CLI 自己执行时用 state='running' 落行, + 这样常驻内核**不会**把 CLI 已经干过的活再领一遍 (它只领 pending). + """ + 环境 = 读环境() + 用环境(环境) # 装日志开关 (门槛/控制台); 读环境自己报错时用的是默认门槛 INFO + 连接 = 连库(环境) + try: + if not argv or argv[0] in ("调度", "常驻"): + return 命令调度(连接, 环境) + + 命令, *参数 = argv + # 每条命令都落一行审计 (state=running: 我自己在执行, 别让常驻内核再领一遍) + 命令id = db.记命令(连接, "cli", 命令, {"参数": 参数}, state="running") + 码, 说明 = 执行命令(连接, 环境, 命令, 参数) + if 命令id: + db.记命令结果(连接, 命令id, "done" if 码 == 0 else "failed", {"码": 码, "说明": 说明}) + if 码 == 2: + print(用法()) + print(f"\n[错误] {说明}") + return 码 + finally: + 连接.close() + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/内核/内核设计.md b/内核/内核设计.md new file mode 100644 index 0000000..0fec935 --- /dev/null +++ b/内核/内核设计.md @@ -0,0 +1,4 @@ +遍历文件夹目录下驱动efi文件 +遍历驱动配置文件 +生成驱动json +管理驱动进程用linux命令 \ No newline at end of file diff --git a/内核/扫描.py b/内核/扫描.py new file mode 100644 index 0000000..466699c --- /dev/null +++ b/内核/扫描.py @@ -0,0 +1,511 @@ +"""扫描驱动: 扫文件夹 -> 读配置 -> 9 条校验 -> 契约匹配/拓扑排序 -> 写注册表 + 落快照. + +[这一步在四步流程里的位置 (设计 02 第 2 节)] + ① 遍历驱动目录, 认 配置.efi.json 为驱动标志 -> 扫目录() + ② 遍历驱动配置文件 + 9 条校验 -> 校验单个() + 定契约() + ③ 生成驱动 json -> 写注册表(PG) + 写快照(文件夹) + ④ 管理驱动进程 -> 内核.py 干 (本文件不碰进程) + +[隔离失败 (最容易做错的地方)] + 一个驱动的配置错, **只把它标 invalid + 写明第几条**, 别的驱动照常工作. + 所以这里全程都是"记下来继续走", 不在第一个错误上抛异常. + +[契约匹配为什么必须有内核] + 驱动之间零耦合: 不 import 对方,配置里也不写对方的名字, 只说"我产出什么(provides)/我要什么(needs)". + 那么"谁提供 -> 谁消费"就只能由唯一知道全局的一方来配 -- 就是这里. + 匹配不上 / 成环的, 标 invalid 并写明是哪个契约 (只描述契约, 不点名驱动). + +[本版的一处自主决定 (不对你说一声)] + 校验第 6 条 (入口文件不存在 / exec 缺 x 位) 在**扫描阶段判 invalid** (静态配置问题, 不用起进程 + 就能查出来), 而不是等到启动才判 failed. 状态机里 failed 留给"启动那一刻才暴露的错" + (解释器缺,立刻非零退出). +""" + +from __future__ import annotations + +import hashlib +import json +import os +from dataclasses import dataclass, field +from pathlib import Path +from typing import Any, cast + +import db +import 状态 + +# 内核认的协议版本 (配置.efi.json 的 efi 字段); 不认识就拒载, 别猜 +支持协议: set[int] = {1} +# python 形态允许的解释器写法: venv / system / 绝对路径 (设计 01 第 2 节) +解释器写法: set[str] = {"venv", "system"} + + +# ─────────────────────────────── json 取值小助手 ─────────────────────────────── +# 为什么要有它们: json.loads 出来的是 Any, 而 isinstance(x, dict) 窄化出来的只是 +# dict[Unknown, Unknown] -- 对窄化后的对象取成员 // 取 len, 严格检查会判"类型部分未知". +# 在这里 cast 一次, 后面到处 .get() 都干净 (引导器 UEFI.boot.py 里是同一套做法). + + +def _取对象(值: Any) -> dict[str, Any]: + """json 里取出来的"对象"统一成 dict[str, Any]; 不是 dict 给空字典 (顺手挡 None / 字符串).""" + return cast(dict[str, Any], 值) if isinstance(值, dict) else {} + + +def _取清单(值: Any) -> list[Any]: + """json 里取出来的"数组"统一成 list[Any]; 不是 list 给空表.""" + return cast(list[Any], 值) if isinstance(值, list) else [] + + +def _取字符串表(值: Any) -> list[str]: + """字符串数组: 只留非空字符串项.单个字符串也接受 (脏配置容错, 不炸).""" + if isinstance(值, str): + return [值.strip()] if 值.strip() else [] + return [项.strip() for 项 in _取清单(值) if isinstance(项, str) and 项.strip()] + + +def _取字符串典(值: Any) -> dict[str, str]: + """env 那种 dict[str, str]: 值统一 str() (json 里可能写成数字 / true, 不许崩).""" + return {str(键): str(值) for 键, 值 in _取对象(值).items()} + + +# ─────────────────────────────── 小工具 ─────────────────────────────── + + +def 摘要(路径: Path) -> str: + """文件的 sha256 (十六进制).文件不在 / 读不了返回空串. + + 用途: drivers.config_hash 和 entry_hash -- 配置/代码改没改靠它比, 不 diff 内容. + 注意: 大文件也能算, 但驱动入口一般就几 KB; 真遇到大文件这也不是瓶颈 (扫描本来就不频繁). + """ + try: + 数据 = 路径.read_bytes() + except OSError: + return "" + return hashlib.sha256(数据).hexdigest() + + +def 入口安全(驱动根: Path, 相对: str) -> tuple[Path | None, str]: + """校验 entry 相对路径, 返回 (绝对路径, 错误原因). + + 四条 (设计 01 第 5 节第 5 条, 属于安全): + 1. 非空; + 2. 不许写绝对路径 (驱动必须自包含, 不能指到文件夹外面去); + 3. 不许含 .. ; + 4. 解析 (含软链) 之后必须仍在驱动根里 -- symlink 指向外面也算越界. + """ + 文本 = 相对.strip() + if not 文本: + return None, "入口 (entry) 没填" + 原始 = Path(文本) + if 原始.is_absolute(): + return None, f"入口不许写绝对路径: {文本}" + if ".." in 原始.parts: + return None, f"入口路径越界 (含 ..): {文本}" + 目标 = (驱动根 / 原始).resolve() + 根 = 驱动根.resolve() + if 目标 != 根 and 根 not in 目标.parents: + return None, f"入口路径越界 (跑到驱动文件夹外面了): {文本}" + return 目标, "" + + +@dataclass +class 扫描结果: + """一次扫描的产出 (内核拿着它决定要不要拉起谁 / 怎么渲染列表).""" + + 清单版本: int = 0 + 总数: int = 0 + 有效: int = 0 + 无效: int = 0 + 在跑: int = 0 + # 下面六项都写全了工厂的类型参数 (field(default_factory=list[dict[str, Any]])): + # 光写 list 的话, pyright 会拿工厂的返回类型 list[Unknown] 当字段类型, 严格档判"类型部分未知". + 驱动: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]]) # 注册表行 (drivers) + 状态表: list[dict[str, Any]] = field(default_factory=list[dict[str, Any]]) # 运行时状态行 (driver_state) + 顺序: list[str] = field(default_factory=list[str]) # 拓扑启动顺序 (提供方在前) + 契约: dict[str, str] = field(default_factory=dict[str, str]) # 契约名 -> 提供方驱动名 + 该拉起: list[tuple[str, str]] = field(default_factory=list[tuple[str, str]]) # (驱动名, 理由) 收尸后建议拉起的 + 问题: list[str] = field(default_factory=list[str]) # 扫描期的警告 (给人看, 不算失败) + + +# ─────────────────────────────── ① 扫目录 ─────────────────────────────── + + +def 扫目录(驱动根: Path) -> list[Path]: + """扫驱动根目录的一级子文件夹, 返回"是驱动"的那些 (根目录里有 配置.efi.json). + + 参数: + 驱动根: 驱动根目录绝对路径 (环境.efi.json 的 driver_root). + 返回: + 驱动文件夹路径列表 (按名字排序, 顺序稳定). + 异常: + 驱动根不存在 -> FileNotFoundError. **不自动建空目录** (建了就是静默假成功: 以后 + 每次扫描都"通过", 但一个驱动都没有, 你根本不知道是路径写错了还是真没驱动). + """ + if not 驱动根.is_dir(): + raise FileNotFoundError(f"驱动目录不存在: {驱动根}") + 出: list[Path] = [] + for 项 in 驱动根.iterdir(): + if not 项.is_dir() or 项.name.startswith("."): + continue + if (项 / 状态.配置名).is_file(): + 出.append(项) + 出.sort(key=lambda 路径: 路径.name) + return 出 + + +# ─────────────────────────────── ② 校验单个驱动 ─────────────────────────────── + + +def 读配置(目录: Path) -> tuple[dict[str, Any], str]: + """读一个驱动的 配置.efi.json, 返回 (配置, 错误原因). + + 错误原因非空 = 这份配置不能用来启动 (调用方把它标 invalid, 其余驱动照常). + 顶层不是对象也当错 (配置必须是个 json 对象, 数组/字符串都不算). + """ + 路径 = 目录 / 状态.配置名 + try: + 原文 = 路径.read_text(encoding="utf-8") + except OSError as 错: + return {}, f"配置读不了: {错}" + try: + 数据: Any = json.loads(原文) + except json.JSONDecodeError as 错: + return {}, f"配置不是合法 JSON: 第 {错.lineno} 行 第 {错.colno} 列 {错.msg}" + if not isinstance(数据, dict): + return {}, "配置顶层必须是对象 ({...})" + return _取对象(数据), "" + + +def 校验单个(目录: Path) -> dict[str, Any]: + """对一个驱动文件夹做静态校验 (设计 01 第 5 节的 1-6 条), 返回注册表行. + + 参数: + 目录: 驱动文件夹绝对路径. + 返回: + drivers 表的行 (字典), 含 valid / error.校验不过也**照样返回** (带原因入库, 列表看得见). + + 逐条 (不过就停下, error 只报第一条 -- 一次修一个, 别甩一串): + 2. JSON 可解析 + 3. efi 版本被支持 + 4. runtime 属于 {python, exec} + 5. entry 路径安全 (非空 / 无 .. / 不越界) + 6. entry 存在; exec 形态还要有 x 位 + 另外落三样"以后要比对"的东西: config_hash / entry_hash / 解释器声明. + """ + 配置, 错 = 读配置(目录) + # 名字: 配置里没写就用文件夹名 (设计 01 第 3 节: name 默认 = 文件夹名) + 名 = str(配置.get("name") or 目录.name).strip() + 行: dict[str, Any] = { + "name": 名, + "dir": str(目录), + "runtime": str(配置.get("runtime") or ""), + "entry": str(配置.get("entry") or ""), + "interpreter": str(配置.get("interpreter") or "venv"), + "args": _取字符串表(配置.get("args")), + "env": _取字符串典(配置.get("env")), + "provides": _取字符串表(配置.get("provides")), + "needs": _取字符串表(配置.get("needs")), + "autostart": bool(配置.get("autostart", False)), + "restart": str(配置.get("restart") or "no"), + "mode": str(配置.get("mode") or "resident"), + "config_hash": 摘要(目录 / 状态.配置名), + "entry_hash": "", + "valid": True, + "error": "", + "note": str(配置.get("note") or ""), + "logs": str(配置.get("logs") or 状态.日志目录名), + } + + def 不过(原因: str) -> dict[str, Any]: + """标 invalid 并原样返回这一行 (就地失败, 不抛异常 -- 隔离失败用).""" + 行["valid"] = False + 行["error"] = 原因 + return 行 + + if 错: + return 不过(错) + + # 第 3 条: 协议版本 + 版本 = 配置.get("efi") + if not isinstance(版本, int) or 版本 not in 支持协议: + return 不过(f"协议版本不支持: efi={版本!r} (内核支持 {sorted(支持协议)})") + + # 第 4 条: 形态 + if 行["runtime"] not in ("python", "exec"): + return 不过(f"runtime 只能是 python 或 exec, 实际 {行['runtime']!r}") + + # 第 5 条: 入口路径安全 + 入口, 路径错 = 入口安全(目录, 行["entry"]) + if 路径错: + return 不过(路径错) + assert 入口 is not None # 路径错为空时一定拿到了入口 (上面保证) + + # 第 6 条: 入口存在 + exec 要 x 位 + if not 入口.is_file(): + return 不过(f"入口文件不存在: {行['entry']}") + if 行["runtime"] == "exec" and not os.access(入口, os.X_OK): + return 不过(f"exec 入口没有可执行位: {行['entry']} (chmod +x 一下)") + if 行["runtime"] == "python": + 声明 = 行["interpreter"] + if 声明 not in 解释器写法 and not 声明.startswith("/"): + return 不过(f"interpreter 只能是 venv / system / 绝对路径, 实际 {声明!r}") + + 行["entry_hash"] = 摘要(入口) + return 行 + + +def 查重名(记录表: list[dict[str, Any]]) -> None: + """第 7 条: 驱动名全局唯一.后到的标 invalid (先到的先生效, 不抢名字). + + 为什么后到的让位: + 谁先谁后得有个确定的规矩, 按目录名排序扫描 = 结果稳定可复现 (不随文件系统顺序抖). + """ + 见过: dict[str, str] = {} + for 记 in 记录表: + if not 记.get("valid"): + continue + 名 = str(记.get("name")) + if 名 in 见过: + 记["valid"] = False + 记["error"] = f"驱动名重复: {名} (已经被 {见过[名]} 占了)" + else: + 见过[名] = str(记.get("dir")) + + +# ─────────────────────────────── ③ 契约匹配 + 拓扑排序 ─────────────────────────────── + + +def 取契约(记录表: list[dict[str, Any]]) -> dict[str, str]: + """把"谁提供什么"收成一张表: 契约名 -> 提供方驱动名 (只收 valid 的驱动). + + 规矩: 同一个契约被两个驱动声明提供 -> 按驱动名排序取先的 (确定性, 不靠扫描顺序碰运气). + 内核常驻时会反复用它 (驱动调用要按契约寻址, 得先把这张表拿在手里). + """ + 契约: dict[str, str] = {} + for 记 in sorted((r for r in 记录表 if r.get("valid")), key=lambda r: str(r.get("name"))): + 名 = str(记.get("name")) + for 契约名 in _取字符串表(记.get("provides")): + if 契约名 not in 契约: + 契约[契约名] = 名 + return 契约 + + +def 定契约(记录表: list[dict[str, Any]]) -> tuple[dict[str, str], list[str]]: + """第 8 条: 全局匹配 provides/needs, 排启动顺序, 标掉"没人给"和"成环"的驱动. + + 参数: + 记录表: 全部驱动记录 (**会被就地改**: 不通过的标 valid=False + error). + 返回: + (契约表, 问题表).契约表: 契约名 -> 提供方驱动名 (只含合法驱动). + 问题表: 扫描期的警告原文 (如"两个驱动都提供某契约" -- 不算失败, 但要让人看见). + + 三步 (循环到不再变化, 最多 3 轮): + 1. 收 provides: 同一个契约被两个驱动提供 -> 取名字在前的, 记一条警告 (确定性, 不靠运气); + 2. 查 needs: 要的契约没人提供 -> 那个驱动 invalid ("契约无人提供"); + 3. 查环: DFS 找回溯边 -> 环上所有驱动 invalid ("依赖成环"), 环上驱动的消费者 + 下一步会因为"契约无人提供"跟着 invalid (级联是天然的, 不用特判). + """ + 问题: list[str] = [] + 契约: dict[str, str] = {} + + for _ in range(3): + 变化 = False + + # 1. 收 provides (按名字排序, 结果稳定) + 契约 = {} + for 记 in sorted((r for r in 记录表 if r.get("valid")), key=lambda r: str(r.get("name"))): + 名 = str(记.get("name")) + for 契约名 in _取字符串表(记.get("provides")): + if 契约名 in 契约 and 契约[契约名] != 名: + 问题.append(f"{契约名}: {契约[契约名]} 和 {名} 都声明提供 -- 用 {契约[契约名]} (按名字排)") + continue + 契约[契约名] = 名 + + # 2. 查 needs + for 记 in 记录表: + if not 记.get("valid"): + continue + 缺 = [需 for 需 in _取字符串表(记.get("needs")) if 需 not in 契约] + if 缺: + 记["valid"] = False + 记["error"] = "契约无人提供: " + ", ".join(缺) + 变化 = True + + # 3. 查环 (DFS, 回溯边就是环) + 合法 = [r for r in 记录表 if r.get("valid")] + 名集 = {str(r.get("name")) for r in 合法} + 图: dict[str, list[str]] = {} + for 记 in 合法: + 名 = str(记.get("name")) + 上游 = [契约[需] for 需 in _取字符串表(记.get("needs")) if 需 in 契约] + 图[名] = sorted({上 for 上 in 上游 if 上 in 名集 and 上 != 名}) + 色: dict[str, int] = {} # 0 未访问 / 1 在栈上 / 2 走完了 + 路径: list[str] = [] + + def 走(名: str) -> None: + """深度优先; 撞到"在栈上"的点就是环, 把环上每个驱动都标掉.""" + 色[名] = 1 + 路径.append(名) + for 上 in 图.get(名, []): + if 色.get(上) == 1: + 圈 = 路径[路径.index(上):] + [上] + 说明 = "依赖成环 (契约链: " + " -> ".join(圈) + ")" + for 环内 in dict.fromkeys(圈): + for 记 in 合法: + if str(记.get("name")) == 环内 and 记.get("valid"): + 记["valid"] = False + 记["error"] = 说明 + elif 色.get(上) is None: + 走(上) + 路径.pop() + 色[名] = 2 + + for 名 in sorted(图): + if 色.get(名) is None: + 走(名) + if any(not r.get("valid") for r in 合法): + 变化 = True + + if not 变化: + break + + return 契约, 问题 + + +def 排顺序(记录表: list[dict[str, Any]], 契约: dict[str, str]) -> list[str]: + """按契约依赖给合法驱动排启动顺序 (提供方一定在消费者前面). + + 参数: + 记录表: 驱动记录 (只取 valid 的). + 契约: 契约名 -> 提供方. + 返回: + 驱动名列表; 无依赖关系的按名字排序 (顺序稳定, 每次扫描一样). + """ + 合法 = [r for r in 记录表 if r.get("valid")] + 名集 = {str(r.get("name")) for r in 合法} + 上游表: dict[str, list[str]] = {} + for 记 in 合法: + 名 = str(记.get("name")) + 上游表[名] = sorted({契约[需] for 需 in _取字符串表(记.get("needs")) if 需 in 契约 and 契约[需] in 名集}) + 顺序: list[str] = [] + 已排: set[str] = set() + + def 排(名: str) -> None: + """先排上游, 再排自己 (后序遍历 = 拓扑序).""" + if 名 in 已排: + return + 已排.add(名) + for 上 in 上游表.get(名, []): + 排(上) + 顺序.append(名) + + for 名 in sorted(上游表): + 排(名) + return 顺序 + + +# ─────────────────────────────── ④ 扫描主体 ─────────────────────────────── + + +def 扫描(连接: Any, 驱动根: Path, 内核版本: str) -> 扫描结果: + """跑一次完整扫描: 读磁盘 -> 写 PG 注册表/状态 -> 落快照 -> 收尸. + + 参数: + 连接: PG 连接 (内核的内存; 连不上就别调这个函数). + 驱动根: 驱动根目录 (环境.efi.json 的 driver_root). + 内核版本: 写进快照和扫描批次 (哪一版内核干的). + 返回: + 扫描结果 (含注册表行 / 状态行 / 启动顺序 / 契约表 / 建议拉起的驱动 / 警告). + 异常: + 驱动根不存在 -> FileNotFoundError 往上抛 (内核负责报错退出, 不在这吞掉). + + 顺序为什么是这样: + 1. 先全读磁盘 (不写库): 一次扫描要么整体一致, 要么什么都没写; + 2. 再写注册表 (drivers) + 清幽灵驱动; + 3. 然后**收尸**: 拿旧的 driver_state + /proc 判现在真状态, 改的写库 + 记事件; + 4. 最后落快照 + 记扫描批次. + 收尸放在写注册表之后: 状态行外键挂在 drivers 上, 先有注册表行才有状态行. + """ + 结果 = 扫描结果() + 目录表 = 扫目录(驱动根) + + # ① + ② 读磁盘 + 静态校验 + 记录表: list[dict[str, Any]] = [校验单个(目录) for 目录 in 目录表] + 查重名(记录表) + 契约, 问题 = 定契约(记录表) + 结果.契约 = 契约 + 结果.问题 = 问题 + 结果.顺序 = 排顺序(记录表, 契约) + + # ③ 写注册表 (以磁盘为准; 顺带把磁盘上没了的清掉) + for 记 in 记录表: + db.记驱动(连接, 记) + 幽灵 = db.清不在(连接, [str(记.get("name")) for 记 in 记录表]) + for 名 in 幽灵: + db.写事件(连接, "内核", "scan", f"驱动 {名} 的文件夹没了, 从注册表清掉", driver=名, level="warn") + + # 清单版本: 先占个号, 状态行要用它记账 (哪次扫描看到的状态) + 版本 = db.记扫描批次(连接, 内核版本, len(记录表), 0, 0, 0) + + # ④ 收尸: 旧状态 + /proc -> 现在的真状态 + 有效 = 0 + 在跑 = 0 + 状态行表: list[dict[str, Any]] = [] + for 记 in 记录表: + 名 = str(记.get("name")) + db.确保状态行(连接, 名, 版本) + 旧行 = db.取状态(连接, 名) + if not 记.get("valid"): + 新状态, 原因 = 状态.无效, "配置没通过校验, 内核不认它" + 原因 = "" # 原因已经在 drivers.error 里了, 事件不重复记 + else: + 新状态, 原因 = 状态.复核(记, 旧行) + 改动: dict[str, Any] = {"state": 新状态, "list_version": 版本} + if 新状态 != str((旧行 or {}).get("state") or ""): + # 状态真的变了才动数据库 + 记事件 (每一次读都写事件会把总线刷满) + if 新状态 in (状态.已退出, 状态.崩了): + 改动["pid"] = None + 改动["pgid"] = None + 改动["stopped_at"] = 状态.现在文本() + 改动["last_error"] = 原因 + db.写事件(连接, "内核", "exit", f"{名}: {原因}", driver=名, level="warn") + db.写状态(连接, 名, 改动) + 行 = db.取状态(连接, 名) + 状态行表.append(行 or {}) + if 记.get("valid"): + 有效 += 1 + if 新状态 == 状态.运行: + 在跑 += 1 + else: + 理由 = 状态.该拉起(记, 行, 新状态) + if 理由: + 结果.该拉起.append((名, 理由)) + + # ⑤ 落快照 (每个驱动文件夹一份, 文件夹自包含) + for 记, 行 in zip(记录表, 状态行表, strict=False): + 目录 = Path(str(记.get("dir"))) + if not 目录.is_dir(): + continue # 驱动文件夹刚被删了 (扫描中途删的), 跳过就行 + 快照 = 状态.组装快照(记, 行, 内核版本) + if not 记.get("valid"): + 快照["state"] = 状态.无效 + 快照["last_error"] = str(记.get("error") or "") + try: + 状态.写快照(目录, 快照) + except OSError as 错: + 结果.问题.append(f"{记.get('name')}: 快照写不进去 ({错})") + + 结果.清单版本 = 版本 + 结果.总数 = len(记录表) + 结果.有效 = 有效 + 结果.无效 = len(记录表) - 有效 + 结果.在跑 = 在跑 + 结果.驱动 = db.取全部驱动(连接) + 结果.状态表 = db.取全部状态(连接) + db.写事件( + 连接, + "内核", + "scan", + f"扫描完成 drivers={结果.总数} valid={结果.有效} invalid={结果.无效} version={版本}", + data={"在跑": 在跑, "警告": len(结果.问题)}, + ) + return 结果 diff --git a/内核/文本.py b/内核/文本.py new file mode 100644 index 0000000..0f7981d --- /dev/null +++ b/内核/文本.py @@ -0,0 +1,182 @@ +"""文本排版: CJK 宽度感知的对齐 / 表格 / 横线 / 整块输出. + +[为什么单独一个文件] + 面向老板的界面一律中文, 而且要对齐成表.中文是**双宽**字符, 直接 len() 排出来一定是歪的: + len("状态") == 2, 但屏幕上占 4 列, 混着 ASCII 列就会越往后越乱. + 内核和引导器都要打这种表, 所以抽出来只此一份实现, 不复制 (设计 02 §7 "一套职责一个文件"). + +[依赖] + 纯 stdlib (unicodedata).引导器是零第三方依赖的, 这个文件不能破例. + +[宽度规则] + * unicodedata.combining(c) 非 0 -> 组合符 (声调符之类), 占 0 列 + * east_asian_width(c) 是 W / F -> 宽 / 全角, 占 2 列 + * 其余 (含 A 歧义 / H 半角 / Na) -> 占 1 列 + 歧义字符按 1 列算是有意的: Linux 终端默认窄渲染, 按 2 算会多留空格. + +[约定] + 表格类函数**返回 list[str] 而不是直接打印**, 这样调用方能自己拼标题 / 汇总行, + 也能在 --json 分支里复用同一份宽度计算. +""" + +from __future__ import annotations + +import sys +import unicodedata + +# 对齐参数的字面量.不用第三方枚举 (零依赖), 也不要用 True/False 这种看不懂的写法. +左: str = "左" +右: str = "右" + + +def 字符宽(字符: str) -> int: + """单个字符在终端里占几列. + + 参数: + 字符: 长度为 1 的字符串.传更长的串不会报错, 但结果没意义 (只按第一个属性判). + 返回: + 0 (组合符) / 1 (半角) / 2 (全角或宽). + + 说明: + 只看 east_asian_width 的 W / F 两类.A (Ambiguous, 如全角破折号,某些符号) + 故意按 1 列算, 因为 Linux 终端默认是窄渲染, 按 2 算会跟实际屏幕对不上. + """ + if unicodedata.combining(字符): + return 0 + if unicodedata.east_asian_width(字符) in ("W", "F"): + return 2 + return 1 + + +def 显示宽度(文本: str) -> int: + """整串在终端里占几列 (逐字符累加, 不是 len()). + + 参数: + 文本: 任意字符串, 可以混中英文. + 返回: + 占用的列数.空串返回 0. + """ + 总 = 0 + for 字符 in 文本: + 总 += 字符宽(字符) + return 总 + + +def 截断(文本: str, 宽: int) -> str: + """超宽就截断, 并在末尾补一个省略号 (省略号本身按 1 列算). + + 参数: + 文本: 原串. + 宽: 允许的最大列数. + 返回: + 截断后的串; 没超宽则原样返回. + 宽 <= 0 返回空串. + + 说明: + 截断点按"列"算而不是按"字符"算, 所以不会把一个全角字符砍成半截. + 预留 1 列给省略号: 条件是 用 + 宽2 > 宽 - 1. + """ + if 宽 <= 0: + return "" + if 显示宽度(文本) <= 宽: + return 文本 + 出: list[str] = [] + 用 = 0 + for 字符 in 文本: + 宽2 = 字符宽(字符) + if 用 + 宽2 > 宽 - 1: + break + 出.append(字符) + 用 += 宽2 + return "".join(出) + "…" + + +def 填充(文本: str, 宽: int, 对齐: str = 左) -> str: + """把一串补空格到指定列数 (先截断再补, 所以结果一定不超宽). + + 参数: + 文本: 原串. + 宽: 目标列数. + 对齐: 左 (默认, 末尾补空格) 或 右 (开头补空格, 数字列好看). + 返回: + 补齐后的串, len() 可能不等于 宽, 但 显示宽度() 一定 <= 宽. + """ + 文本 = 截断(文本, 宽) + 空 = 宽 - 显示宽度(文本) + if 空 < 0: + 空 = 0 + if 对齐 == 右: + return " " * 空 + 文本 + return 文本 + " " * 空 + + +def 横线(宽: int, 字符: str = "─") -> str: + """画一条分隔线 (表头下面那条). + + 参数: + 宽: 列数 (表格里传某一列的列宽). + 字符: 线用的字符, 默认 U+2500 制表符横线 (终端里一定等宽). + 返回: + 重复 宽 次的字符串; 宽 <= 0 返回空串. + """ + if 宽 <= 0: + return "" + return 字符 * 宽 + + +def 表格( + 表头: list[str], + 行表: list[list[str]], + 对齐表: list[str] | None = None, + 间隔: str = " ", +) -> list[str]: + """渲染一张对齐的中文表格, 返回各行文本 (不打印). + + 参数: + 表头: 列名列表, 长度即列数. + 行表: 每行是一个字符串列表; 某行短于列数时, 缺的列按空串处理 (不报错). + 对齐表: 每列的对齐方式 (左 / 右); 缺省全左. + 间隔: 列与列之间塞几个空格, 默认 2 个 (中文表看着不挤). + 返回: + 行文本列表: [表头, 横线, 数据行...].调用方自己决定要不要再加汇总行. + + 说明: + 列宽 = 该列表头与所有数据行里最宽的那个 (显示宽度), 全表一次性算好, + 所以中途插入更长的行不会把别的行挤歪. + """ + 列数 = len(表头) + 宽表: list[int] = [] + for 列 in range(列数): + 宽 = 显示宽度(表头[列]) + for 行 in 行表: + if 列 < len(行): + 宽 = max(宽, 显示宽度(行[列])) + 宽表.append(宽) + 对表 = 对齐表 if 对齐表 is not None else [左] * 列数 + 出: list[str] = [] + 出.append(间隔.join(填充(表头[列], 宽表[列]) for 列 in range(列数))) + 出.append(间隔.join(横线(宽表[列]) for 列 in range(列数))) + for 行 in 行表: + 格: list[str] = [] + for 列 in range(列数): + 值 = 行[列] if 列 < len(行) else "" + 对 = 对表[列] if 列 < len(对表) else 左 + 格.append(填充(值, 宽表[列], 对)) + 出.append(间隔.join(格)) + return 出 + + +def 打印(行: list[str]) -> None: + """整块输出一行行文本. + + 参数: + 行: 要输出的每行内容 (通常直接用 表格() 的返回值 + 自己加的汇总行). + 返回: + None. + + 说明: + 一次 write 写完再 flush, 不逐行 print: 逐行打印时终端会一行行挤出来, + 管道重定向 (老板要 `> 文件`) 时也不会被行缓冲切得七零八落. + """ + sys.stdout.write("\n".join(行) + "\n") + sys.stdout.flush() diff --git a/内核/日志.py b/内核/日志.py new file mode 100644 index 0000000..dab4ac8 --- /dev/null +++ b/内核/日志.py @@ -0,0 +1,515 @@ +#!/usr/bin/env python3 +"""日志库: 内核 / 引导器 / 驱动三条道共用的一份实现 (纯 stdlib, 谁都能 import). + +[为什么要有它 (而不是各处自己 print)] + 2026-09-16 之前是三套口径: 内核自己写了一份 说(), 引导器的 WARN 只打 stderr 不落盘 (没有历史), + 驱动日志只做重定向不管死活. 更糟的是引导器起常驻内核时把内核的 stdout 一起重定向进了 内核.log, + 于是**命令输出 (中文表格) 混进了日志文件** -- 想按级别筛一条都做不到. 这份库把 + "写 / 解析 / 过滤 / 轮转 / 实时跟" 收在一处, 三条道共用. + +[三条道 (一个文件一种内容, 不混)] + 内核/logs/内核.log 内核的结构化日志行 (只有这一种东西) + 内核/logs/内核.out.log 内核进程的 stdout/stderr 原始流 (命令输出 + 未捕获的崩溃原文) + 内核/logs/引导器.log 引导器自己的动作 (体检 / 包 / 移交 / 内核启停) + 驱动/<名>/logs/<名>.log 驱动 stdout/stderr (内核只重定向不解析; 每轮启动写一条分隔头) + +[行格式 (固定字段, 一行一条)] + 2026-09-16T15:45:41+08:00 INFO [内核] 扫描完成 驱动=3 有效=3 + ^ 时刻 (带时区) ^级别 ^来源 ^内容 + +[级别] DEBUG < INFO < WARN < ERROR. 低于门槛的直接丢掉 (门槛来自 环境.efi.json 的 log_level). +[轮转] 超过 log_max_mb 就把 <文件> 改名成 <文件>.1, 旧的往后挤, 只留 log_keep 份 -- + 常驻进程的日志不能无限长 (断电前后要能翻, 但也不能把盘写满). +[不吞错] 写日志失败返回 False 但不抛 (调用方自己决定怎么办); 解析不了的行原样保留, 不丢内容. +""" + +from __future__ import annotations + +import json +import os +import re +import sys +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Any + +级别表: tuple[str, ...] = ("DEBUG", "INFO", "WARN", "ERROR") +默认门槛: str = "INFO" +默认上限字节: int = 5 * 1024 * 1024 # 5 MB: 单份日志的上限 (轮转阈值) +默认保留份数: int = 3 # 轮转后留几份历史 (1 条当前 + 3 条历史) +跟日志间隔秒: float = 0.3 # -f 实时跟的轮询间隔 + +# 行样式: 时刻 级别 [来源] 内容 (来源用方括号裹住, 方便机器拆字段) +行样式 = re.compile( + r"^(?P<时刻>\d{4}-\d{2}-\d{2}T[0-9:.+\-]+)\s+" + r"(?P<级别>DEBUG|INFO|WARN|ERROR)\s+" + r"\[(?P<来源>[^\]]*)\]\s?" + r"(?P<消息>.*)$" +) +# 驱动日志是别人的原始 stdout, 没有固定格式 -- 级别只能靠这些字样猜 (启发式, 不是规范) +错字样 = re.compile(r"ERROR|FATAL|CRITICAL|Traceback|Exception|错误|失败|异常", re.IGNORECASE) +警字样 = re.compile(r"WARN|警告|告警|重试|超时|retry|timeout", re.IGNORECASE) + + +# ─────────────────────────────── 路径 (集中定义, 别在各处拼) ─────────────────────────────── + + +def 内核日志路径(根: Path) -> Path: + """内核的结构化日志: <根>/内核/logs/内核.log (内核自己写, 不靠 fd 重定向).""" + return 根 / "内核" / "logs" / "内核.log" + + +def 内核输出路径(根: Path) -> Path: + """内核进程的 stdout/stderr 原始流: <根>/内核/logs/内核.out.log. + + 为什么要单独一个文件: 内核执行 CLI 命令时会 print 表格到 stdout, 那段**不是日志**; + 以前它跟日志行挤在同一个文件里, 于是"日志文件里躺着状态表格". 分开之后: + 内核.log 只有日志行 (可过滤/可 JSON), 内核.out.log 只有命令输出与崩溃原文 (可原样贴给人). + """ + return 根 / "内核" / "logs" / "内核.out.log" + + +def 引导器日志路径(根: Path) -> Path: + """引导器自己的动作日志: <根>/内核/logs/引导器.log (以前引导器只打 stderr, 没有历史).""" + return 根 / "内核" / "logs" / "引导器.log" + + +def 驱动日志路径(驱动目录: Path, 名: str, 子目录: str = "logs") -> Path: + """驱动日志: <驱动目录>/<子目录>/<驱动名>.log (stdout/stderr 都进这一个).""" + return 驱动目录 / 子目录 / f"{名}.log" + + +# ─────────────────────────────── 级别与时间 ─────────────────────────────── + + +def 规范化级别(值: Any) -> str: + """把任意输入统一成合法级别 (认大小写; 认不出来给 INFO, 不抛). + + 参数: + 值: 级别字符串 (info / WARN / 空 ...). + 返回: + 级别表里的一项. + """ + 文本值 = str(值 or "").strip().upper() + return 文本值 if 文本值 in 级别表 else 默认门槛 + + +def 级别序号(级别: str) -> int: + """级别在 级别表 里的下标 (越大越严重); 认不出来按 INFO 算.""" + 级 = 规范化级别(级别) + return 级别表.index(级) + + +def 达标(级别: str, 门槛: str) -> bool: + """这一条够不够门槛 (>= 才写). 门槛空 = 不筛.""" + if not str(门槛 or "").strip(): + return True + return 级别序号(级别) >= 级别序号(门槛) + + +def 现在文本() -> str: + """当前时刻, ISO8601 带时区 (+08:00) -- 日志第一列就是这个.""" + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def 本地时刻(值: Any) -> str: + """把库里取出来的 timestamptz / 字符串统一成日志那列的样子 (给事件表混排用).""" + if isinstance(值, datetime): + return 值.astimezone().isoformat(timespec="seconds") + return str(值 or "") + + +# ─────────────────────────────── 写 ─────────────────────────────── + + +def 格式化(时刻: str, 级别: str, 来源: str, 消息: str) -> str: + """拼一行: 时刻 级别 [来源] 内容 (级别按 5 格左对齐, 列才对得齐).""" + return f"{时刻} {级别:<5} [{来源}] {消息}" + + +def 记( + 路径: Path, + 级别: str, + 来源: str, + 消息: str, + 门槛: str = 默认门槛, + 控制台: bool = False, +) -> bool: + """写一条日志: 门槛够了才写, 一行一条追加, 需要时同时打 stderr. + + 参数: + 路径: 日志文件 (父目录自动建). + 级别: DEBUG / INFO / WARN / ERROR (认不出来按 INFO). + 来源: 谁写的 (内核 / 引导器 / 驱动名 ...), 显示在方括号里. + 消息: 内容 (多行内容会原样写进去, 不截断). + 门槛: 低于它的直接丢掉 (空 = 不筛). + 控制台: True 时同时打 stderr (前台跑给人看; 守护模式关掉, 免得跟 out.log 重复). + 返回: + True = 写了; False = 被门槛挡掉 or 写失败 (写失败**不抛**, 不拦调用方干活). + """ + 级 = 规范化级别(级别) + if not 达标(级, 门槛): + return False + 行 = 格式化(现在文本(), 级, 来源, 消息) + if 控制台: + print(行, file=sys.stderr, flush=True) + return 追加(路径, 行) + + +def 追加(路径: Path, 文本: str) -> bool: + """追加一行 (不加换行就自己加). 写不了返回 False, 不抛.""" + try: + 路径.parent.mkdir(parents=True, exist_ok=True) + with 路径.open("a", encoding="utf-8") as 句柄: + 句柄.write(文本 if 文本.endswith("\n") else 文本 + "\n") + except OSError: + return False + return True + + +# ─────────────────────────────── 解析 ─────────────────────────────── + + +def 解析行(行: str) -> dict[str, str] | None: + """拆一行日志: {时刻, 级别, 来源, 消息}; 拆不了 (驱动原始输出) 给 None. + + 参数: + 行: 一行文本. + 返回: + 字段字典, 或 None (裸行 -- 不是本库写的格式). + """ + 命中 = 行样式.match(行) + if 命中 is None: + return None + return { + "时刻": 命中.group("时刻"), + "级别": 命中.group("级别"), + "来源": 命中.group("来源"), + "消息": 命中.group("消息"), + } + + +def 猜级别(行: str) -> str: + """问一行的级别: 能解析就用真级别, 否则按字样猜 (驱动日志是别人的原始输出, 只能猜). + + 参数: + 行: 一行文本. + 返回: + DEBUG / INFO / WARN / ERROR. + """ + 字段 = 解析行(行) + if 字段 is not None: + return 字段["级别"] + if 错字样.search(行): + return "ERROR" + if 警字样.search(行): + return "WARN" + return "INFO" + + +def 命中(行: str, 级别: str = "", 关键词: str = "") -> bool: + """这一行过不过滤 (级别按"至少这么严重", 关键词不区分大小写, 裸行整行匹配).""" + if 级别.strip() and 级别序号(猜级别(行)) < 级别序号(级别): + return False + if 关键词.strip() and 关键词.lower() not in 行.lower(): + return False + return True + + +# ─────────────────────────────── 读 ─────────────────────────────── + + +def 带序路径(路径: Path, 序: int) -> Path: + """轮转后的历史文件名: <名字>.N.""" + return 路径.with_name(f"{路径.name}.{序}") + + +def 轮转名单(路径: Path) -> list[Path]: + """按"从老到新"列出 路径 与它的历史文件 (不存在的不列). + + 参数: + 路径: 主日志文件. + 返回: + 文件路径列表, 老 -> 新; 全是真实存在的文件. + """ + 表: list[tuple[int, Path]] = [] + try: + 同目录 = list(路径.parent.glob(f"{路径.name}.*")) + except OSError: + 同目录 = [] + for 候选 in 同目录: + 尾段 = 候选.name.rsplit(".", 1)[-1] + if 尾段.isdigit(): + 表.append((int(尾段), 候选)) + 表.sort(key=lambda 项: 项[0], reverse=True) # 序号大 = 老 + 结果 = [项[1] for 项 in 表] + if 路径.exists(): + 结果.append(路径) + return 结果 + + +def 读全部(路径: Path, 含轮转: bool = True) -> list[str]: + """读一个日志文件 (以及它的历史轮转份) 的全部行, 老 -> 新.""" + 行表: list[str] = [] + for 文件 in (轮转名单(路径) if 含轮转 else ([路径] if 路径.exists() else [])): + try: + 行表.extend(文件.read_text(encoding="utf-8", errors="replace").splitlines()) + except OSError: + continue + return 行表 + + +def 尾(路径: Path, 行数: int = 200, 级别: str = "", 关键词: str = "", 含轮转: bool = True) -> list[str]: + """读日志末尾 N 行 (等价 tail -n), 可先按级别/关键词过滤 (过滤在取尾之前). + + 参数: + 路径: 日志文件. + 行数: 要几行 (<=0 给空表). + 级别: 只要这个级别及以上的 (空 = 全要). + 关键词: 只要含它的行, 不区分大小写 (空 = 不筛). + 含轮转: 是否把 .1/.2 那些历史份也算进来. + 返回: + 行列表 (文件不存在给空表, 不抛). + """ + if 行数 <= 0: + return [] + 全部 = [行 for 行 in 读全部(路径, 含轮转) if 行.strip()] + if 级别.strip() or 关键词.strip(): + 全部 = [行 for 行 in 全部 if 命中(行, 级别, 关键词)] + return 全部[-行数:] + + +def 跟(路径: Path, 级别: str = "", 关键词: str = "", 初始行: int = 10) -> int: + """`-f` 实时跟日志: 先吐末尾若干行, 然后一直跟着新内容打印 (Ctrl+C 停). + + 参数: + 路径: 日志文件 (可以先不存在, 等它被创建). + 级别: 只要这个级别及以上 (空 = 全要). + 关键词: 只要含它的行 (空 = 不筛). + 初始行: 开始时先吐几行. + 返回: + 0 (Ctrl-C 也算正常收工). + + 实现要点: + 轮询文件大小 + 从上次位置读新增字节, 每轮睡 0.3s (不起 `tail -f`, 少一个外部依赖). + 两点比朴素实现讲究: + ① **按行缓冲**: 只打完整行, 半个行留到下一轮 (追加写的文件随时可能被读到半行). + ② 文件被轮转/清空 (大小比位置小) -> 位置归 0 重头读, 否则会卡在旧偏移上什么都不出. + """ + for 行 in 尾(路径, 初始行, 级别, 关键词): + print(行, flush=True) + try: + 位置 = 路径.stat().st_size + except OSError: + 位置 = 0 + 缓冲 = b"" + try: + while True: + try: + 大小 = 路径.stat().st_size + except OSError: + 大小 = 位置 + if 大小 < 位置: # 被轮转或清空 + 位置 = 0 + 缓冲 = b"" + if 大小 > 位置: + try: + with 路径.open("rb") as 句柄: + 句柄.seek(位置) + 数据 = 句柄.read() + 位置 = 句柄.tell() + except OSError: + 数据 = b"" + if 数据: + 缓冲 += 数据 + 完整, _, 缓冲 = 缓冲.rpartition(b"\n") + for 原始 in 完整.split(b"\n"): + 行 = 原始.decode("utf-8", "replace") + if 命中(行, 级别, 关键词): + print(行, flush=True) + else: + time.sleep(跟日志间隔秒) + except KeyboardInterrupt: + return 0 + return 0 + + +# ─────────────────────────────── 轮转 ─────────────────────────────── + + +def 轮转(路径: Path, 上限字节: int = 默认上限字节, 保留份数: int = 默认保留份数) -> bool: + """日志超过上限就改名: <名> -> <名>.1, <名>.1 -> <名>.2 ... 最老的丢掉. + + 参数: + 路径: 日志文件. + 上限字节: 超过它就轮转 (<=0 = 不轮转). + 保留份数: 留几份历史 (<1 = 不轮转). + 返回: + True = 真轮转了; False = 没到上限 / 文件不在 / 参数关了轮转. + + 为什么在"启动前"调而不是运行中: + 运行中的进程按 fd 追加写, 中途改名会让它继续写老 inode (日志就"丢了"). + 所以内核/引导器都在**拉起进程之前**轮转一次 -- 常驻进程写满一份就走新的, 简单且不出错. + """ + if 上限字节 <= 0 or 保留份数 < 1: + return False + try: + 大小 = 路径.stat().st_size + except OSError: + return False + if 大小 <= 上限字节: + return False + 最老 = 带序路径(路径, 保留份数) + try: + if 最老.exists(): + 最老.unlink() + for 序 in range(保留份数 - 1, 0, -1): + 源 = 带序路径(路径, 序) + if 源.exists(): + os.replace(源, 带序路径(路径, 序 + 1)) + os.replace(路径, 带序路径(路径, 1)) + except OSError: + return False + return True + + +# ─────────────────────────────── 给人看的格式 ─────────────────────────────── + + +def 大小文本(字节: int) -> str: + """字节数 -> 人读的 KB/MB (表格里用).""" + 数 = float(max(字节, 0)) + for 单位 in ("B", "KB", "MB", "GB"): + if 数 < 1024 or 单位 == "GB": + return f"{数:.1f}{单位}" if 单位 != "B" else f"{int(数)}B" + 数 /= 1024 + return f"{数:.1f}GB" + + +def 转json行(行表: list[str]) -> str: + """把日志行转成 JSON Lines (机器读; 裸行给 来源="", 级别=猜出来的). + + 参数: + 行表: 原始日志行. + 返回: + 多行字符串, 一条一行 JSON (末尾有换行). + """ + 出: list[str] = [] + for 行 in 行表: + 字段 = 解析行(行) + if 字段 is None: + 记录: dict[str, Any] = {"时刻": "", "级别": 猜级别(行), "来源": "", "消息": 行} + else: + 记录 = dict(字段) + 出.append(json.dumps(记录, ensure_ascii=False)) + return "".join(条 + "\n" for 条 in 出) + + +# ─────────────────────────────── 命令参数 (内核与引导器共用一份解析) ─────────────────────────────── + + +@dataclass +class 选项: + """`日志` 命令解析出来的选项 (内核看驱动日志,引导器看内核/自己日志, 认同一套开关). + + 属性: + 名: 要看谁的日志 (驱动名 / 空). + 行数: 尾部多少行 (-n). + 跟随: -f 实时跟. + 级别: 只看这个级别及以上 (--级别). + 关键词: 只看含它的行 (-g / --grep). + json输出: 输出 JSON Lines 而不是原样文本 (--json). + 全部: 所有对象汇总 (--全部). + """ + + 名: str = "" + 行数: int = 200 + 跟随: bool = False + 级别: str = "" + 关键词: str = "" + json输出: bool = False + 全部: bool = False + 内核: bool = False + 引导器: bool = False + 输出: bool = False + + +def 解析选项(参数: list[str], 默认行数: int = 200) -> tuple[选项, str]: + """解析 `日志 ...` 的参数. + + 参数: + 参数: 命令行里 日志 之后的全部词. + 默认行数: 没写 -n 时给几行. + 返回: + (选项, 错误说明); 错误说明非空 = 参数写错了, 调用方打出来退出 1. + """ + 选 = 选项(行数=max(默认行数, 1)) + 位 = 0 + while 位 < len(参数): + 条 = 参数[位] + if 条 in ("-n", "--行数"): + if 位 + 1 >= len(参数): + return 选, "-n 后面要跟一个数字" + try: + 选.行数 = max(int(参数[位 + 1]), 1) + except ValueError: + return 选, f"-n 后面要跟一个数字, 收到 {参数[位 + 1]!r}" + 位 += 2 + continue + if 条 in ("--级别", "--level", "-l"): + if 位 + 1 >= len(参数): + return 选, "--级别 后面要跟一个级别" + 候选 = str(参数[位 + 1]).strip().upper() + if 候选 not in 级别表: + return 选, f"--级别 只认 {'/'.join(级别表)}, 收到 {参数[位 + 1]!r}" + 选.级别 = 候选 + 位 += 2 + continue + if 条 in ("-g", "--grep", "--关键词"): + if 位 + 1 >= len(参数): + return 选, "-g 后面要跟一个关键词" + 选.关键词 = str(参数[位 + 1]) + 位 += 2 + continue + if 条 in ("-f", "--follow"): + 选.跟随 = True + elif 条 == "--json": + 选.json输出 = True + elif 条 == "--全部": + 选.全部 = True + elif 条 == "--内核": + 选.内核 = True + elif 条 == "--引导器": + 选.引导器 = True + elif 条 == "--输出": + 选.输出 = True + elif 条.startswith("-"): + return 选, f"不认识的参数: {条}" + elif not 选.名: + 选.名 = 条 + 位 += 1 + return 选, "" + + +def 打印(行表: list[str], json输出: bool = False) -> None: + """把日志行打出来: --json 走 JSON Lines, 否则原样 (一行一条, 不加工不截断).""" + if json输出: + sys.stdout.write(转json行(行表)) + sys.stdout.flush() + return + for 行 in 行表: + print(行) + + +# ─────────────────────────────── 环境开关 ─────────────────────────────── + + +def 控制台开() -> bool: + """要不要同时打 stderr: 读环境变量 EFI_LOG_CONSOLE (引导器起守护时置 0). + + 为什么要这个开关: 守护模式下的内核 stdout/stderr 会被重定向进 内核.out.log, + 日志行再从 stderr 走一遍就会在 out.log 里出现副本 -- 守卫模式关掉, 前台跑 (人看着) 打开. + """ + return str(os.environ.get("EFI_LOG_CONSOLE", "1")).strip().lower() not in ("0", "no", "false", "off") diff --git a/内核/状态.py b/内核/状态.py new file mode 100644 index 0000000..24716d2 --- /dev/null +++ b/内核/状态.py @@ -0,0 +1,296 @@ +"""驱动状态: 落盘快照 (运行.efi.json) + 状态机 + 断电收尸判定. + +[两个真相, 谁说了算 (最容易搞混的地方)] + 活的真相 = PG 的 driver_state 表 (内核的内存; 判活 / 调度 / 级联都看它). + 落盘快照 = 驱动文件夹里的 运行.efi.json (**不是状态源**). 它的用处只有三个: + 1. 文件夹自包含 -- 拷走一个驱动目录, 状态跟着走; + 2. PG 不在 / 断电之后, 离线也能看出它上次什么状态; + 3. 给人看 (cat 一下就知道上次谁在跑). + **判活一律回 /proc 复核**, 绝不拿快照当依据 (快照可能是几小时前写的). + +[为什么快照要原子写] + 直接 open(w) 写一半断电 = 留个半截 json, 下次读出来就是"损坏". + 所以先写 运行.efi.json.tmp, 再 os.replace 换名 -- 同目录 rename 是原子的, + 要么是旧内容, 要么是完整新内容, 不会出现半截 (踩过: 快照损坏被当成"没状态", 白丢现场). + +[谁写快照] + 内核 (扫描 / 启动 / 停止 / 收尸之后各刷一次). 驱动作者手改它没有意义, 下次刷新就覆盖. + +[状态机 (设计 01 第 4 节)] + ┌────────► stopped ◄────────┐ + │ │ │ + pid 不在 │ start│ │stop 成功 + │ ▼ │ + crashed ◄─┘ starting ──► running ─┤ + exited ◄─ pid 被复用 │ │ │ + 失败│ 崩了 │ │ + ▼ ▼ │ + failed crashed ─┘ +""" + +from __future__ import annotations + +import json +import os +from datetime import datetime +from pathlib import Path +from typing import Any, cast + +import 进程 + +# ─────────────────────────────── 常量 ─────────────────────────────── + +配置名 = "配置.efi.json" # 驱动作者写 (内核只读, 用来算 config_hash) +快照名 = "运行.efi.json" # 内核写 (给人看 / 离线读) +日志目录名 = "logs" # 驱动 stdout/stderr 落这儿 (内核重定向, 不解析内容) +快照版本 = 1 # 快照格式版本 (efi 字段) + +# 状态取值: 存 PG 用英文 (值稳定, 不受界面措辞影响), 给老板看的时候过 显示状态() 转中文 +停止 = "stopped" # 没在跑, 正常态 +启动中 = "starting" # 已 spawn, 还没确认存活 +运行 = "running" # /proc/ 在, 且 cmdline 校验通过 +已退出 = "exited" # 进程没了 (正常/异常退出), 退出码在 exit_code +崩了 = "crashed" # 断电 / 内核重启后的判定 (旧状态说在跑, /proc 里没有) +失败 = "failed" # 拉起就失败 (入口不存在 / 解释器缺 / 立刻非零退出) +无效 = "invalid" # 配置不合法, 内核拒载 (原因在 last_error), 不影响别的驱动 + +# 英文状态 -> 中文显示 (界面必须中文; 存库保持英文) +中文表: dict[str, str] = { + 停止: "停止", + 启动中: "启动中", + 运行: "运行", + 已退出: "已退出", + 崩了: "崩了", + 失败: "失败", + 无效: "无效", +} + + +def 显示状态(值: Any) -> str: + """把库里的英文状态转成中文给人看; 不认识的取值原样返回 (别吞掉陌生状态).""" + 文本 = str(值 or 停止) + return 中文表.get(文本, 文本) + + +def 现在文本() -> str: + """当前时间, ISO8601 带时区偏移 (快照的 updated_at 用; 老板要看得出是哪一刻写的).""" + return datetime.now().astimezone().isoformat(timespec="seconds") + + +def _时刻文本(值: Any) -> str | None: + """把 PG 里取出来的 timestamptz 转成 ISO 字符串; None / 空 返回 None. + + 为什么要在这一层转: + json 里存不了 datetime 对象; 快照是给人看和离线读的, ISO 文本最省事. + """ + if 值 is None: + return None + if isinstance(值, datetime): + return 值.astimezone().isoformat(timespec="seconds") + return str(值) + + +# ─────────────────────────────── 路径 ─────────────────────────────── + + +def 驱动根(驱动: dict[str, Any]) -> Path: + """驱动文件夹绝对路径 (drivers.dir).""" + return Path(str(驱动.get("dir") or "")) + + +def 入口路径(驱动: dict[str, Any]) -> Path: + """入口文件绝对路径 = 驱动根 / entry. + + 用途: 传给 进程.判活 / 进程.停止 做 cmdline 校验 -- 这是"防 pid 复用误杀"的关键, + 所以必须是**和启动时同一个**路径 (启动用 驱动根/entry, 这里也是). + """ + return 驱动根(驱动) / str(驱动.get("entry") or "") + + +def 日志目录(驱动: dict[str, Any]) -> Path: + """驱动日志目录 (配置里的 logs 字段, 默认 logs/).""" + 名 = str(驱动.get("logs") or 日志目录名) + return 驱动根(驱动) / 名 + + +def 日志路径(驱动: dict[str, Any]) -> Path: + """驱动日志文件: <驱动根>/logs/<驱动名>.log (stdout/stderr 都进这一个).""" + return 日志目录(驱动) / f"{驱动.get('name')}.log" + + +# ─────────────────────────────── 快照读写 ─────────────────────────────── + + +def 读快照(目录: Path) -> dict[str, Any] | None: + """读一份 运行.efi.json. + + 参数: + 目录: 驱动文件夹绝对路径. + 返回: + 快照字典; 文件不在 / 不是合法 JSON / 顶层不是对象 一律返回 None. + + 为什么把"损坏"也归成 None: + 调用方要的行为是同一个: 当成"没有快照", 用 PG 重建一份. + 损坏不是致命错误 (PG 才是真相), 所以这里不抛异常,也不在外面分两种情况处理. + """ + try: + 原文 = (目录 / 快照名).read_text(encoding="utf-8") + except OSError: + return None + try: + 数据: Any = json.loads(原文) + except json.JSONDecodeError: + return None + # isinstance 窄化出来的只是 dict[Unknown, Unknown]; cast 一次, 免得检查器判"类型部分未知" + return cast(dict[str, Any], 数据) if isinstance(数据, dict) else None + + +def 写快照(目录: Path, 数据: dict[str, Any]) -> None: + """整份重写 运行.efi.json (原子替换). + + 参数: + 目录: 驱动文件夹绝对路径. + 数据: 组装快照() 出来的完整字典. + 返回: + 无. + + 步骤: + 1. 写 <快照名>.tmp (同目录, 保证 os.replace 是同一个文件系统内的 rename); + 2. os.replace 换名覆盖正式文件. + 其它: + 目录不存在 (驱动被删了) 时抛 OSError -- 该报错就报错, 不静默吞. + """ + 临时 = 目录 / (快照名 + ".tmp") + 文本 = json.dumps(数据, ensure_ascii=False, indent=2) + "\n" + 临时.write_text(文本, encoding="utf-8") + os.replace(临时, 目录 / 快照名) + + +def 组装快照(驱动: dict[str, Any], 状态行: dict[str, Any] | None, 内核版本: str) -> dict[str, Any]: + """按"注册表 + 运行时状态"拼一份完整的快照 (键表见设计 01 第 4 节). + + 参数: + 驱动: drivers 表的一行 (dir/entry/config_hash/entry_hash 从这里来). + 状态行: driver_state 表的一行; None = 还没有状态记录 (当"停止"处理). + 内核版本: 写进 kernel 字段 (哪一版内核写的, 排查时有用). + 返回: + 可以直接 json.dumps 的字典. + + 为什么同时写 config_hash 和 boot_hash: + config_hash = **当前**配置的指纹 (从注册表来), boot_hash = **起进程那一刻**的指纹. + 两个一比就知道"配置改过了但还没重启" (设计里那句"配置已改, 待重启"), + 而且快照自包含 -- 离线也能自己判, 不用回 PG 查. + """ + 行: dict[str, Any] = 状态行 or {} + pid值 = 行.get("pid") + return { + "efi": 快照版本, + "name": str(驱动.get("name") or ""), + "state": str(行.get("state") or 停止), + "pid": int(pid值) if isinstance(pid值, int) else None, + "pgid": 行.get("pgid"), + "started_at": _时刻文本(行.get("started_at")), + "stopped_at": _时刻文本(行.get("stopped_at")), + "exit_code": 行.get("exit_code"), + "restarts": int(行.get("restarts") or 0), + "config_hash": 驱动.get("config_hash"), + "boot_hash": 行.get("boot_hash"), + "entry_hash": 驱动.get("entry_hash"), + "list_version": 行.get("list_version"), + "kernel": 内核版本, + "last_error": 行.get("last_error"), + "updated_at": 现在文本(), + } + + +def 待重启(驱动: dict[str, Any], 状态行: dict[str, Any] | None) -> bool: + """配置改过了但进程还在用旧配置跑 -> True (列表里显示"待重启"). + + 判据: 进程确实在跑 (有 boot_hash), 且 boot_hash != 当前 config_hash. + boot_hash 为空 = 从没起过 (或没记录), 不算"待重启" (那是"没跑过"). + """ + 行: dict[str, Any] = 状态行 or {} + if str(行.get("state") or 停止) != 运行: + return False + 起时 = 行.get("boot_hash") + 当前 = 驱动.get("config_hash") + if not 起时 or not 当前: + return False + return str(起时) != str(当前) + + +# ─────────────────────────────── 收尸判定 ─────────────────────────────── + + +def 复核(驱动: dict[str, Any], 状态行: dict[str, Any] | None) -> tuple[str, str]: + """拿 PG 里的旧状态 + /proc 的实际情况, 判出这个驱动**现在**到底什么状态. + + 什么时候跑: 内核每次 扫描 / boot 都先跑一遍 (本机 22:30 断电, 这是必做项). + 参数: + 驱动: drivers 表一行 (要它的 dir/entry 去 /proc 里核对). + 状态行: driver_state 表一行; None = 没记录. + 返回: + (状态, 原因). 原因只在"需要解释"时有内容 (要落 last_error / 写事件), 比如 + "上次在跑, 现在 /proc 里没了 (断电 / 被杀)". + + 判定表 (设计 02 第 5 节): + 旧状态是"在跑"(running/starting): + 进程在且 cmdline 匹配 -> 运行 (认领, 不重起) + 进程不在 -> 崩了 (断电 / 被杀) + 进程在但 cmdline 不对 -> 已退出 (pid 被复用: 清掉, **不杀**) + 僵尸 -> 崩了 (已经死了, 只是没被 wait 回收) + 旧状态是"没在跑"(停止/已退出/崩了/失败/无效): + 进程在且匹配 -> 运行 (认领回来: 有人绕过内核对它做了什么) + 否则 -> 维持旧状态 (不动) + """ + 旧 = str((状态行 or {}).get("state") or 停止) + pid值 = (状态行 or {}).get("pid") + pid = int(pid值) if isinstance(pid值, int) else None + 入口 = 入口路径(驱动) + 活 = 进程.判活(pid, 入口) + + if 旧 not in (运行, 启动中): + if 活 == 进程.运行中: + return 运行, "状态里没写它在跑, 但 /proc 里确实在 -- 认领回来" + return 旧, "" + + if 活 == 进程.运行中: + return 运行, "" + if 活 == 进程.僵尸: + return 崩了, f"进程 {pid} 已成僵尸 (已死, 父进程没回收)" + if 活 == 进程.被复用: + return 已退出, f"pid {pid} 已被系统分给别人 (不认领, 也不杀)" + if str(驱动.get("mode") or "resident") == "oneshot": + return 已退出, "oneshot 跑完就退 (正常, 不用重起)" + return 崩了, f"状态里写着在跑, 但 /proc/{pid} 没了 (断电 / 被杀)" + + +def 该拉起(驱动: dict[str, Any], 状态行: dict[str, Any] | None, 判定: str) -> str: + """收尸之后判断"该不该顺手把它拉起来", 返回理由 ("" = 不拉). + + 参数: + 驱动: drivers 表一行 (autostart / restart 两个策略字段). + 状态行: driver_state 表一行 (要 exit_code 来区分"断电"和"自己崩"). + 判定: 复核() 给出的新状态. + 返回: + 该拉 -> 一句人读的理由 (写进 events); 不该拉 -> 空串. + + 规则 (设计 02 第 5 节 + 第 2 节): + * 断电 / 被杀留下的 crashed: exit_code 是空的 -> 看 autostart (开机自启的才拉); + * 进程自己崩的 (exit_code 有值): 看 restart == "on-failure"; + * 其它状态 (停止 / 已退出 / 无效 / 失败): 一律不自动拉. + "失败"不自动重试是故意的 -- 配置错了就别反复撞墙 (省得把日志刷满). + """ + 行: dict[str, Any] = 状态行 or {} + if 判定 not in (崩了, 失败): + return "" + if 判定 == 失败: + return "" + 退出码 = 行.get("exit_code") + if 退出码 is None: + if bool(驱动.get("autostart", False)): + return "断电/被杀后留下的进程, 而且 autostart=true -- 拉起来" + return "" + if str(驱动.get("restart") or "no") == "on-failure": + return f"上次非正常退出 (退出码 {退出码}), restart=on-failure -- 拉起来" + return "" diff --git a/内核/自测AST等价.py b/内核/自测AST等价.py new file mode 100644 index 0000000..35ce02c --- /dev/null +++ b/内核/自测AST等价.py @@ -0,0 +1,76 @@ +"""AST 等价对比: 证明"只改了注释/docstring", 逻辑一行没动. + +[什么时候用] + 大改注释,重排文档串,批量扫标点之后, 拿改前改后的两份文件比一次 -- + 比"我觉得没改逻辑"可靠得多 (人工重抄 1000 行, 抄错一行很难自己发现). + +用法: + python3 内核/自测AST等价.py 改前.py 改后.py + +判据: + 去掉 docstring (按 AST 摘) 之后, ast.unparse() 的文本逐字符相同 => 逻辑零改动. + 输出里只剩"字符串常量里的标点差异"也算通过 (那是注释/文案的事, 不是逻辑). + +退出码: 0 等价 / 1 有逻辑差异 / 2 函数与类定义序列都变了 +""" + +from __future__ import annotations + +import ast +import difflib +import sys + + +def 去docstring(树: ast.AST) -> ast.AST: + """摘掉模块 / 函数 / 类体开头的字符串字面量 docstring; 摘空了补一个 pass 占位.""" + for 节点 in ast.walk(树): + if isinstance(节点, (ast.Module, ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)): + 体 = 节点.body + if ( + 体 + and isinstance(体[0], ast.Expr) + and isinstance(体[0].value, ast.Constant) + and isinstance(体[0].value.value, str) + ): + 体.pop(0) + if not 体: + 体.append(ast.Pass()) + return 树 + + +def 载入(路径: str) -> tuple[str, list[str]]: + """读一个 .py, 返回 (去 docstring 后的规范化源码, 函数与类名序列).""" + with open(路径, encoding="utf-8") as 句柄: + 文本 = 句柄.read() + 树 = ast.parse(文本, filename=路径) + 名称 = [ + 节点.name + for 节点 in ast.walk(树) + if isinstance(节点, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)) + ] + return ast.unparse(去docstring(树)), 名称 + + +def main(参数: list[str]) -> int: + if len(参数) != 2: + print(__doc__) + return 2 + 旧文本, 旧名 = 载入(参数[0]) + 新文本, 新名 = 载入(参数[1]) + print(f"函数/类定义数量: 旧 {len(旧名)} -> 新 {len(新名)}") + if 旧名 != 新名: + print("[差异] 定义序列变了:") + print(" 只在新文件:", [x for x in 新名 if x not in 旧名] or "无") + print(" 只在旧文件:", [x for x in 旧名 if x not in 新名] or "无") + return 2 + if 旧文本 == 新文本: + print("[OK] AST 完全等价 -- 除了注释和 docstring, 逻辑一行没动") + return 0 + print("[差异] 去注释后 AST 不同, 逐行看下面的 diff:") + 差异 = list(difflib.unified_diff(旧文本.splitlines(), 新文本.splitlines(), "改前", "改后", lineterm="", n=2)) + print("\n".join(差异[:200])) + return 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/内核/自测db.py b/内核/自测db.py new file mode 100644 index 0000000..1ffe530 --- /dev/null +++ b/内核/自测db.py @@ -0,0 +1,754 @@ +"""数据库层真机自测: 43 个函数 / 8 张表, 全打真 PG, 不 mock. + +用法: .venv/bin/python 内核/自测db.py +跑完 0 退出; 有 FAIL 退出 1. + +为什么必须真机: + db.py 是**唯一碰 SQL 的文件**, 它对不对只有 PG 自己说了算 -- + SQL 拼错 / 列名写错 / jsonb 强转漏了 / text[] 与 list 对不上, 纯逻辑测一律测不出来. + (自测内核.py 是纯逻辑那份, 这份补的就是"真写库"这一段.) + +怎么保证不脏生产数据 (两条): + 1. 自己造的行一律带 `_自测db_` 前缀 (驱动名 / source / cmd / caller / kernel / argv / detail), + 跑完按前缀删干净; 末了两道终检: **前缀残留 0 条** + **8 张表行数与开跑前逐表一致**. + 2. 三个**全局**函数 (收尸命令 / 收尾未结束 / 清不在空表) 会把别人的行一起改掉, + 所以它们在自己的事务里跑 (autocommit = False), 验完 ROLLBACK -- 真 SQL 真 PG, 生产行不动. + +开跑前会先按前缀清一次 (上次跑崩留下的残渣), 有残渣会打出来. +""" + +from __future__ import annotations + +import datetime +import json +import os +import sys +import time +import traceback +from collections.abc import Callable +from pathlib import Path +from typing import Any + +项目根 = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(项目根 / "内核")) + +import db # noqa: E402 +import 文本 # noqa: E402 + +# 自测造的行一律带这个前缀 (删的时候只认它, 绝不碰生产行) +前缀 = "_自测db_" +# 8 张表 (表名来自这个固定清单, 不是外部输入, 所以下面敢拼进 SQL) +表名: list[str] = ["drivers", "driver_state", "events", "scans", "commands", "calls", "kernel_env", "kernel_runs"] + +通过 = 0 +失败 = 0 +组结果: list[tuple[str, int, int]] = [] + + +def 核对(名称: str, 实际: object, 期望: object) -> None: + """比一下, 顺手把实测值打出来 (老板要看逐条的实测值, 不看'测试通过').""" + global 通过, 失败 + if 实际 == 期望: + 通过 += 1 + print(f" [OK] {名称}: {实际!r}") + return + 失败 += 1 + print(f" [FAIL] {名称}: 实际 {实际!r} != 期望 {期望!r}") + + +def 核对真(名称: str, 条件: bool, 说明: str) -> None: + """条件式核对 (判断不只是"等于某个值"时用, 说明写清楚实测到了什么).""" + global 通过, 失败 + if 条件: + 通过 += 1 + print(f" [OK] {名称}: {说明}") + return + 失败 += 1 + print(f" [FAIL] {名称}: {说明}") + + +def 抓错(名称: str, 动作: Callable[[], None]) -> None: + """期望它抛 ValueError (护栏不生效就是 FAIL). 抛别的类型算 FAIL, 不抛也算 FAIL.""" + global 通过, 失败 + try: + 动作() + except ValueError as 错: + 通过 += 1 + print(f" [OK] {名称}: 按预期抛 ValueError ({错})") + return + except Exception as 错: # pylint: disable=broad-except + 失败 += 1 + print(f" [FAIL] {名称}: 抛的是 {type(错).__name__} 而不是 ValueError ({错})") + return + 失败 += 1 + print(f" [FAIL] {名称}: 该报错的没报错 (护栏失效)") + + +def 值(行: dict[str, Any] | None, 键: str) -> Any: + """从可能为 None 的行里取值 (测试里到处是"行在就取某列, 不在就算了").""" + return 行.get(键) if 行 is not None else None + + +def 数行(连接: Any, 表: str) -> int: + """某张表的行数 (借 db.查, 免得自测里再抄一份连接代码).""" + 行表 = db.查(连接, f"SELECT count(*) AS 条数 FROM {表}", ()) + return int(行表[0]["条数"]) if 行表 else -1 + + +def 快照(连接: Any) -> dict[str, int]: + """8 张表的行数快照 (开跑前 / 收工后各来一份, 逐表比).""" + return {表: 数行(连接, 表) for 表 in 表名} + + +def 按前缀数(连接: Any) -> dict[str, int]: + """每张表里带自测前缀的残留行数 (收工终检用).""" + 查法: dict[str, tuple[str, tuple[Any, ...]]] = { + "drivers": ("name LIKE %s", (f"{前缀}%",)), + "driver_state": ("name LIKE %s", (f"{前缀}%",)), + "events": ("source LIKE %s", (f"{前缀}%",)), + "scans": ("kernel LIKE %s", (f"{前缀}%",)), + "commands": ("source LIKE %s", (f"{前缀}%",)), + "calls": ("caller LIKE %s", (f"{前缀}%",)), + "kernel_env": ("detail LIKE %s", (f"{前缀}%",)), + "kernel_runs": ("argv LIKE %s", (f"{前缀}%",)), + } + 出: dict[str, int] = {} + for 表, (条件, 参数) in 查法.items(): + 行表 = db.查(连接, f"SELECT count(*) AS 条数 FROM {表} WHERE {条件}", 参数) + 出[表] = int(行表[0]["条数"]) if 行表 else -1 + return 出 + + +def 清场(连接: Any) -> int: + """按前缀删掉自测自己造的行 (开跑前清残渣 + 收工后清场共用), 返回删了几行.""" + 总 = 0 + 删法: list[tuple[str, str]] = [ + ("drivers", "name LIKE %s"), + ("events", "source LIKE %s"), + ("scans", "kernel LIKE %s"), + ("commands", "source LIKE %s"), + ("calls", "caller LIKE %s"), + ("kernel_env", "detail LIKE %s"), + ("kernel_runs", "argv LIKE %s"), + ] + for 表, 条件 in 删法: + with 连接.cursor() as 游标: + 游标.execute(f"DELETE FROM {表} WHERE {条件}", (f"{前缀}%",)) + 总 += int(游标.rowcount) + return 总 + + +def 现在() -> datetime.datetime: + """带时区的时间戳 (timestamptz 列要 aware, 别喂 naive).""" + return datetime.datetime.now(datetime.timezone.utc) + + +def 造驱动(name: str, mode: str = "resident", valid: bool = True, error: str | None = None) -> dict[str, Any]: + """造一条完整的 drivers 记录 (键名跟表列名一致, 用来验 upsert / 回读).""" + return { + "name": name, + "dir": f"/tmp/{name}", + "runtime": "python", + "entry": "跑.py", + "interpreter": "system", + "args": ["--自测"], + "env": {"自测": "1"}, + "provides": ["自测db:能力"], + "needs": ["自测db:依赖"], + "autostart": False, + "restart": "no", + "mode": mode, + "config_hash": "哈希-初", + "entry_hash": "入口哈希-初", + "valid": valid, + "error": error, + "note": f"{前缀}造的行", + } + + +# ───────────────────────── ① 连接与建表 ───────────────────────── + + +def 测连接与建表(库: db.数据库, 连接: Any) -> None: + """连 / 配置兜底 / 建表幂等 / 库存在 -- 后面 10 组都踩在这上面.""" + 默认 = db.从配置(None) + 核对("从配置(None) 库名兜底", 默认.name, "efi_kernel") + 核对("从配置(None) 端口兜底", 默认.port, 5432) + 核对("从配置(空) 主机兜底", 默认.host, "/home/lou/pgdata/socket") + + 脏 = db.从配置({"name": "自测库", "port": "8080", "host": "1.2.3.4", "user": "谁"}) + 核对("从配置 吃配置值", (脏.name, 脏.port, 脏.host, 脏.user), ("自测库", 8080, "1.2.3.4", "谁")) + 核对("从配置 端口是字符串也能转", 脏.port, 8080) + 核对("从配置 端口是垃圾就兜底", db.从配置({"port": "垃圾"}).port, 5432) + 核对("从配置 端口是 None 兜底", db.从配置({"port": None}).port, 5432) + 核对("连接参数 库名覆盖", 默认.连接参数("别的库")["dbname"], "别的库") + 核对("连接参数 默认吃自库", 默认.连接参数()["dbname"], "efi_kernel") + 核对真("描述() 是人读的", 库.描述() == f"{库.name} @ {库.host}:{库.port} (user={库.user})", 库.描述()) + + 核对真("连() autocommit 开着", 连接.autocommit is True, f"autocommit={连接.autocommit}") + 行表 = db.查(连接, "SELECT 1 AS 一", ()) + 核对("连() 真能查 (SELECT 1)", 行表[0]["一"], 1) + + 管理 = db.连(库, 库名="postgres") + 核对真("库存在(efi_kernel)", db.库存在(管理, 库.name), f"查 pg_database 命中 {库.name}") + 核对真("库存在(瞎编的库名) 为假", not db.库存在(管理, f"{前缀}压根没有这个库"), "没这个库返回 False") + 管理.close() + + 建的全 = db.建表(连接) + 核对("建表() 对象数 (6 表 + 2 索引 + 2 张引导器台账)", len(建的全), 10) + 核对真("建表() 报的是真表名", "drivers" in 建的全 and "events_ts_idx" in 建的全, f"清单 {建的全}") + 核对("建表() 幂等 (再来一遍同样 10 个)", len(db.建表(连接)), 10) + 核对("建引导器表() 只动自己两张", len(db.建引导器表(连接)), 2) + + 行表 = db.查( + 连接, + "SELECT count(*) AS 表数 FROM information_schema.tables" + " WHERE table_schema = 'public' AND table_name = ANY(%s)", + (表名,), + ) + 核对("PG 里真有这 8 张表", int(行表[0]["表数"]), 8) + + 核对("清理函数能跑 (无残渣时删 0 行)", 清场(连接), 0) + + +# ───────────────────────── ② 注册表 drivers ───────────────────────── + + +def 测注册表(连接: Any, 生产名单: list[str]) -> None: + """upsert / 回读 / 排序 / 清不在 (只删磁盘没了的).""" + def 造一条(name: str, **改: Any) -> dict[str, Any]: + 记录 = 造驱动(name) + 记录.update(改) + return 记录 + + 甲 = f"{前缀}甲" + db.记驱动(连接, 造一条(甲)) + 行 = db.取驱动(连接, 甲) + 核对真("记驱动 后取得到", 行 is not None, "行不是 None") + if 行 is not None: + 核对("回读 name", 行["name"], 甲) + 核对("回读 runtime", 行["runtime"], "python") + 核对("回读 entry", 行["entry"], "跑.py") + 核对("回读 mode", 行["mode"], "resident") + 核对("回读 interpreter", 行["interpreter"], "system") + 核对("回读 args (jsonb -> list)", 行["args"], ["--自测"]) + 核对("回读 env (jsonb -> dict)", 行["env"], {"自测": "1"}) + 核对("回读 provides (text[] -> list)", 行["provides"], ["自测db:能力"]) + 核对("回读 needs (text[] -> list)", 行["needs"], ["自测db:依赖"]) + 核对("回读 autostart", 行["autostart"], False) + 核对("回读 valid", 行["valid"], True) + 核对("回读 error 为空", 行["error"], None) + 核对真("scanned_at 落了时间", 行["scanned_at"] is not None, str(行["scanned_at"])) + + # upsert: 同一个名字再扫一次 = 覆盖, 不是插第二条 + db.记驱动(连接, 造一条(甲, mode="oneshot", valid=False, error="自测-配置坏了", config_hash="哈希-新")) + 全部 = db.取全部驱动(连接) + 同名条数 = len([1 for 行 in 全部 if 行["name"] == 甲]) + 核对("upsert 不新增行 (同名仍只 1 条)", 同名条数, 1) + 行 = db.取驱动(连接, 甲) + 核对("upsert 覆盖了 mode", 行["mode"] if 行 else None, "oneshot") + 核对("upsert 覆盖了 valid", 行["valid"] if 行 else None, False) + 核对("upsert 覆盖了 error", 行["error"] if 行 else None, "自测-配置坏了") + 核对("upsert 覆盖了 config_hash", 行["config_hash"] if 行 else None, "哈希-新") + + 名字表 = [str(行["name"]) for 行 in 全部] + 核对("取全部驱动 按名字有序", 名字表, sorted(名字表)) + 核对真("取驱动(不存在) 给 None", db.取驱动(连接, f"{前缀}压根没有") is None, "返回 None") + + # 清不在: 保留 = 生产 + 自测, 谁都不该删 + 核对("清不在(保留全) 不删任何行", db.清不在(连接, [*生产名单, 甲]), []) + 核对真("清不在 后甲还在", db.取驱动(连接, 甲) is not None, "自测驱动还在") + + # 清不在: 保留 = 只有生产 -> 自测那条该被删 + db.确保状态行(连接, 甲, 1) + 核对真("删之前 driver_state 有甲", db.取状态(连接, 甲) is not None, "状态行在") + 被删 = db.清不在(连接, 生产名单) + 核对("清不在 报出被删的名字", 被删, [甲]) + 核对真("被删驱动不在了", db.取驱动(连接, 甲) is None, "drivers 里没了") + 核对真("driver_state 级联也删了 (不留孤儿)", db.取状态(连接, 甲) is None, "状态行跟着走") + 核对真("生产驱动一个没少", all(db.取驱动(连接, 名) is not None for 名 in 生产名单), f"名单 {生产名单}") + + +# ───────────────────────── ③ 运行时状态 driver_state ───────────────────────── + + +def 测状态(连接: Any) -> None: + """确保状态行幂等 / 白名单动态 UPDATE / 只改传来的列 (不抹别人的字段).""" + 名 = f"{前缀}状态" + db.记驱动(连接, 造驱动(名)) + + db.确保状态行(连接, 名, 7) + db.确保状态行(连接, 名, 99) + 行表 = db.查(连接, "SELECT count(*) AS 条数 FROM driver_state WHERE name = %s", (名,)) + 核对("确保状态行 幂等 (只有 1 行)", int(行表[0]["条数"]), 1) + 行 = db.取状态(连接, 名) + 核对("新状态行默认 stopped", 行["state"] if 行 else None, "stopped") + 核对("新状态行 list_version 记的是第一次那版 (不覆盖)", 行["list_version"] if 行 else None, 7) + + db.写状态(连接, 名, {"state": "running", "pid": 4242, "pgid": 4242, "started_at": 现在(), "restarts": 3, + "boot_hash": "启动指纹"}) + 行 = db.取状态(连接, 名) + 核对("写状态 state", 行["state"] if 行 else None, "running") + 核对("写状态 pid", 行["pid"] if 行 else None, 4242) + 核对("写状态 pgid", 行["pgid"] if 行 else None, 4242) + 核对("写状态 restarts", 行["restarts"] if 行 else None, 3) + 核对("写状态 boot_hash", 行["boot_hash"] if 行 else None, "启动指纹") + 核对真("写状态 started_at 落库", 值(行, "started_at") is not None, str(值(行, "started_at"))) + + # 关键: 只改 pid, 别的列一个都不能被动 (状态就是这么丢的) + db.写状态(连接, 名, {"pid": 999}) + 行 = db.取状态(连接, 名) + 核对("只改 pid 时 boot_hash 不被抹", 行["boot_hash"] if 行 else None, "启动指纹") + 核对("只改 pid 时 state 不变", 行["state"] if 行 else None, "running") + 核对("只改 pid 时 restarts 不变", 行["restarts"] if 行 else None, 3) + 核对("只改 pid 时 started_at 不变", 行["started_at"] is not None if 行 else False, True) + 核对("只改 pid 生效", 行["pid"] if 行 else None, 999) + + db.写状态(连接, 名, {"state": "stopped", "pid": None, "pgid": None, "exit_code": 0, "stopped_at": 现在()}) + 行 = db.取状态(连接, 名) + 核对("写 pid=None 能真写进去", 行["pid"] if 行 else "取不到", None) + 核对("写 exit_code=0 能真写进去", 行["exit_code"] if 行 else None, 0) + + db.写状态(连接, 名, {}) + 核对("写状态({}) 不报错 (空改动直接返回)", db.取状态(连接, 名) is not None, True) + 抓错("写状态(拼错的列名) 被白名单挡住", lambda: db.写状态(连接, 名, {"列名拼错了": 1})) + 抓错("写状态(想改 updated_at) 被挡住", lambda: db.写状态(连接, 名, {"updated_at": 现在()})) + 抓错("写状态(想改主键 name) 被挡住", lambda: db.写状态(连接, 名, {"name": "改名"})) + + db.写状态(连接, f"{前缀}压根没有这行", {"pid": 1}) + 核对("写状态(没有这行) 静默 0 行 (调用方先 确保状态行)", db.取状态(连接, f"{前缀}压根没有这行"), None) + + 全部 = db.取全部状态(连接) + 名字表 = [str(行["name"]) for 行 in 全部] + 核对("取全部状态 按名字有序", 名字表, sorted(名字表)) + 核对真("取全部状态 含自测那行", 名 in 名字表, f"共 {len(全部)} 行") + + +# ───────────────────────── ④ 事件总线 events ───────────────────────── + + +def 测事件(连接: Any) -> None: + """写事件 / 读事件 (条数 / 按驱动过滤) -- 这就是"没有通信协议"的那张总线表.""" + 名 = f"{前缀}状态" + db.写事件(连接, source=f"{前缀}甲", kind="start", message="自测: 起来了", driver=名, data={"pid": 4242}) + db.写事件(连接, source=f"{前缀}甲", kind="error", message="自测: 报了个错", driver=名, level="error", + data={"原因": "自测"}) + + 最近 = db.读事件(连接, 1) + 核对("读事件 新的在前", 最近[0]["message"], "自测: 报了个错") + 核对("读事件 level 回读", 最近[0]["level"], "error") + 核对("读事件 driver 回读", 最近[0]["driver"], 名) + 核对("读事件 data (jsonb) 回读", 最近[0]["data"], {"原因": "自测"}) + + 我们 = db.读事件(连接, 10, 名) + 核对("按驱动过滤 只出这个驱动的", [str(行["driver"]) for 行 in 我们], [名, 名]) + 核对("按驱动过滤 新的在前", [str(行["kind"]) for 行 in 我们], ["error", "start"]) + 核对("按驱动过滤 拿到 data", 我们[1]["data"], {"pid": 4242}) + 核对("默认 level 是 info", 我们[1]["level"], "info") + + 核对("按不存在的驱动过滤 = 空表", db.读事件(连接, 10, f"{前缀}压根没有"), []) + 核对("读事件(条数=1) 只给 1 条", len(db.读事件(连接, 1)), 1) + 核对真("读事件 带 id (主键)", isinstance(最近[0]["id"], int), f"id={最近[0]['id']}") + 核对真("读事件 带 ts (时间戳)", 最近[0]["ts"] is not None, str(最近[0]["ts"])) + + +# ───────────────────────── ⑤ 扫描批次 scans ───────────────────────── + + +def 测扫描批次(连接: Any) -> None: + """list_version 自增 + 最近一次能取回来 (列表页脚那个 扫描 #N).""" + 第一次 = db.记扫描批次(连接, f"{前缀}内核", 3, 3, 0, 2) + 第二次 = db.记扫描批次(连接, f"{前缀}内核", 3, 2, 1, 1) + 核对真("记扫描批次 给自增 list_version", 第二次 > 第一次 > 0, f"第一次 {第一次} 第二次 {第二次}") + + 最近 = db.取最近扫描(连接) + 核对真("取最近扫描 就是刚写那条", 最近 is not None and 最近["list_version"] == 第二次, str(最近)) + if 最近 is not None: + 核对("最近扫描 kernel 字段", 最近["kernel"], f"{前缀}内核") + 核对("最近扫描 total", 最近["total"], 3) + 核对("最近扫描 valid", 最近["valid"], 2) + 核对("最近扫描 invalid", 最近["invalid"], 1) + 核对("最近扫描 running", 最近["running"], 1) + 核对真("最近扫描 带 started_at", 最近["started_at"] is not None, str(最近["started_at"])) + + +# ───────────────────────── ⑥ 命令 commands (事务里跑) ───────────────────────── + + +def 测命令(事务连接: Any) -> None: + """待领 -> 领走 -> 回结果, 外加"running 的命令不会被重复领"和收尸. + + 这一组在事务里跑 (autocommit=False, 末尾 rollback): 领命令 是全表找第一条 pending, + 生产库里真有待领命令的话会被我们顺手领走 -- 回滚保证它原封不动. + """ + 口令 = f"{前缀}命令" + 我的id = db.记命令(事务连接, 口令, "列表", {"自测": 1}) + 核对真("记命令 返回 id", 我的id > 0, f"id={我的id}") + + 行 = db.读命令(事务连接, 我的id) + 核对("新命令 state=pending", 行["state"] if 行 else None, "pending") + 核对("新命令 args (jsonb) 回读", 行["args"] if 行 else None, {"自测": 1}) + 核对("pending 命令 started_at 还是空", 行["started_at"] if 行 else "取不到", None) + + # 领: pending -> running. 生产里若有 pending, 会先被领到 (回滚掉, 无副作用). + 待领数 = int(db.查(事务连接, "SELECT count(*) AS 条数 FROM commands WHERE state = 'pending'", ())[0]["条数"]) + 领到的: list[int] = [] + for _ in range(待领数 + 1): + 条目 = db.领命令(事务连接) + if 条目 is None: + break + 领到的.append(int(条目["id"])) + 核对真("领到的命令 state=running", 条目["state"] == "running", f"#{条目['id']} state={条目['state']}") + 核对真("领到的命令 started_at 落了", 条目["started_at"] is not None, str(条目["started_at"])) + 核对真("领命令 能领到我们自己那条", 我的id in 领到的, f"领到 {领到的}") + + for _ in range(3): + db.领命令(事务连接) + 行 = db.读命令(事务连接, 我的id) + 核对("被领过的命令不会被重复领 (已 running)", 行["state"] if 行 else None, "running") + + # CLI 自己执行的命令用 state='running' 落行, 常驻内核不许再领 + 我自己跑 = db.记命令(事务连接, 口令, "启动 Json解码", state="running") + 行 = db.读命令(事务连接, 我自己跑) + 核对("记命令(state=running) 立刻有 started_at", 行["started_at"] is not None if 行 else False, True) + 再领: list[int] = [] + for _ in range(3): + 条目 = db.领命令(事务连接) + if 条目 is not None: + 再领.append(int(条目["id"])) + 核对真("running 的命令不会被领走", 我自己跑 not in 再领, f"又领到 {再领}") + + db.记命令结果(事务连接, 我的id, "done", {"输出": "自测跑完了"}) + 行 = db.读命令(事务连接, 我的id) + 核对("记命令结果 state", 行["state"] if 行 else None, "done") + 核对("记命令结果 result", 行["result"] if 行 else None, {"输出": "自测跑完了"}) + 核对真("记命令结果 落了 finished_at", 值(行, "finished_at") is not None, str(值(行, "finished_at"))) + + 核对("读命令(不存在) 给 None", db.读命令(事务连接, 99999999), None) + + # 收尸: 断电留下的 running (finished_at 空) 要能收尾 + db.记命令(事务连接, 口令, "谁都没管它", state="running") + 收了几条 = db.收尸命令(事务连接, "自测收尸") + 核对真("收尸命令 收到了 running 的行", 收了几条 >= 1, f"收了 {收了几条} 条") + 行 = db.读命令(事务连接, 我自己跑) + 核对("被收尸的命令 state=failed", 行["state"] if 行 else None, "failed") + 核对真("收尸 落了 finished_at", 值(行, "finished_at") is not None, str(值(行, "finished_at"))) + 核对("收尸 把原因写进 result", 行["result"] if 行 else None, {"detail": "自测收尸"}) + 再收 = db.收尸命令(事务连接, "自测再收一次") + 核对("已收过的不会再收 (幂等)", 再收, 0) + + +# ───────────────────────── ⑦ 调用 calls (事务里跑) ───────────────────────── + + +def 测调用(事务连接: Any) -> None: + """驱动写 pending -> 内核领 -> 转发 -> 回填. 这里照抄 驱动/样例消费器/请求.py 那条 INSERT, + 顺便验"驱动自己写的行, 内核领得到" (db.py 没有插 calls 的函数 -- 入口在驱动那边). + """ + 我的 = f"{前缀}调用" + 契约 = "自测db:能力" + 锁 = "自测db:锁" + 游标 = 事务连接.cursor() + 游标.execute( + "INSERT INTO calls (caller, want, args, lock_key)" + " VALUES (%s, %s, %s::jsonb, %s) RETURNING id", + (我的, 契约, json.dumps({"chain": [我的]}, ensure_ascii=False), 锁), + ) + 行 = 游标.fetchone() + 游标.close() + 我的id = int(行[0]) if 行 else 0 + 核对真("驱动那条 INSERT 写进去了 (真集成)", 我的id > 0, f"calls id={我的id}") + + 待领 = int(db.查(事务连接, "SELECT count(*) AS 条数 FROM calls WHERE state = 'pending'", ())[0]["条数"]) + 领到的 = db.领调用(事务连接) + 核对真("领调用 能领到驱动写的行", 我的id in [int(行["id"]) for 行 in 领到的], f"领到 {len(领到的)} 条") + 核对真("领调用 备注是 pending 数 + 1", len(领到的) <= 待领, f"待领 {待领} 领到 {len(领到的)}") + 行 = db.读调用(事务连接, 我的id) + 核对("领走后 state=waiting", 行["state"] if 行 else None, "waiting") + + db.写调用(事务连接, 我的id, {"state": "running", "provider": f"{前缀}状态", "started_at": 现在()}) + 行 = db.读调用(事务连接, 我的id) + 核对("写调用 state", 行["state"] if 行 else None, "running") + 核对("写调用 provider (内核自己记的账)", 行["provider"] if 行 else None, f"{前缀}状态") + 核对("写调用 caller 没被动", 行["caller"] if 行 else None, 我的) + 核对("写调用 want 没被动", 行["want"] if 行 else None, 契约) + + 核对真("同锁在跑 (别的 id 占着同一把锁)", db.同锁在跑(事务连接, 锁, 0), f"lock_key={锁}") + 核对真("同锁在跑 (排除自己就没有别人)", not db.同锁在跑(事务连接, 锁, 我的id), "排除自己 -> False") + 核对真("同锁在跑 (没人用的锁)", not db.同锁在跑(事务连接, "自测db:没人用的锁", 0), "False") + + 运行中的 = db.取调用(事务连接, "running") + 核对真("取调用(running) 含我们那条", 我的id in [int(行["id"]) for 行 in 运行中的], f"{len(运行中的)} 条") + 待办 = db.取调用(事务连接, "pending") + 核对真("取调用(pending) 不含我们那条", 我的id not in [int(行["id"]) for 行 in 待办], f"{len(待办)} 条") + + 抓错("写调用(拼错的列名) 被白名单挡住", lambda: db.写调用(事务连接, 我的id, {"列名拼错了": 1})) + 抓错("写调用(想改主键 id) 被挡住", lambda: db.写调用(事务连接, 我的id, {"id": 1})) + db.写调用(事务连接, 我的id, {}) + 核对("写调用({}) 不报错", db.读调用(事务连接, 我的id) is not None, True) + + db.写调用(事务连接, 我的id, {"state": "done", "result": {"产出": "自测的产出"}, "finished_at": 现在()}) + 行 = db.读调用(事务连接, 我的id) + 核对("回填结果 state", 行["state"] if 行 else None, "done") + 核对("回填结果 result", 行["result"] if 行 else None, {"产出": "自测的产出"}) + 核对("读调用(不存在) 给 None", db.读调用(事务连接, 99999999), None) + + +def 等通知(连接: Any, 超时: float = 3.0) -> list[tuple[str, str]]: + """等通知到货 (最多等 超时 秒), 取到就返回. + + 为什么不能 通知完就立刻 poll: NOTIFY 要等发方那条命令提交后才真正投递, 收方这边 + 网络 + poll() 都要一点点时间 -- 立刻取会间歇性取到空 (2026-09-16 自测第一次跑就误报 3 条 FAIL). + """ + 线 = time.monotonic() + 超时 + while True: + try: + 连接.poll() + except Exception: # pylint: disable=broad-except + pass + 收到 = db.收通知(连接) + if 收到: + return 收到 + if time.monotonic() >= 线: + return [] + time.sleep(0.05) + + +# ───────────────────────── ⑧ LISTEN / NOTIFY (两条真连接) ───────────────────────── + + +def 测通知(库: db.数据库) -> None: + """真 LISTEN / 真 NOTIFY: 一条连接听, 另一条发, 听得见才算通 (这是内核被唤醒的唯一机制).""" + 听者 = db.连(库) + 喊者 = db.连(库) + 通道 = f"{前缀}通道" + db.监听(听者, 通道) + db.通知(喊者, 通道, "喂") + 核对("LISTEN/NOTIFY 收到通知", 等通知(听者), [(通道, "喂")]) + 核对("收通知 取走就没了 (第二次空)", db.收通知(听者), []) + + db.通知(喊者, 通道, "") + 核对("空载荷也能收到 (只有叫醒作用)", 等通知(听者), [(通道, "")]) + + db.监听(听者, f"{前缀}中文通道") + db.通知(喊者, f"{前缀}中文通道", "中文") + 核对("中文通道名 (驱动名有中文, 通道也有)", 等通知(听者), [(f"{前缀}中文通道", "中文")]) + + # 一条连接自己发的自己能不能听着? PG 会把通知投给包括自己在内的所有监听会话 -- 顺手记下真行为 + db.监听(喊者, f"{前缀}自听") + db.通知(喊者, f"{前缀}自听", "自己喊自己") + 核对("同一连接自己喊自己 (PG 行为, 如实记下)", 等通知(喊者), [(f"{前缀}自听", "自己喊自己")]) + + 抓错("监听(带连字符的通道名) 被挡住", lambda: db.监听(听者, "坏-通道")) + 抓错("监听(空格) 被挡住", lambda: db.监听(听者, "坏 通道")) + 抓错("监听(空串) 被挡住", lambda: db.监听(听者, "")) + 听者.close() + 喊者.close() + + +# ───────────────────────── ⑨ 咨询锁 (独一份调度器) ───────────────────────── + + +def 测咨询锁(库: db.数据库) -> None: + """会话级咨询锁: 抢得到 / 抢不到 / 连接一断自动放 (断电不留死锁).""" + 键 = 0x7A7A7A01 # 自测专用键 (别抢 0x65666901, 那是常驻内核的) + 甲 = db.连(库) + 乙 = db.连(库) + 核对真("甲 抢到锁", db.试锁(甲, 键), f"键 {键}") + 核对真("乙 抢不到 (同一份 PG 内存只能有一个调度器)", not db.试锁(乙, 键), "False") + 核对真("甲 再抢同一把还是 True (会重入计数)", db.试锁(甲, 键), "会话级锁可重入") + 核对真("乙 换一把键就能抢到", db.试锁(乙, 键 + 1), f"键 {键 + 1}") + 甲.close() + time.sleep(0.2) + 核对真("甲 连接一断, 锁自动放掉 (断电不留死锁)", db.试锁(乙, 键), "乙现在抢到了") + 乙.close() + + +# ───────────────────────── ⑩ 引导器两张台账 ───────────────────────── + + +def 测台账(连接: Any, 事务连接: Any) -> None: + """kernel_env (体检快照) + kernel_runs (运行台账) -- 引导器那两张.""" + 核对("取bool(None)", db.取bool(None), False) + 核对("取bool(True)", db.取bool(True), True) + 核对("取bool(False)", db.取bool(False), False) + 核对("取bool('true')", db.取bool("true"), True) + 核对("取bool('TRUE')", db.取bool("TRUE"), True) + 核对("取bool('1')", db.取bool("1"), True) + 核对("取bool('yes')", db.取bool("yes"), True) + 核对("取bool('ok')", db.取bool("ok"), True) + 核对("取bool('no')", db.取bool("no"), False) + 核对("取bool(数字) 不算真", db.取bool(3), False) + + # 快照里故意混 None / 字符串 / 真 bool (json 落盘回来就是这样) + db.记体检(连接, { + "python": {"version": "3.11.15"}, + "venv": {"path": "/tmp/自测venv", "healthy": "true"}, + "packages": [{"name": "自测包", "want": ">=1", "got": "1.0", "ok": True, "required": True}], + "pg": {"ok": None}, + "driver_root": {"ok": True}, + "blocking_ok": True, + "detail": f"{前缀}体检快照", + }) + 行表 = db.查(连接, "SELECT * FROM kernel_env WHERE detail = %s ORDER BY id DESC LIMIT 1", (f"{前缀}体检快照",)) + 核对真("记体检 落了一行", len(行表) == 1, "1 行") + if 行表: + 行 = 行表[0] + 核对("体检 python_version", 行["python_version"], "3.11.15") + 核对("体检 venv_path", 行["venv_path"], "/tmp/自测venv") + 核对("体检 venv_healthy ('true' -> True)", 行["venv_healthy"], True) + 核对("体检 pg_ok (None -> False)", 行["pg_ok"], False) + 核对("体检 driver_root_ok", 行["driver_root_ok"], True) + 核对("体检 ok", 行["ok"], True) + 核对("体检 packages (jsonb 数组)", 行["packages"][0]["name"] if 行["packages"] else None, "自测包") + + # kernel_runs: 开跑 -> 补 pid -> 收尾 + 今日前 = db.今日运行次数(连接) + 运行id = db.记运行开始(连接, f"{前缀}运行", "daemon", os.getpid()) + 核对真("记运行开始 返回 id", 运行id > 0, f"id={运行id}") + 核对("今日运行次数 加一", db.今日运行次数(连接), 今日前 + 1) + + 未结束 = db.未结束运行(连接) + 核对真("未结束运行 含刚开的那条", 运行id in [int(行["id"]) for 行 in 未结束], f"共 {len(未结束)} 条") + 最近 = db.最近运行(连接) + 核对真("最近运行 就是刚开那条", 最近 is not None and int(最近["id"]) == 运行id, str(最近)) + + db.记内核pid(连接, 运行id, 4321) + 最近 = db.最近运行(连接) + 核对("记内核pid 补上了", 最近["pid"] if 最近 else None, 4321) + + db.记运行结束(连接, 运行id, 0, 0.25, True, "自测收尾") + 最近 = db.最近运行(连接) + 核对("记运行结束 exit_code", 最近["exit_code"] if 最近 else None, 0) + 核对("记运行结束 ok", 最近["ok"] if 最近 else None, True) + 核对("记运行结束 detail", 最近["detail"] if 最近 else None, "自测收尾") + 核对真("记运行结束 seconds 落库", abs(float(最近["seconds"]) - 0.25) < 0.01 if 最近 else False, + f"{最近['seconds'] if 最近 else '?'}") + 核对真("记运行结束 落了 finished_at", 最近["finished_at"] is not None if 最近 else False, "有值") + 核对真("收尾后不在未结束列表里", 运行id not in [int(行["id"]) for 行 in db.未结束运行(连接)], "已收尾") + + # 收尾未结束 是全局函数 (会把所有没收尾的行改掉) -> 在事务里验, 末尾回滚 + in_tx = db.记运行开始(事务连接, f"{前缀}断电残留", "oneshot", None) + 收到了 = db.收尾未结束(事务连接, "自测收尾未结束") + 核对真("收尾未结束 收到了没收尾的行", 收到了 >= 1, f"收了 {收到了} 条") + 行表 = db.查(事务连接, "SELECT * FROM kernel_runs WHERE id = %s", (in_tx,)) + 核对("收尾未结束 给补了 finished_at", 行表[0]["finished_at"] is not None if 行表 else False, True) + 核对("收尾未结束 标 ok=false", 行表[0]["ok"] if 行表 else None, False) + 核对("收尾未结束 写明是谁收的", 行表[0]["detail"] if 行表 else None, "自测收尾未结束") + + +# ───────────────────────── ⑪ 收工: 清场 + 两道终检 ───────────────────────── + + +def 测清理与残留(连接: Any, 开跑行数: dict[str, int]) -> None: + """自己的行删干净, 再逐表核对: 残留 0 条 + 行数与开跑前一致.""" + 名 = f"{前缀}状态" + db.确保状态行(连接, 名, 1) + 核对真("清场前 自测驱动还在", db.取驱动(连接, 名) is not None, "在") + 核对真("清场前 它的状态行也在", db.取状态(连接, 名) is not None, "在") + + 删了 = 清场(连接) + 核对真("清场 删掉了自己造的行", 删了 >= 1, f"删了 {删了} 行") + 核对真("清场 把自测驱动删了", db.取驱动(连接, 名) is None, "没了") + 核对真("drivers 级联带走了 driver_state (不留孤儿)", db.取状态(连接, 名) is None, "状态行跟着走") + + 残留 = 按前缀数(连接) + 核对("前缀残留 (8 张表逐表)", 残留, dict.fromkeys(表名, 0)) + + 收工行数 = 快照(连接) + 核对("8 张表行数与开跑前一致", 收工行数, 开跑行数) + + +# ───────────────────────── 收口 ───────────────────────── + + +def 跑组(名称: str, 动作: Callable[[], None]) -> None: + """跑一组并记账; 某一组炸了不影响后面的组 (后面的组照样能报问题).""" + global 通过, 失败 + 前的通过, 前的失败 = 通过, 失败 + print(f"\n== {名称} ==") + try: + 动作() + except Exception as 错: # pylint: disable=broad-except + 失败 += 1 + print(f" [FAIL] 这一组抛异常: {type(错).__name__}: {错}") + traceback.print_exc() + 组结果.append((名称, 通过 - 前的通过, 失败 - 前的失败)) + + +def main() -> int: + """连真库, 逐组跑, 出汇总表.""" + print(f"db 层真机自测 {time.strftime('%Y-%m-%d %H:%M:%S')}") + + 配置 = json.loads((项目根 / "环境.efi.json").read_text(encoding="utf-8")) + 库 = db.从配置(配置.get("db")) + print(f"库: {库.描述()} (测试造的行一律带前缀 {前缀!r}, 只删自己造的 -- 见文件头)") + + if not db.有psycopg2: + print("[FAIL] 这个 python 没装 psycopg2, db 层没法验 (引导器可以走降级分支, 自测不行)") + return 1 + + 连接 = db.连(库) + 开跑行数 = 快照(连接) + 开跑前残留 = 按前缀数(连接) + 残渣 = sum(开跑前残留.values()) + if 残渣: + print(f"[WARN] 上次跑崩留下的残渣 {残渣} 条 (逐表 {开跑前残留}), 先清掉再开始") + 清场(连接) + 开跑行数 = 快照(连接) + + 事务连接 = db.连(库) + 事务连接.autocommit = False + + 生产名单 = [str(行["name"]) for 行 in db.取全部驱动(连接)] + print(f"生产注册表: {len(生产名单)} 个驱动 {生产名单} (自测绝不删它们)") + + 跑组("① 连接与建表", lambda: 测连接与建表(库, 连接)) + 跑组("② 注册表 drivers", lambda: 测注册表(连接, 生产名单)) + 跑组("③ 运行时状态 driver_state", lambda: 测状态(连接)) + 跑组("④ 事件总线 events", lambda: 测事件(连接)) + 跑组("⑤ 扫描批次 scans", lambda: 测扫描批次(连接)) + + def 命令组() -> None: + 测命令(事务连接) + 事务连接.rollback() + print(" [提示] 本组在事务里跑, 已 ROLLBACK (生产库的 pending 命令原封不动)") + + def 调用组() -> None: + 测调用(事务连接) + 事务连接.rollback() + print(" [提示] 本组在事务里跑, 已 ROLLBACK (生产库的 calls 原封不动)") + + 跑组("⑥ 命令 commands (事务)", 命令组) + 跑组("⑦ 调用 calls (事务)", 调用组) + 跑组("⑧ LISTEN / NOTIFY", lambda: 测通知(库)) + + def 锁组() -> None: + 测咨询锁(库) + + 跑组("⑨ 咨询锁 (独一份)", 锁组) + + def 台账组() -> None: + 测台账(连接, 事务连接) + + 跑组("⑩ 引导器台账 kernel_env / kernel_runs", 台账组) + 事务连接.close() + 跑组("⑪ 清场与残留终检", lambda: 测清理与残留(连接, 开跑行数)) + + 连接.close() + + print("\n" + "=" * 110) + print("汇总") + print("=" * 110) + 行表 = [[str(序), 名称, str(过), str(挂), "全过" if 挂 == 0 else "有问题"] + for 序, (名称, 过, 挂) in enumerate(组结果, 1)] + 文本.打印(文本.表格(["#", "测试组", "通过", "失败", "判定"], 行表, + [文本.右, 文本.左, 文本.右, 文本.右, 文本.左])) + print(f"\n 合计: 通过 {通过} / 失败 {失败}") + print(f" 残留: {'无' if 失败 == 0 else '见上面 FAIL 行'}") + print(f" 结论: {'全过' if 失败 == 0 else '有 FAIL, 上面逐条看'}") + return 0 if 失败 == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/内核/自测内核.py b/内核/自测内核.py new file mode 100644 index 0000000..08ff909 --- /dev/null +++ b/内核/自测内核.py @@ -0,0 +1,262 @@ +"""内核的纯逻辑自测: 校验 / 契约匹配 / 拓扑排序 / 状态机 / 拼命令. + +用法: python3 内核/自测内核.py + +为什么分成"纯逻辑"这一份: + 端到端那套 (真起进程,真写库) 由 试跑引导器.py 和真机敲命令来验; + 这一份只喂**脏配置**和**假状态行**, 不碰 PG,不写注册表 -- 所以可以反复跑, 不留残渣. +老板口径: "单元测试不算测试" -- 所以这里除了 状态.复核() 那一组会真起一个 python 进程去 +验判活, 其余都是纯函数; 真流程由真机命令验 (见技能里的 试跑引导器.py + 启动/停止实测). + +跑完 0 退出; 有 FAIL 退出 1. +""" + +from __future__ import annotations + +import json +import os +import signal +import subprocess +import sys +import tempfile +import time +from pathlib import Path +from typing import Any + +项目根 = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(项目根 / "内核")) + +import 内核 # noqa: E402 +import 扫描 # noqa: E402 +import 状态 # noqa: E402 + +通过 = 0 +失败 = 0 + + +def 核对(名称: str, 实际: object, 期望: object) -> None: + """比一下, 顺手把结果打出来 (老板要看得到逐条的实测值).""" + global 通过, 失败 + if 实际 == 期望: + 通过 += 1 + print(f" [OK] {名称}: {实际!r}") + return + 失败 += 1 + print(f" [FAIL] {名称}: 实际 {实际!r} != 期望 {期望!r}") + + +def 造驱动(根: Path, 目录名: str, 配置: dict[str, Any] | None, 入口名: str = "跑.py", 建入口: bool = True, + 入口可执行: bool = False, 配置原文: str | None = None) -> Path: + """在临时驱动根里造一个驱动文件夹 (配置可以给字典 / 原始文本 / 不给).""" + 目录 = 根 / 目录名 + 目录.mkdir(parents=True, exist_ok=True) + if 配置原文 is not None: + (目录 / 状态.配置名).write_text(配置原文, encoding="utf-8") + elif 配置 is not None: + (目录 / 状态.配置名).write_text(json.dumps(配置, ensure_ascii=False), encoding="utf-8") + if 建入口: + 入口 = 目录 / 入口名 + 入口.write_text("# 测试用\nprint('hi')\n", encoding="utf-8") + 入口.chmod(0o755 if 入口可执行 else 0o644) + return 目录 + + +def 测扫目录(根: Path) -> None: + """扫目录: 只认根目录有 配置.efi.json 的一级子目录 (隐藏目录/没配置的都不算).""" + print("\n== 扫目录 ==") + 造驱动(根, "有配置", {"efi": 1, "name": "有配置", "runtime": "python", "entry": "跑.py"}) + 造驱动(根, "没配置", None) + 造驱动(根, ".藏起来的", {"efi": 1, "name": "藏", "runtime": "python", "entry": "跑.py"}) + (根 / "一个文件.txt").write_text("x", encoding="utf-8") + 出 = 扫描.扫目录(根) + 核对("认出来的驱动数", len(出), 1) + 核对("认出来的是谁", [项.name for 项 in 出], ["有配置"]) + + +def 测校验(根: Path) -> None: + """9 条校验里的静态部分 (1-6): 每种脏配置都要被拦下, 且原因写在 error 里.""" + print("\n== 校验单个 (脏配置逐条) ==") + 好 = 造驱动(根, "甲", {"efi": 1, "name": "甲", "runtime": "python", "entry": "跑.py"}) + 行 = 扫描.校验单个(好) + 核对("好驱动 valid", 行["valid"], True) + 核对("好驱动 entry_hash 非空", bool(行["entry_hash"]), True) + 核对("好驱动 config_hash 非空", bool(行["config_hash"]), True) + 核对("name 缺省用文件夹名", 扫描.校验单个(造驱动(根, "没写名", {"efi": 1, "runtime": "python", "entry": "跑.py"}))["name"], "没写名") + + 行 = 扫描.校验单个(造驱动(根, "坏json", None, 配置原文="{ 这不是 json")) + 核对("坏 json 被拦", 行["valid"], False) + 核对("坏 json 原因", "不是合法 JSON" in str(行["error"]), True) + + 行 = 扫描.校验单个(造驱动(根, "版本不对", {"efi": 9, "name": "版本不对", "runtime": "python", "entry": "跑.py"})) + 核对("协议版本被拦", 行["valid"], False) + 核对("版本原因", "协议版本不支持" in str(行["error"]), True) + + 行 = 扫描.校验单个(造驱动(根, "形态不对", {"efi": 1, "name": "形态不对", "runtime": "shell", "entry": "跑.py"})) + 核对("runtime 被拦", 行["valid"], False) + + 行 = 扫描.校验单个(造驱动(根, "越界", {"efi": 1, "name": "越界", "runtime": "python", "entry": "../../跑.py"})) + 核对("entry 越界被拦", 行["valid"], False) + 核对("越界原因", "越界" in str(行["error"]), True) + + 行 = 扫描.校验单个(造驱动(根, "缺入口", {"efi": 1, "name": "缺入口", "runtime": "python", "entry": "没有这个.py"}, 建入口=False)) + 核对("入口不存在被拦", 行["valid"], False) + + 行 = 扫描.校验单个(造驱动(根, "缺x位", {"efi": 1, "name": "缺x位", "runtime": "exec", "entry": "跑.py"})) + 核对("exec 缺 x 位被拦", 行["valid"], False) + + 行 = 扫描.校验单个(造驱动(根, "解释器乱写", {"efi": 1, "name": "解释器乱写", "runtime": "python", "entry": "跑.py", "interpreter": "奇怪的写法"})) + 核对("interpreter 乱写被拦", 行["valid"], False) + + +def 测重名(根: Path) -> None: + """第 7 条: 驱动名全局唯一, 后到的让位 (按目录名排序扫描, 结果稳定).""" + print("\n== 查重名 ==") + 记录表 = [ + 扫描.校验单个(造驱动(根, "重名a", {"efi": 1, "name": "重名", "runtime": "python", "entry": "跑.py"})), + 扫描.校验单个(造驱动(根, "重名b", {"efi": 1, "name": "重名", "runtime": "python", "entry": "跑.py"})), + ] + 扫描.查重名(记录表) + 核对("先到的留", 记录表[0]["valid"], True) + 核对("后到的被拒", 记录表[1]["valid"], False) + 核对("原因写着重复", "驱动名重复" in str(记录表[1]["error"]), True) + + +def 测契约(根: Path) -> None: + """第 8 条: 契约匹配 + 拓扑排序 + 成环拒载 (含"环上驱动的消费者跟着拒载").""" + print("\n== 契约匹配 / 拓扑 / 成环 ==") + 记录表 = [ + 扫描.校验单个(造驱动(根, "供货", {"efi": 1, "name": "供货", "runtime": "python", "entry": "跑.py", + "provides": ["表格:网页"]})), + 扫描.校验单个(造驱动(根, "消费", {"efi": 1, "name": "消费", "runtime": "python", "entry": "跑.py", + "needs": ["表格:网页"]})), + 扫描.校验单个(造驱动(根, "孤儿", {"efi": 1, "name": "孤儿", "runtime": "python", "entry": "跑.py", + "needs": ["没人给的东西"]})), + 扫描.校验单个(造驱动(根, "环甲", {"efi": 1, "name": "环甲", "runtime": "python", "entry": "跑.py", + "provides": ["环:a"], "needs": ["环:b"]})), + 扫描.校验单个(造驱动(根, "环乙", {"efi": 1, "name": "环乙", "runtime": "python", "entry": "跑.py", + "provides": ["环:b"], "needs": ["环:a"]})), + 扫描.校验单个(造驱动(根, "环下游", {"efi": 1, "name": "环下游", "runtime": "python", "entry": "跑.py", + "needs": ["环:a"]})), + ] + 契约, 问题 = 扫描.定契约(记录表) + 拣 = {str(行["name"]): 行 for 行 in 记录表} + 核对("供货 valid", 拣["供货"]["valid"], True) + 核对("消费 valid", 拣["消费"]["valid"], True) + 核对("契约表", 契约.get("表格:网页"), "供货") + 核对("孤儿被拒", 拣["孤儿"]["valid"], False) + 核对("孤儿原因", "契约无人提供" in str(拣["孤儿"]["error"]), True) + 核对("环甲被拒", 拣["环甲"]["valid"], False) + 核对("环乙被拒", 拣["环乙"]["valid"], False) + 核对("环原因写着成环", "成环" in str(拣["环甲"]["error"]), True) + 核对("环下游跟着被拒", 拣["环下游"]["valid"], False) + 核对("问题表 (没有重复提供契约时为空)", 问题, []) + 顺序 = 扫描.排顺序(记录表, 契约) + 核对("供货排在消费前面", 顺序.index("供货") < 顺序.index("消费"), True) + + +def 测拼命令(根: Path) -> None: + """拼命令: venv 缺要回落 system 并记警告; exec 形态直接给入口; EFI_DB 与配置 env 都要注入.""" + print("\n== 拼命令 ==") + 环境: dict[str, Any] = {"db": {"name": "efi_kernel", "host": "/tmp/socket", "port": 5432, "user": "lou"}} + 目录 = 造驱动(根, "拼命令", {"efi": 1, "name": "拼命令", "runtime": "python", "entry": "跑.py", + "args": ["--x"], "env": {"我的变量": "1"}}) + 驱动 = 扫描.校验单个(目录) + argv, env, 警告表 = 内核.拼命令(驱动, 环境) + 核对("venv 缺 -> 回落 python3", argv[0], "python3") + 核对("argv 是 解释器 + 入口 + args", argv[1:], [str(目录 / "跑.py"), "--x"]) + 核对("有没有记警告", len(警告表) > 0, True) + 核对("注入了 EFI_DB", "host=" in env.get("EFI_DB", ""), True) + 核对("配置里的 env 生效", env.get("我的变量"), "1") + + venv = 目录 / ".venv" / "bin" + venv.mkdir(parents=True, exist_ok=True) + (venv / "python").write_text("#!/bin/sh\n", encoding="utf-8") + argv, _env, 警告表 = 内核.拼命令(驱动, 环境) + 核对("有 venv 就用 venv", argv[0], str(venv / "python")) + 核对("这次没警告", 警告表, []) + + exec驱动 = 扫描.校验单个(造驱动(根, "可执行形态", {"efi": 1, "name": "可执行形态", "runtime": "exec", + "entry": "跑.py"}, 入口可执行=True)) + argv, _env, _警告 = 内核.拼命令(exec驱动, 环境) + 核对("exec 形态直接给入口", argv[0], str(目录.parent / "可执行形态" / "跑.py")) + + +def 测取链() -> None: + """调用链提取 (挡 A->B->A 用).""" + print("\n== 取链 ==") + 核对("有链", 内核.取链({"args": {"chain": ["x", "y"]}}), ["x", "y"]) + 核对("没链", 内核.取链({"args": {}}), []) + 核对("args 不是对象", 内核.取链({"args": "乱写"}), []) + + +def 测复核(根: Path) -> None: + """状态机 / 收尸判定: 真起一个 python 进程去验 (不是纸面推演).""" + print("\n== 状态.复核 (真起进程) ==") + 目录 = 造驱动(根, "活着的", {"efi": 1, "name": "活着的", "runtime": "python", "entry": "睡.py"}, 入口名="睡.py") + (目录 / "睡.py").write_text("import time\ntime.sleep(60)\n", encoding="utf-8") + 驱动 = 扫描.校验单个(目录) + 进程 = subprocess.Popen([sys.executable, str(目录 / "睡.py")]) + + try: + time.sleep(0.4) + 核对("在跑的进程 -> 运行", 状态.复核(驱动, {"state": 状态.运行, "pid": 进程.pid})[0], 状态.运行) + 核对("别人的 pid -> 已退出 (不认领不杀)", + 状态.复核(驱动, {"state": 状态.运行, "pid": os.getpid()})[0], 状态.已退出) + 核对("没在跑的状态维持原样", 状态.复核(驱动, {"state": 状态.停止, "pid": None})[0], 状态.停止) + 核对("oneshot 进程没了算正常退出", + 状态.复核({"mode": "oneshot", "dir": str(目录), "entry": "没这个.py"}, + {"state": 状态.运行, "pid": 999999})[0], 状态.已退出) + 核对("resident 进程没了算崩了", + 状态.复核({"mode": "resident", "dir": str(目录), "entry": "没这个.py"}, + {"state": 状态.运行, "pid": 999999})[0], 状态.崩了) + finally: + 进程.send_signal(signal.SIGKILL) + 进程.wait(timeout=5) + + 核对("被杀的进程 -> 崩了", 状态.复核(驱动, {"state": 状态.运行, "pid": 进程.pid})[0], 状态.崩了) + 核对("没人记录的驱动但真在跑 -> 认领回运行", + 状态.复核(驱动, {"state": 状态.停止, "pid": None})[0], 状态.停止) + + +def 测该拉起() -> None: + """自动重拉的策略: autostart 管断电, restart=on-failure 管自己崩.""" + print("\n== 该不该拉起 ==") + 拉 = 内核.状态.该拉起 + 核对("断电 + autostart", bool(拉({"autostart": True}, {"exit_code": None}, 状态.崩了)), True) + 核对("断电 + 没 autostart", 拉({"autostart": False}, {"exit_code": None}, 状态.崩了), "") + 核对("自己崩 + on-failure", bool(拉({"restart": "on-failure"}, {"exit_code": 3}, 状态.崩了)), True) + 核对("自己崩 + no", 拉({"restart": "no"}, {"exit_code": 3}, 状态.崩了), "") + 核对("失败不自动重试", 拉({"restart": "on-failure"}, {"exit_code": 1}, 状态.失败), "") + 核对("正常退出不拉", 拉({"autostart": True}, {"exit_code": 0}, 状态.已退出), "") + + +def 测待重启() -> None: + """配置改了但还在跑 -> 列表要显示"待重启".""" + print("\n== 待重启 ==") + 核对("改了要重启", 内核.状态.待重启({"config_hash": "b"}, {"state": 状态.运行, "boot_hash": "a"}), True) + 核对("没改不用", 内核.状态.待重启({"config_hash": "a"}, {"state": 状态.运行, "boot_hash": "a"}), False) + 核对("没在跑不算", 内核.状态.待重启({"config_hash": "b"}, {"state": 状态.停止, "boot_hash": "a"}), False) + 核对("从没起过不算", 内核.状态.待重启({"config_hash": "b"}, {"state": 状态.运行, "boot_hash": None}), False) + + +def main() -> int: + """把各组测试跑一遍, 打印汇总.""" + print(f"内核纯逻辑自测 {time.strftime('%Y-%m-%d %H:%M:%S')}") + with tempfile.TemporaryDirectory(prefix="内核自测-") as 临时: + 根 = Path(临时) + 测扫目录(根) + 测校验(根) + 测重名(根) + 测契约(根) + 测拼命令(根) + 测复核(根) + # 取链/该拉起/待重启 是纯字典运算, 不需要工作目录 + 测取链() + 测该拉起() + 测待重启() + print(f"\n合计: 通过 {通过} / 失败 {失败}") + return 0 if 失败 == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/内核/自测日志.py b/内核/自测日志.py new file mode 100644 index 0000000..bfc6e17 --- /dev/null +++ b/内核/自测日志.py @@ -0,0 +1,231 @@ +#!/usr/bin/env python3 +"""日志库真机自测: 真写文件, 真轮转, 真起子进程跟日志 -- 不 mock. + +用法: .venv/bin/python 内核/自测日志.py + +覆盖: 级别门槛 / 行解析与猜级别 / 写(门槛过滤) / 尾读(级别+关键词过滤) / 轮转(真造超限文件) / + 实时跟(真起一个子进程跟在后面, 追加两条看它吐哪条) / 选项解析 / 启动分隔头(真起进程). + +隔离: 所有测试文件都在 tempfile.mkdtemp() 的临时目录里, 跑完整个删掉 -- + 生产日志 (内核/logs/*.log, 驱动/*/logs/*.log) 一个字节都不碰 (开头结尾各拍一次快照核对). +""" + +from __future__ import annotations + +import shutil +import subprocess +import sys +import tempfile +import time +from pathlib import Path + +项目根 = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(项目根 / "内核")) + +import 日志 # noqa: E402 +import 进程 # noqa: E402 + +工作 = Path(tempfile.mkdtemp(prefix="_自测日志_")) +生产日志目录 = 项目根 / "内核" / "logs" +失败 = 0 + + +def 核对(名称: str, 实际: object, 期望: object) -> None: + """一项核对 (照 自测进程.py 的口径: 打实际值, 不对才追加期望值).""" + global 失败 + ok = 实际 == 期望 + if not ok: + 失败 += 1 + print(f"[{'OK ' if ok else 'FAIL'}] {名称}: {实际!r}" + ("" if ok else f" (期望 {期望!r})")) + + +def 快照(目录: Path) -> dict[str, int]: + """目录里 .log 文件的 名字->大小 (核对"自测没碰生产日志").""" + if not 目录.exists(): + return {} + return {项.name: 项.stat().st_size for 项 in sorted(目录.glob("*.log"))} + + +def 造满(路径: Path, 标记: str, 行数: int = 400) -> int: + """造一个"全是标记行"的大文件 (轮转测试用: 之后靠标记认得出这份是哪一轮的).""" + 路径.write_text("".join(f"{标记} 第 {序} 行 填充填充填充填充\n" for 序 in range(行数)), encoding="utf-8") + return 路径.stat().st_size + + +生产快照 = 快照(生产日志目录) +print(f"工作目录: {工作}") +print(f"生产日志快照: {生产快照}") + +print("== 1) 级别与门槛 (纯逻辑) ==") +核对("规范化 info -> INFO", 日志.规范化级别("info"), "INFO") +核对("规范化 认不出来 -> INFO", 日志.规范化级别("瞎写的"), "INFO") +核对("规范化 空 -> INFO", 日志.规范化级别(None), "INFO") +核对("级别表顺序", 日志.级别表, ("DEBUG", "INFO", "WARN", "ERROR")) +核对("WARN 比 INFO 严重", 日志.级别序号("WARN") > 日志.级别序号("INFO"), True) +核对("ERROR 过 WARN 门槛", 日志.达标("ERROR", "WARN"), True) +核对("INFO 过不了 WARN 门槛", 日志.达标("INFO", "WARN"), False) +核对("空门槛全过", 日志.达标("DEBUG", ""), True) + +print("== 2) 行解析与猜级别 ==") +样本 = 日志.格式化("2026-09-16T20:00:00+08:00", "WARN", "内核", "测试 一条") +核对("格式化", 样本, "2026-09-16T20:00:00+08:00 WARN [内核] 测试 一条") +字段 = 日志.解析行(样本) +核对("解析 级别", (字段 or {}).get("级别"), "WARN") +核对("解析 来源", (字段 or {}).get("来源"), "内核") +核对("解析 消息", (字段 or {}).get("消息"), "测试 一条") +核对("解析 时刻", (字段 or {}).get("时刻"), "2026-09-16T20:00:00+08:00") +核对("裸行解析给 None", 日志.解析行("假内核: 心跳"), None) +核对("猜级别 裸行 Traceback", 日志.猜级别("Traceback (most recent call last):"), "ERROR") +核对("猜级别 裸行 [WARN]", 日志.猜级别("[WARN] 快没电了"), "WARN") +核对("猜级别 裸行 普通输出", 日志.猜级别("假内核: 心跳"), "INFO") +核对("猜级别 结构化行用真字段", 日志.猜级别(样本), "WARN") +核对("命中 关键词在", 日志.命中(样本, "", "测试"), True) +核对("命中 级别不够", 日志.命中(样本, "ERROR", ""), False) +核对("命中 关键词不在", 日志.命中(样本, "", "没有这段"), False) + +print("== 3) 记(): 真写文件 + 门槛过滤 ==") +一个 = 工作 / "一个.log" +核对("写 INFO 返回 True", 日志.记(一个, "INFO", "内核", "第一条", 控制台=False), True) +核对("DEBUG 被门槛挡 (返回 False)", 日志.记(一个, "DEBUG", "内核", "被挡掉的", 门槛="INFO"), False) +核对("ERROR 过门槛", 日志.记(一个, "ERROR", "内核", "真错误", 门槛="INFO"), True) +核对("写到子目录也能建", 日志.记(工作 / "子" / "目录.log", "INFO", "内核", "x"), True) +行表 = 日志.读全部(一个) +核对("文件里 2 行 (DEBUG 没进去)", len(行表), 2) +核对("第一行内容", (日志.解析行(行表[0]) or {}).get("消息"), "第一条") +核对("第二行级别", (日志.解析行(行表[1]) or {}).get("来源"), "内核") +核对("门槛空 = 全记", 日志.记(一个, "DEBUG", "内核", "门槛空", 门槛=""), True) +核对("大小文本 5MB", 日志.大小文本(5 * 1024 * 1024), "5.0MB") + +print("== 4) 尾读: 行数 / 级别 / 关键词 ==") +多条 = 工作 / "多条.log" +for 序 in range(12): + 级 = "ERROR" if 序 in (0, 5) else "INFO" + 日志.记(多条, 级, "内核", f"第{序}条 标记{'甲' if 序 % 2 else '乙'}", 门槛="") +核对("尾 5 行", len(日志.尾(多条, 5)), 5) +核对("尾 5 行是最后 5 条", (日志.解析行(日志.尾(多条, 5)[0]) or {}).get("消息"), "第7条 标记甲") +核对("级别 ERROR 只 2 条", len(日志.尾(多条, 100, 级别="ERROR")), 2) +核对("关键词 甲 6 条", len(日志.尾(多条, 100, 关键词="甲")), 6) +核对("级别+关键词 同时筛", len(日志.尾(多条, 100, 级别="ERROR", 关键词="乙")), 1) +核对("尾 0 行给空表", 日志.尾(多条, 0), []) +核对("尾 文件不存在给空表", 日志.尾(工作 / "没有这个.log", 5), []) +核对("行数比文件大 = 全给", len(日志.尾(多条, 999)), 12) + +print("== 5) 轮转 (真造超限文件) ==") +大 = 工作 / "大.log" +尺寸 = 造满(大, "第一批") +核对("不到上限不轮转", 日志.轮转(大, 上限字节=尺寸 * 2, 保留份数=2), False) +核对("超上限就轮转", 日志.轮转(大, 上限字节=尺寸 // 2, 保留份数=2), True) +核对("原文件已改名 (.1)", 日志.带序路径(大, 1).exists(), True) +核对("原文件不在了 (被改名搬走)", 大.exists(), False) +核对(".1 里是第一批", "第一批" in 日志.带序路径(大, 1).read_text(encoding="utf-8"), True) +造满(大, "第二批") +核对("第二轮轮转", 日志.轮转(大, 上限字节=尺寸 // 2, 保留份数=2), True) +造满(大, "第三批") +核对("第三轮轮转", 日志.轮转(大, 上限字节=尺寸 // 2, 保留份数=2), True) +核对("名单 老->新 (第三次轮转后原文件也不在了)", [项.name for 项 in 日志.轮转名单(大)], ["大.log.2", "大.log.1"]) +核对(".2 是第二批 (第三批把第一批挤掉了)", "第二批" in 日志.带序路径(大, 2).read_text(encoding="utf-8"), True) +核对(".1 是第三批", "第三批" in 日志.带序路径(大, 1).read_text(encoding="utf-8"), True) +核对("保留份数=0 = 不轮转", 日志.轮转(大, 上限字节=1, 保留份数=0), False) +核对("上限=0 = 不轮转", 日志.轮转(大, 上限字节=0, 保留份数=3), False) +核对("尾读含历史份 (最早那批已按保留份数挤掉, 剩 .2 + .1)", len(日志.尾(大, 9999)), 800) +核对("尾读不含历史份 -> 原文件不在给 0", len(日志.尾(大, 9999, 含轮转=False)), 0) +造满(大, "第四批") +核对("尾读不带历史份 = 只看当前文件", len(日志.尾(大, 9999, 含轮转=False)), 400) +核对("尾读带历史份 = 当前 + 两份历史", len(日志.尾(大, 9999)), 1200) + +print("== 6) 实时跟 (-f): 真起子进程跟在后面 ==") +跟文件 = 工作 / "跟.log" +跟文件.write_text("开头一条\n", encoding="utf-8") +代码 = ( + "import sys; sys.path.insert(0, " + + repr(str(项目根 / "内核")) + + "); from pathlib import Path; import 日志; sys.exit(日志.跟(Path(" + + repr(str(跟文件)) + + "), 级别='ERROR', 初始行=10))" +) +子 = subprocess.Popen( + [sys.executable, "-c", 代码], + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, +) +time.sleep(1.2) +日志.记(跟文件, "INFO", "内核", "这条不该跟出来", 门槛="") +日志.记(跟文件, "ERROR", "内核", "这条该跟出来", 门槛="") +time.sleep(1.5) +子.terminate() +try: + 跟出, _ = 子.communicate(timeout=5) +except subprocess.TimeoutExpired: + 子.kill() + 跟出, _ = 子.communicate(timeout=5) +核对("跟: 吐出了 ERROR 那条", "这条该跟出来" in 跟出, True) +核对("跟: 过滤掉了 INFO 那条", "这条不该跟出来" in 跟出, False) +核对("跟: 初始的裸行 (猜 INFO) 也被级别挡住", "开头一条" in 跟出, False) +核对("跟: 子进程已收工", 子.poll() is not None, True) + +print("== 7) 选项解析 (内核与引导器共用一份) ==") +选, 问题 = 日志.解析选项(["样板常驻", "-n", "5", "--级别", "warn", "-g", "心跳", "--json"]) +核对("名", 选.名, "样板常驻") +核对("行数", 选.行数, 5) +核对("级别自动大写", 选.级别, "WARN") +核对("关键词", 选.关键词, "心跳") +核对("json", 选.json输出, True) +核对("没问题", 问题, "") +核对("默认行数可传", 日志.解析选项([], 77)[0].行数, 77) +核对("--全部", 日志.解析选项(["--全部"])[0].全部, True) +核对("--内核", 日志.解析选项(["--内核"])[0].内核, True) +核对("--引导器", 日志.解析选项(["--引导器"])[0].引导器, True) +核对("--输出", 日志.解析选项(["--输出"])[0].输出, True) +核对("-f", 日志.解析选项(["-f"])[0].跟随, True) +核对("坏级别报错", 日志.解析选项(["--级别", "瞎写"])[1], "--级别 只认 DEBUG/INFO/WARN/ERROR, 收到 '瞎写'") +核对("不认识的参数报错", 日志.解析选项(["--瞎写"])[1], "不认识的参数: --瞎写") +核对("缺值报错", 日志.解析选项(["-n"])[1], "-n 后面要跟一个数字") +核对("行数写错报错", 日志.解析选项(["-n", "五"])[1], "-n 后面要跟一个数字, 收到 '五'") + +print("== 8) JSON Lines 输出 ==") +多行 = 日志.转json行([样本, "假内核: 心跳"]) +核对("两行 jsonl", len(多行.strip().splitlines()), 2) +核对("结构化行带字段", '"级别": "WARN"' in 多行, True) +核对("裸行给空来源", '"来源": ""' in 多行, True) +核对("裸行级别靠猜", '"级别": "INFO"' in 多行, True) + +print("== 9) 启动分隔头 (真起进程, 走 进程.启动) ==") +假驱动 = 工作 / "说一句.py" +假驱动.write_text("print('我起来了')\n", encoding="utf-8") +分隔日志 = 工作 / "分隔.log" +结果 = 进程.启动( + [sys.executable, str(假驱动)], + cwd=工作, + 日志=分隔日志, + env=None, + 入口=假驱动, + 探活秒=0.6, + 分隔="启动 假驱动 说一句.py", +) +文本 = 分隔日志.read_text(encoding="utf-8") +核对("分隔头写进日志了", "==== 启动 假驱动 说一句.py" in 文本, True) +核对("进程自己的输出也在", "我起来了" in 文本, True) +核对("分隔头在进程输出之前", 文本.index("====") < 文本.index("我起来了"), True) +核对("秒退仍如实报 (这驱动就是跑完就退)", 结果.ok, False) +分隔日志2 = 工作 / "不分隔.log" +_ = 进程.启动([sys.executable, "-c", "print('没分隔')"], cwd=工作, 日志=分隔日志2, 入口=工作 / "x.py") +核对("不传分隔就不写分隔头", "====" in 分隔日志2.read_text(encoding="utf-8"), False) + +print("== 10) 清场与残留终检 ==") +shutil.rmtree(工作, ignore_errors=True) +核对("临时工作目录已删干净", 工作.exists(), False) +核对("生产日志一个字节都没动", 快照(生产日志目录), 生产快照) +剩: list[int] = [] +for 项 in Path("/proc").glob("[0-9]*"): + try: + 命令行 = (项 / "cmdline").read_bytes().decode("utf-8", "replace") + except OSError: + continue # 进程刚没了 / 读不到, 跳过 + if "日志.跟(" in 命令行 and str(工作) in 命令行: + 剩.append(int(项.name)) +核对("没有跟日志的自测残留进程", 剩, []) + +print() +print(f"结论: {'全过' if 失败 == 0 else f'{失败} 项没过'}") +sys.exit(0 if 失败 == 0 else 1) diff --git a/内核/自测进程.py b/内核/自测进程.py new file mode 100644 index 0000000..ca6d337 --- /dev/null +++ b/内核/自测进程.py @@ -0,0 +1,83 @@ +"""进程库真机自测: 真起进程, 真收子树, 不 mock. + +用法: .venv/bin/python 内核/自测进程.py +跑完应该看到: 判活 running / pid 被复用 exited / 停止把父+孙一起收干净 / 组残留为空. +""" + +from __future__ import annotations + +import sys +from pathlib import Path + +项目根 = Path(__file__).resolve().parent.parent +sys.path.insert(0, str(项目根 / "内核")) + +import 进程 # noqa: E402 + +工作 = 项目根 / "内核" / "logs" / "自测进程" +工作.mkdir(parents=True, exist_ok=True) +假驱动 = 工作 / "假驱动.py" +假驱动.write_text( + "import subprocess, sys, time\n" + "subprocess.Popen([sys.executable, '-c', 'import time; time.sleep(600)'])\n" # 孙子进程, 测收子树 + "while True:\n" + " time.sleep(1)\n", + encoding="utf-8", +) +日志 = 工作 / "假驱动.log" +失败 = 0 + + +def 核对(名称: str, 实际: object, 期望: object) -> None: + global 失败 + ok = 实际 == 期望 + if not ok: + 失败 += 1 + print(f"[{'OK ' if ok else 'FAIL'}] {名称}: {实际!r}" + ("" if ok else f" (期望 {期望!r})")) + + +print("== 1) 启动 (独立进程组) ==") +启动 = 进程.启动([sys.executable, str(假驱动)], cwd=工作, 日志=日志, 入口=假驱动) +print(" ", 启动) +核对("启动 ok", 启动.ok, True) +父 = 启动.pid +if 父 is None: + print("[FAIL] 启动没拿到 pid, 后面的核对没法做") + sys.exit(1) +信息 = 进程.读进程信息(父) + +print("== 2) 判活: cmdline 匹配 ==") +核对("running", 进程.判活(父, 假驱动), 进程.运行中) +print("== 3) 判活: 喂错入口 -> pid 被复用 (不认领不杀) ==") +核对("exited", 进程.判活(父, 工作 / "别的.py"), 进程.被复用) +print("== 4) 按入口找进程 (扫 /proc, 不用 ps) ==") +核对("找到自己", 进程.按入口找进程(假驱动), [父]) + +if 信息 is not None: + print("== 5) /proc 直读 ==", f"pgid={信息.pgid}", 信息.内存文本, 信息.存活文本, f"cpu {信息.cpu秒:.2f}s") + print("== 6) 进程组 (父+孙) ==", 进程.进程组成员(信息.pgid)) + 核对("组里有 2 个", len(进程.进程组成员(信息.pgid)), 2) + print("== 7) 停止: SIGTERM 给进程组, 连子树一起收 ==") + 停止 = 进程.停止(父, 入口=假驱动, 超时=5) + print(" ", 停止) + 核对("停止 ok", 停止.ok, True) + print("== 8) 复查 ==", 进程.判活(父, 假驱动), "组残留:", 进程.进程组成员(信息.pgid)) + 核对("组残留为空", 进程.进程组成员(信息.pgid), []) + print("== 9) 幂等再停 ==") + 核对("幂等 ok", 进程.停止(父, 入口=假驱动).ok, True) + +print("== 10) 秒退判定 (退出码 0) ==") +秒退 = 进程.启动([sys.executable, "-c", "print('hi')"], cwd=工作, 日志=日志, 入口=工作 / "x.py") +print(" ", 秒退.detail) +核对("秒退被判失败", 秒退.ok, False) +print("== 11) 秒退判定 (退出码 3): 日志尾巴只算这一次的输出 ==") +坏 = 进程.启动([sys.executable, "-c", "raise SystemExit(3)"], cwd=工作, 日志=日志, 入口=工作 / "x.py") +print(" ", 坏.detail) +核对("退出码 3", 坏.exit_code, 3) +核对("不带上次的旧输出", "hi" in 坏.detail, False) +print("== 12) 跑命令 ==") +核对("跑命令输出", 进程.跑命令([sys.executable, "-c", "print('ok')"]).lines, ["ok"]) + +print() +print(f"结论: {'全过' if 失败 == 0 else f'{失败} 项没过'}") +sys.exit(0 if 失败 == 0 else 1) diff --git a/内核/自测配置.py b/内核/自测配置.py new file mode 100644 index 0000000..af66dc6 --- /dev/null +++ b/内核/自测配置.py @@ -0,0 +1,99 @@ +"""引导器的配置解析自测: 脏配置不许把引导器搞崩. + +用法: python3 内核/自测配置.py + +覆盖: + * 读包要求 / db段 / _解析包表 对脏数据 (字符串当数组, 数字当对象, 缺键, 坏 json) 的容错 + * 取对象 / 取清单 两个 json 取值助手 + * 版本比较的边界 (相等, 短版本补零, 空要求) +跑完 0 退出, 有 FAIL 退出 1. +""" + +from __future__ import annotations + +import importlib.util +import sys +from pathlib import Path +from types import ModuleType +from typing import Any + +项目根 = Path(__file__).resolve().parent.parent +if not (项目根 / "UEFI.boot.py").exists(): + 项目根 = Path(__file__).resolve().parent + +失败数 = 0 + + +def 载入引导器() -> Any: + """动态加载 UEFI.boot.py (文件名带点, 没法当模块名 import). + + 坑: exec_module 之前必须把模块塞进 sys.modules, 否则 py3.14 的 dataclass 找不到 + cls.__module__ 对应的模块, 直接 AttributeError ('NoneType' has no attribute '__dict__'). + 返回类型写 Any: 动态加载出来的模块成员对类型检查器是未知的, 当成 Any 用最省事. + """ + spec = importlib.util.spec_from_file_location("uefi_boot", str(项目根 / "UEFI.boot.py")) + if spec is None or spec.loader is None: + print("[FAIL] 加载 UEFI.boot.py 失败") + sys.exit(1) + 模块: ModuleType = importlib.util.module_from_spec(spec) + sys.modules["uefi_boot"] = 模块 + spec.loader.exec_module(模块) + return 模块 + + +def 核对(名称: str, 实际: object, 期望: object) -> None: + global 失败数 + 好 = 实际 == 期望 + if not 好: + 失败数 += 1 + print(f"[{'OK ' if 好 else 'FAIL'}] {名称}: {实际!r}" + ("" if 好 else f" (期望 {期望!r})")) + + +引导 = 载入引导器() + +脏包 = { + "packages": [ + {"name": "a", "version": ">=1", "required": True}, + "脏项", + 3, + {"name": ""}, + {"name": "b"}, + ] +} +核对( + "读包要求: 脏项混合只留两条有名字的", + [(条.名, 条.要求, 条.必需) for 条 in 引导.读包要求(脏包)], + [("a", ">=1", True), ("b", "", False)], +) +核对("读包要求: packages 是字符串", 引导.读包要求({"packages": "不是数组"}), []) +核对("读包要求: 没有 packages 键", 引导.读包要求({}), []) +核对("读包要求: 项是数字", 引导.读包要求({"packages": [7]}), []) + +核对("db段: 正常", 引导.db段({"db": {"name": "x"}}), {"name": "x"}) +核对("db段: 缺键", 引导.db段({}), {}) +核对("db段: 是数字", 引导.db段({"db": 5}), {}) +核对("db段: 是 None", 引导.db段({"db": None}), {}) + +核对("_解析包表: 空数组", 引导._解析包表("[]"), {}) +核对("_解析包表: 坏 json", 引导._解析包表("坏 json"), None) +核对( + "_解析包表: 包名要归一化 (PEP 503)", + 引导._解析包表('[{"name": "P_Sql.Thing", "version": "1.2"}]'), + {"p-sql-thing": "1.2"}, +) + +核对("取对象: None", 引导.取对象(None), {}) +核对("取对象: 数组", 引导.取对象([1]), {}) +核对("取清单: 字符串", 引导.取清单("x"), []) +核对("取清单: 数组", 引导.取清单([1, 2]), [1, 2]) + +核对("缺 db 段时库名兜底", 引导.db.从配置(引导.db段({})).name, "efi_kernel") +核对("版本比较: 相等算满足", 引导.满足要求("2.9.9", ">=2.9.9"), True) +核对("版本比较: 短版本补零", 引导.满足要求("2.9", ">=2.9.0"), True) +核对("版本比较: 差一位不算满足", 引导.满足要求("2.9.8", ">=2.9.9"), False) +核对("版本比较: 空要求 = 不校验", 引导.满足要求("", ""), True) +核对("版本比较: 没装就是没装", 引导.满足要求("", ">=1.0"), False) + +print() +print(f"结论: {'全过' if 失败数 == 0 else f'{失败数} 项没过'}") +sys.exit(0 if 失败数 == 0 else 1) diff --git a/内核/进程.py b/内核/进程.py new file mode 100644 index 0000000..ed1d918 --- /dev/null +++ b/内核/进程.py @@ -0,0 +1,799 @@ +"""通用进程库: 启动 / 停止 / 判活 / 日志. + +[谁用它] + 内核用它管**驱动**进程, 引导器用它管**内核**进程 -- 只此一份实现, 绝不复制 + (设计 02 §7 原话: "只此一份实现, 不复制").两套实现 = 各有一套坑, 还各自以为对方对. + +[为什么全走 /proc, 不用 ps] + * busybox 的 ps 会截断命令行 (软路由上踩过), 长 cmdline 根本看不全 + * `ps | grep` 会把**你自己的排查命令**也算进去, 得到假计数 (踩过好几次) + * /proc//cmdline 是 NUL 分隔的 argv 原文, 精确点名, 不用二次解析 + +[四条铁律 (都是踩过的坑, 改这个文件前先读一遍)] + 1. 发信号前必须校验 cmdline -- pid 会被系统复用, 裸 kill 可能杀到别人的进程 + (老板的 GUI 程序就这么被误杀过一次, 被点名批评) + 2. start_new_session=True 起独立进程组 -- 停止时能连子树一起收 (杀父不等于杀子树: + python 死了, 它拉起的子进程会变孤儿继续跑,继续写同一个文件) + 3. 启动与停止分两条命令, 一次只起一份 -- 同一个 shell 里连着做会留孤儿互抢端口 + 4. 排查动作本身会破坏现场: 反复起/杀会攒出一堆孤儿, 让人误判成"程序不稳定" + (曾经攒出 13 份隧道孤儿互抢端口).所以本库只做"一次一件事", 不自动重试. + +[依赖] + 纯 stdlib.内核和引导器都直接 import 这个文件. +""" + +from __future__ import annotations + +import os +import signal +import subprocess +import sys +import time +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path + +# ─────────────────────── 判活结果 (判活() 的返回值, 也是 driver_state.state 的词汇表) ─────────────────────── +# 五个值各有明确含义, 调用方按它决定"认领 / 收尸 / 不认领": +运行中: str = "running" # /proc/ 在, 且 cmdline 校验通过 -> 是我们那个进程, 认领 +已停止: str = "stopped" # 没有 pid 记录, 或 /proc/ 根本不在了 -> 正常态, 幂等 +已崩: str = "crashed" # PG 里记着 pid 但进程没了 (断电 / 被杀) -> 该收尸, 按 restart 策略决定要不要拉 +被复用: str = "exited" # pid 还在, 但 cmdline 不是我们的 (系统把 pid 分给别人了) -> 不认领, 更不许杀 +僵尸: str = "zombie" # 进程其实已死, 但父进程没 wait 回收 (Z 态) -> 杀不动, 只能等父进程收 + +# /proc//stat 里的 utime / stime / starttime 单位是"节拍"(jiffies), 除以它才是秒 +时钟频率: int = os.sysconf("SC_CLK_TCK") +# /proc//statm 的单位是"页", 乘它才是字节 (拿来做 RSS) +页大小: int = os.sysconf("SC_PAGE_SIZE") + + +@dataclass +class 进程信息: + """从 /proc 直读出来的一份进程快照 (读不到就返回 None, 不抛异常). + + 字段全部来自 /proc//{stat,statm,cmdline}, 不来自 PG 里的旧 pid 记录. + """ + + pid: int # 进程号 + ppid: int # 父进程号 (看进程归属) + pgid: int # 进程组号 (收子树的关键: killpg(-pgid)) + state: str # 单字母状态: R 运行 / S 睡眠 / D 不可中断 / Z 僵尸 / T 停止 + cmdline: list[str] # argv 原文 (NUL 分隔切好的); 空表 = 读不到 (权限 / 内核线程 / 僵尸) + rss_kb: int # 常驻内存, KB (来自 statm 的第 2 列 * 页大小) + cpu秒: float # 用户态 + 内核态 CPU 时间, 秒 (utime + stime) + 启动时刻: float # unix 时间戳 (由 开机秒数 - starttime 反推) + 存活秒: float # 已经跑了多久 (秒) + + @property + def cmdline文本(self) -> str: + """空格拼起来的命令行, 给人看 / 写日志用.""" + return " ".join(self.cmdline) + + @property + def 内存文本(self) -> str: + """RSS 的人读格式 (KB / MB / GB).""" + if self.rss_kb >= 1024 * 1024: + return f"{self.rss_kb / 1024 / 1024:.2f} GB" + if self.rss_kb >= 1024: + return f"{self.rss_kb / 1024:.1f} MB" + return f"{self.rss_kb} KB" + + @property + def 存活文本(self) -> str: + """存活时长的人读格式 (HH:MM:SS).""" + return 时长文本(self.存活秒) + + +@dataclass +class 启动结果: + """启动() 的返回.秒退也算返回 (不是异常), 由调用方决定怎么记账. + + 字段: + ok: 起成功了 (探活期内没退). + pid: 子进程 pid; spawn 都失败时为 None. + pgid: 子进程的进程组号 (独立会话所以通常 == pid); 没活下来时 None. + exit_code: 秒退时的退出码; 没退则 None. + seconds: 从 spawn 到判定的秒数. + detail: 人读的一句话结论 (给 PG 的 last_error / detail 用, 带原文不吞错). + """ + + ok: bool + pid: int | None + pgid: int | None + exit_code: int | None + seconds: float + detail: str + + +@dataclass +class 停止结果: + """停止() 的返回. + + 字段: + ok: 结果干净 (进程本来就不在 / SIGTERM 收工 / SIGKILL 后收干净). + forced: 是否升过级 (用过 SIGKILL). + seconds: 总共等了多久. + detail: 人读结论; pid 被复用时说明"不发信号"的原因. + """ + + ok: bool + forced: bool + seconds: float + detail: str + + +@dataclass +class 命令结果: + """跑命令() / 跑并转发() 的返回 (给 uv / pip / psql / 探版本这类短命令用). + + 字段: + code: 退出码 (超时 124, 跑不起来 127, 跟 shell 惯例一致). + seconds: 耗时. + lines: 输出行 (已去掉空行; 跑并转发 时同时转发到了 stdout). + detail: 出错时的一句话原因 (超时才填). + """ + + code: int + seconds: float + lines: list[str] + detail: str = "" + + +# ─────────────────────────────── 底层读 /proc ─────────────────────────────── + + +def 时长文本(秒: float) -> str: + """秒 -> HH:MM:SS.负数按 0 处理 (时钟抖动时别出现 -00:00:01).""" + 整 = int(max(秒, 0.0)) + 时 = 整 // 3600 + 分 = (整 % 3600) // 60 + 剩 = 整 % 60 + return f"{时:02d}:{分:02d}:{剩:02d}" + + +def 读cmdline(pid: int) -> list[str]: + """读 /proc//cmdline: argv 的原文, 元素之间是 NUL 字节 (\0). + + 参数: + pid: 进程号. + 返回: + argv 列表; 进程不在 / 读不到 (权限,内核线程,僵尸) 时返回空表. + + 说明: + 空表**不等于**"进程不在": 僵尸进程的 cmdline 就是空的. + 所以要判在不在, 用 判活() / Path("/proc/").exists(), 别只看这个. + """ + try: + 原始 = Path(f"/proc/{pid}/cmdline").read_bytes() + except OSError: + return [] + return [段.decode("utf-8", "replace") for 段 in 原始.split(b"\0") if 段] + + +def 读stat(pid: int) -> list[str] | None: + """读 /proc//stat, 返回**去掉 pid 和 comm 之后**的字段列表. + + 参数: + pid: 进程号. + 返回: + 字段列表, 下标 0 == stat 的第 3 个字段 (state); 读不到返回 None. + + 为什么这样切: + stat 的第 2 个字段 comm 是括号包着的进程名, **里面可以带空格和右括号** + (如 "(my prog),v2)"), 用 split() 切必错.唯一可靠的切法是取**最后一个**右括号, + 后面的部分再 split -- 所以下标要整体减 2 (下标 i 对应 stat 的第 i+3 个字段). + + 常用下标 (下标 = 字段号 - 3): + [0] state 进程状态 (R/S/D/Z/T) + [1] ppid 父进程 + [2] pgrp 进程组 (收子树用) + [11] utime 用户态 jiffies + [12] stime 内核态 jiffies + [19] starttime 进程启动时刻 (相对开机, jiffies) + """ + try: + 原文 = Path(f"/proc/{pid}/stat").read_text() + except OSError: + return None + 右括号 = 原文.rfind(")") + if 右括号 < 0: + return None + return 原文[右括号 + 1 :].split() + + +def 读开机秒() -> float: + """系统开机到现在的秒数 (/proc/uptime 的第 1 列).读不到返回 0.""" + try: + return float(Path("/proc/uptime").read_text().split()[0]) + except (OSError, ValueError, IndexError): + return 0.0 + + +def 读进程信息(pid: int) -> 进程信息 | None: + """把 /proc/ 下几份文件拼成一份快照. + + 参数: + pid: 进程号. + 返回: + 进程信息; 进程不在 / 字段残缺 / 权限不够时返回 None (不抛). + + 启动时刻怎么算的: + stat 只给"相对开机时刻"的 starttime (jiffies), 换算: + 存活秒 = 开机秒数 - starttime / 时钟频率 + 启动时刻(unix) = 当前时间 - 存活秒 + uptime 和某进程的 starttime 是两个不同时刻读的, 会有毫秒级误差, 够用. + """ + if pid <= 0: + return None + 字段 = 读stat(pid) + if 字段 is None or len(字段) < 20: + return None + try: + ppid = int(字段[1]) + pgid = int(字段[2]) + utime = int(字段[11]) + stime = int(字段[12]) + 启动tick = int(字段[19]) + except (ValueError, IndexError): + return None + rss_kb = 0 + try: + # statm 第 1 列是虚拟内存页数, 第 2 列才是常驻 (RSS) 页数 + 页 = Path(f"/proc/{pid}/statm").read_text().split() + rss_kb = int(页[1]) * 页大小 // 1024 + except (OSError, ValueError, IndexError): + rss_kb = 0 + 存活秒 = 读开机秒() - 启动tick / 时钟频率 + 存活秒 = max(存活秒, 0.0) + 启动时刻 = time.time() - 存活秒 + return 进程信息( + pid=pid, + ppid=ppid, + pgid=pgid, + state=字段[0], + cmdline=读cmdline(pid), + rss_kb=rss_kb, + cpu秒=(utime + stime) / 时钟频率, + 启动时刻=启动时刻, + 存活秒=存活秒, + ) + + +# ─────────────────────────────── 判活 / 认领 ─────────────────────────────── + + +def 规范化(路径: str | Path) -> str: + """转成 realpath 绝对路径, 专门用于"这条 cmdline 指的是不是那个文件"的精确比对. + + 为什么要 realpath: + 启动时可能给的是相对路径,带软链的路径,带 ../ 的路径; 而 /proc 里记的是当时的写法. + 两边都 realpath 到真实文件再比, 才判得准. + """ + return os.path.realpath(os.fspath(路径)) + + +def 匹配入口(pid: int, 入口: Path) -> bool: + """argv 里有没有哪个元素 realpath 后正好等于这个入口文件. + + 参数: + pid: 要校验的进程. + 入口: 入口文件的绝对路径 (如 内核/内核.py,驱动/<名>/json解码.py). + 返回: + 命中 True / 不命中 False. + + 为什么不写成子串匹配: + 子串匹配会把"我的排查命令里提到了这个文件"也算命中 (踩过: 宽松匹配 `*aria2c*x*` + 连自己那条 ssh 一起匹配上, 真把自己 shell 杀了).这里只认 realpath 完全相等. + + 局限: + 只认"argv 里直接出现入口文件"这一种写法.`python -m 包` 这类入口 (argv 里没有文件路径) + 匹配不上 -- 本项目不用这种写法, 驱动/内核都是 `解释器 + 入口文件` 的形态. + """ + 目标 = 规范化(入口) + for 段 in 读cmdline(pid): + if 规范化(段) == 目标: + return True + return False + + +def 判活(pid: int | None, 入口: Path | None = None) -> str: + """判一个进程现在是什么状态 (本库最核心的判定). + + 参数: + pid: 要判的进程号; None / <=0 直接当"没在跑". + 入口: 给了就做 cmdline 校验 (防 pid 复用); 不给只判在不在. + 返回: + 五个状态之一 (见文件头的常量注释): 运行中 / 已停止 / 已崩 / 被复用 / 僵尸. + + 判定顺序 (顺序本身有讲究): + 1. 没 pid -> 已停止 + 2. /proc/ 不存在 -> 已崩 (我们以为它在跑, 其实没了) + 3. state == Z -> 僵尸 (已经死了, 只是父进程还没回收; 如实报, 不谎报运行中) + 4. 给了入口但 cmdline 对不上 -> 被复用 (pid 被系统分给了别人) + 5. 其余 -> 运行中 + 第 4 步放最后: pid 复用概率低但后果严重 (误杀别人), 前面几步都是便宜判断. + """ + if pid is None or pid <= 0: + return 已停止 + if not Path(f"/proc/{pid}").exists(): + return 已崩 + 字段 = 读stat(pid) + if 字段 is not None and 字段[0] == "Z": + return 僵尸 + if 入口 is not None and not 匹配入口(pid, 入口): + return 被复用 + return 运行中 + + +def 按入口找进程(入口: Path) -> list[int]: + """扫 /proc 所有进程, 把 cmdline 里有这个入口文件的 pid 全找出来. + + 参数: + 入口: 入口文件绝对路径. + 返回: + 升序 pid 列表; 没有则空表. + + 为什么不用 ps / pgrep: + 见文件头.这里逐 pid 读 /proc//cmdline, 顺便跳过自己 (否则"查找命令自己" + 可能被算成一个匹配项, 得到假计数). + + 用途: + 引导器拿它找内核进程 (不依赖 PG, PG 挂了也能找); 内核拿它找驱动进程. + """ + 出: list[int] = [] + 自己 = os.getpid() + for 项 in Path("/proc").iterdir(): + if not 项.name.isdigit(): + continue + pid = int(项.name) + if pid == 自己: + continue + if 匹配入口(pid, 入口): + 出.append(pid) + 出.sort() + return 出 + + +def 进程组成员(pgid: int) -> list[int]: + """列出某个进程组里**还活着**的成员 pid (收残用). + + 参数: + pgid: 进程组号 (一般来自 进程信息.pgid). + 返回: + 升序 pid 列表, 不含自己, **不含僵尸**. + + 为什么跳过僵尸: + 僵尸是"已死但没被父进程 wait 回收"的空壳, SIGKILL 也收不掉. + 如果算进来, 停止就会永远报"还有 N 个没收掉", 让人以为没停干净 (踩过). + + 局限: + 进程组号会被复用.这个函数只在"刚对自己确认过的进程组发完信号"这个窗口里用, + 不要拿去当长期的身份判据. + """ + 出: list[int] = [] + 自己 = os.getpid() + for 项 in Path("/proc").iterdir(): + if not 项.name.isdigit(): + continue + pid = int(项.name) + if pid == 自己: + continue + 字段 = 读stat(pid) + if 字段 is None or len(字段) < 3: + continue + if 字段[0] == "Z": + continue + try: + if int(字段[2]) == pgid: + 出.append(pid) + except ValueError: + continue + 出.sort() + return 出 + + +# ─────────────────────────────── 日志读取 ─────────────────────────────── +# 注意分工 (2026-09-16 日志系统落地后): +# 内核 / 引导器 里的 "日志 <谁> 看几行 / -f 跟 / 按级别关键词筛 / 认轮转历史份" 都走 +# 内核/日志.py (尾/跟) -- 那边带过滤与轮转感知, 是日志系统的实现 (设计 04). +# 本节这两个函数仍是**进程库自己的通用能力**: 从位置读日志 是"秒退只读本次输出"的判据 +# (启动() 在用, 别动), 尾日志 / 跟日志 保留给进程层的诊断 (自测进程.py 用 从位置读日志). + + +def 从位置读日志(路径: Path, 起始: int, 行数: int = 200) -> list[str]: + """读日志文件里"第 起始 字节之后"的内容. + + 参数: + 路径: 日志文件. + 起始: 字节偏移 (启动前记下的文件大小). + 行数: 最多返回末尾几行; <=0 表示不限. + 返回: + 行列表 (读不到返回空表). + + 为什么需要它: + 日志是**追加**写的 (append).进程秒退时要回答"它临死说了什么", 如果直接读整个文件, + 会把上一次运行的输出也当成本次死因 (踩过: 退出码 3 的进程, 死因栏写着上一次的 "hi"). + 所以启动前记下文件大小, 秒退时只读新增的那一段. + """ + try: + with open(路径, "rb") as 句柄: + 句柄.seek(max(起始, 0)) + 数据 = 句柄.read() + except OSError: + return [] + 行 = 数据.decode("utf-8", "replace").splitlines() + if 行数 <= 0: + return 行 + return 行[-行数:] + + +def 尾日志(路径: Path, 行数: int = 200) -> list[str]: + """读日志末尾 N 行 (等价 tail -n). + + 参数: + 路径: 日志文件. + 行数: 要几行; <=0 返回空表. + 返回: + 行列表; 文件不存在/读不到 返回空表 (不抛). + + 说明: + 小文件直接整读再切片 (本项目日志没有几十 MB 的, 不做 seek 倒读那套复杂度). + """ + if 行数 <= 0: + return [] + try: + 原文 = 路径.read_text(errors="replace") + except OSError: + return [] + return 原文.splitlines()[-行数:] + + +def 跟日志(路径: Path) -> int: + """`-f` 用的实时跟日志: 先吐末尾 10 行, 然后一直跟着新内容打印. + + 参数: + 路径: 日志文件 (可以先不存在, 等它被创建). + 返回: + 0 (Ctrl-C 中断也返回 0, 不当失败). + + 实现方式: + 轮询文件大小 + 从上次位置读新增字节, 每轮睡 0.3s. + 不用 subprocess 起 `tail -f`: 少一个外部依赖, 也好控制"文件被轮转/清空"的情况. + 文件被清空/轮转: + 大小比上次位置小 -> 位置归 0, 从头读 (否则会一直卡在旧偏移上什么都不出). + """ + for 行 in 尾日志(路径, 10): + print(行, flush=True) + try: + 位置 = 路径.stat().st_size + except OSError: + 位置 = 0 + try: + while True: + try: + 大小 = 路径.stat().st_size + except OSError: + 大小 = 位置 + if 大小 < 位置: + 位置 = 0 + if 大小 > 位置: + try: + with open(路径, "rb") as 句柄: + 句柄.seek(位置) + 数据 = 句柄.read() + 位置 = 句柄.tell() + except OSError: + 数据 = b"" + if 数据: + sys.stdout.write(数据.decode("utf-8", "replace")) + sys.stdout.flush() + else: + time.sleep(0.3) + except KeyboardInterrupt: + return 0 + return 0 + + +# ─────────────────────────────── 启动 ─────────────────────────────── + + +def 启动( + argv: list[str], + cwd: Path, + 日志: Path, + env: dict[str, str] | None = None, + 入口: Path | None = None, + 探活秒: float = 0.6, + 分隔: str = "", +) -> 启动结果: + """拉起一个进程: 独立进程组, stdout/stderr 追加进日志文件, 探活一段时间再判定. + + 参数: + argv: 完整命令行 (第一个元素是解释器或可执行文件), **不经过 shell**. + cwd: 工作目录 (驱动就起在驱动文件夹里; 内核起在项目根). + env: 完整环境变量字典; None = 继承当前进程的. + 日志: stdout/stderr 追加到这个文件 (文件不存在会被创建, 父目录会自动建). + 入口: 入口文件绝对路径, 用于探活后做 cmdline 校验 (确认起的是我们要的那个). + 探活秒: spawn 后等多久再 poll (默认 0.6s -- 够 "入口不存在/解释器缺" 这类错暴露出来). + 分隔: 非空时先在日志里写一条分隔头 (如 "启动 样板常驻 <命令>") -- 日志是追加的, + 多轮启动的输出连成一片就分不清"这句是哪一次说的"; 分隔头把它切开, + 也顺手给人一个 tail 定位锚点. + + 返回: + 启动结果 (见其注释).**秒退不抛异常**, 而是 ok=False + exit_code + 本次的日志尾巴, + 让调用方自己决定怎么记账. + + 关键决定: + start_new_session=True -> 子进程自己开一个会话/进程组, 停止时能 killpg 连子树一起收 + stdin=DEVNULL -> 别让子进程去抢终端输入 (后台跑着还要读键盘会挂住) + stdout/stderr 同一个文件 -> 驱动/内核的原始输出都留着, 出问题时有据可查 + 先记 起始字节偏移 -> 秒退时只读"这一次"的新增输出 (别冤枉上一次) + """ + 起点 = time.monotonic() + if not argv: + return 启动结果(False, None, None, None, 0.0, "argv 为空") + try: + 日志.parent.mkdir(parents=True, exist_ok=True) + 句柄 = open(日志, "ab") + except OSError as 错: + return 启动结果(False, None, None, None, time.monotonic() - 起点, f"日志打不开: {错}") + if 分隔.strip(): + try: + 句柄.write(f"\n==== {分隔} {datetime.now().astimezone().isoformat(timespec='seconds')} ====\n".encode("utf-8")) + 句柄.flush() + except OSError: + pass + try: + 起始 = 日志.stat().st_size # 分隔头之后取, 秒退死因才只读"这一次"的输出 + except OSError: + 起始 = 0 + try: + 进程 = subprocess.Popen( # noqa: S603 (argv 由本程序拼, 不经 shell) + argv, + cwd=str(cwd), + stdin=subprocess.DEVNULL, + stdout=句柄, + stderr=subprocess.STDOUT, + start_new_session=True, + env=env, + ) + except (OSError, ValueError) as 错: + 句柄.close() + return 启动结果(False, None, None, None, time.monotonic() - 起点, f"spawn 失败: {错}") + 句柄.close() # 子进程已经持有 fd, 这边及时关掉 (否则孤儿进程会把日志句柄一直拽着) + time.sleep(max(探活秒, 0.0)) + 码 = 进程.poll() + 秒 = time.monotonic() - 起点 + if 码 is not None: + 尾部 = " | ".join(从位置读日志(日志, 起始, 5)) + if 码 == 0: + 说明 = f"启动后 {秒:.1f}s 就退出 (退出码 0) -- 不像常驻进程" + else: + 说明 = f"秒退, 退出码 {码}" + if 尾部: + 说明 += f", 日志末尾: {尾部}" + return 启动结果(False, 进程.pid, None, 码, 秒, 说明) + 信息 = 读进程信息(进程.pid) + if 信息 is None: + return 启动结果(False, 进程.pid, None, None, 秒, "读不到 /proc, 状态未知") + 校验 = "" + if 入口 is not None and not 匹配入口(信息.pid, 入口): + 校验 = " (cmdline 里没看到入口文件 -- 起错了?)" + return 启动结果( + True, 信息.pid, 信息.pgid, None, 秒, f"已拉起 pid={信息.pid} pgid={信息.pgid}{校验}" + ) + + +# ─────────────────────────────── 停止 ─────────────────────────────── + + +def _没了(pid: int) -> bool: + """进程是不是"死透了"(/proc 不在, 或已成僵尸). + + 僵尸死透了等回收, 再发信号也没意义 -- 所以"停干净"的判据是 _没了() 为真, 不是"kill 成功了". + """ + if not Path(f"/proc/{pid}").exists(): + return True + 字段 = 读stat(pid) + return 字段 is not None and 字段[0] == "Z" + + +def _等死(pid: int, 超时: float) -> list[int]: + """等 pid 死透, 最多等 超时 秒. + + 参数: + pid: 目标进程. + 超时: 最多等多少秒. + 返回: + 死透了 -> 空表. + 超时了 -> 它所在进程组里**还活着**的 pid 列表 (收残目标; 不含自己, 不含僵尸). + """ + 结束 = time.monotonic() + max(超时, 0.0) + while True: + if _没了(pid): + return [] + if time.monotonic() >= 结束: + break + time.sleep(0.1) + 信息 = 读进程信息(pid) + 组 = 信息.pgid if 信息 is not None else pid + return 进程组成员(组) + + +def 停止( + pid: int, + 入口: Path | None = None, + 超时: float = 10.0, +) -> 停止结果: + """停一个进程 (连同它的子树), 全过程按"宁可不杀, 不可误杀"来. + + 参数: + pid: 目标进程号. + 入口: 给了就先做 cmdline 校验 -- 对不上就判"pid 被复用", **一个信号都不发**. + 超时: SIGTERM 之后等多久再升级 SIGKILL, 默认 10s (设计文档的 stop_timeout). + + 返回: + 停止结果 (见其注释). + + 流程: + 1. 判活: 已经不在 (已停止/已崩/僵尸) -> 直接当成功 (幂等, 重复停不报错); + 被复用 -> 不杀, 返回 ok=False + 原因; + 2. SIGTERM 发给**进程组** killpg(-pgid) -- 连子树一起收 (杀父不等于杀子树); + 3. 等 超时 秒; + 4. 还活着 -> SIGKILL 给进程组, 再按 pid 逐个收残 (只补刀, 不猜); + 5. 复查, 收干净才算 ok. + + 为什么先 SIGTERM 不直接 SIGKILL: + 给进程一个收尾的机会 (刷缓冲区,写最后一条 events,删自己的 pid 文件). + 只有赖着不走的才升级. + """ + 起点 = time.monotonic() + 状态 = 判活(pid, 入口) + if 状态 in (已停止, 已崩, 僵尸): + return 停止结果(True, False, 0.0, "进程本来就不在 (幂等)") + if 状态 == 被复用: + return 停止结果(False, False, 0.0, f"pid {pid} 的 cmdline 不是我们的 (pid 被复用), 不发信号") + 信息 = 读进程信息(pid) + 组 = 信息.pgid if 信息 is not None else pid + # ── 自杀防线 (2026-09-16 真踩过) ── + # 目标如果跟我们在**同一个进程组** (典型场景: 引导器用 subprocess 前台起的常驻内核, + # 它的进程组是从调用方继承来的), 对它 killpg 就等于给自己也来一下 -- + # 当时 `试跑引导器.py` 里的 `内核 停止` 把整条进程组收了, 脚本自己 exit -15. + # 同组时只发**单进程**信号: 目标的子树归它自己收尾 (驱动都是独立会话, 不会漏). + 要打组 = 组 != os.getpgrp() + + def 发信号(号: int) -> None: + """平时打整个进程组 (连子树一起收); 同组时只打目标自己 (免得把自己也收了).""" + if 要打组: + os.killpg(组, 号) + else: + os.kill(pid, 号) + + try: + 发信号(signal.SIGTERM) + except ProcessLookupError: + return 停止结果(True, False, time.monotonic() - 起点, "发信号前进程已退出") + except PermissionError as 错: + return 停止结果(False, False, time.monotonic() - 起点, f"没权限发信号: {错}") + 剩 = _等死(pid, 超时) + if not 剩: + 哪打的 = f"进程组 {组}" if 要打组 else f"单进程 {pid} (跟我同组, 没打组)" + return 停止结果(True, False, time.monotonic() - 起点, f"SIGTERM 收工 ({哪打的})") + 强制: list[int] = [] + if 要打组: + try: + os.killpg(组, signal.SIGKILL) + 强制.append(组) + except (ProcessLookupError, PermissionError): + pass + for 目标 in 剩: + if 目标 in (os.getpid(), os.getpgrp()): + continue + try: + os.kill(目标, signal.SIGKILL) + 强制.append(目标) + except (ProcessLookupError, PermissionError): + continue + 剩 = _等死(pid, 3.0) + if 剩: + return 停止结果( + False, + True, + time.monotonic() - 起点, + f"SIGKILL 之后还有 {len(剩)} 个没收掉: {剩}", + ) + return 停止结果(True, True, time.monotonic() - 起点, f"SIGTERM 超时 -> SIGKILL 收干净 (进程组 {组})") + + +# ─────────────────────────────── 跑命令 ─────────────────────────────── + + +def 跑命令( + argv: list[str], + cwd: Path | None = None, + 超时: float = 60.0, + env: dict[str, str] | None = None, +) -> 命令结果: + """跑一条短命令, 收输出, 不转发给用户 (uv pip list / psql / 问解释器版本这类). + + 参数: + argv: 命令行. + cwd: 工作目录; None = 当前目录. + 超时: 秒.超了返回 code=124 (跟 timeout(1) 惯例一致). + env: 环境变量; None = 继承. + 返回: + 命令结果 (stdout + stderr 合并进 lines, 空行已去掉). + """ + 起点 = time.monotonic() + try: + 完成 = subprocess.run( # noqa: S603 + argv, + cwd=None if cwd is None else str(cwd), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + timeout=超时, + env=env, + check=False, + ) + except subprocess.TimeoutExpired: + return 命令结果(124, time.monotonic() - 起点, [], f"超时 ({超时:.0f}s)") + except (OSError, ValueError) as 错: + return 命令结果(127, time.monotonic() - 起点, [], f"跑不起来: {错}") + 行 = [段 for 段 in 完成.stdout.splitlines() if 段.strip()] + return 命令结果(完成.returncode, time.monotonic() - 起点, 行) + + +def 跑并转发( + argv: list[str], + cwd: Path, + 尾部行数: int = 200, + env: dict[str, str] | None = None, +) -> 命令结果: + """跑命令并把输出**实时转发**到 stdout, 同时留末尾若干行给台账. + + 参数: + argv: 命令行 (内核/驱动的启动命令). + cwd: 工作目录. + 尾部行数: 保留多少行给调用方 (给 PG 的 detail 栏用; 失败时贴日志尾巴). + env: 环境变量; 调用方一般会带 PYTHONUNBUFFERED=1 (见下). + 返回: + 命令结果 (lines = 保留的那几行). + + 为什么要转发而不是重定向到文件: + 老板要能看实时进度, 不接受黑盒等待.管道的另一头是人. + + 为什么要 PYTHONUNBUFFERED=1: + 子进程 (尤其是 python) 往管道写时默认是**块缓冲**, 不 flush 的话几百行输出会憋到进程 + 退出才一起出来, "实时"就没了.由调用方在 env 里带上这个变量 (本库不擅自改别人的 env). + + 注意: + 这里逐行读管道直到 EOF, 所以会一直阻塞到子进程结束 -- 这正是"前台跑一次"的语义. + 要"后台常驻不等它"的话用 启动(), 别用这个. + """ + 起点 = time.monotonic() + 保留: list[str] = [] + try: + 进程 = subprocess.Popen( # noqa: S603 + argv, + cwd=str(cwd), + stdin=subprocess.DEVNULL, + stdout=subprocess.PIPE, + stderr=subprocess.STDOUT, + text=True, + bufsize=1, # 行缓冲: 子进程一 flush 我们就能收到 + env=env, + ) + except (OSError, ValueError) as 错: + return 命令结果(127, time.monotonic() - 起点, [], f"跑不起来: {错}") + 管道 = 进程.stdout + if 管道 is not None: + for 行 in 管道: + 文本行 = 行.rstrip("\n") + print(文本行, flush=True) + 保留.append(文本行) + if len(保留) > 尾部行数: + 保留.pop(0) + 码 = 进程.wait() + return 命令结果(码, time.monotonic() - 起点, 保留) diff --git a/归档/20260916-日志系统重做前/内核.log b/归档/20260916-日志系统重做前/内核.log new file mode 100644 index 0000000..7a83422 --- /dev/null +++ b/归档/20260916-日志系统重做前/内核.log @@ -0,0 +1,141 @@ +假内核: 起来了 pid= 32972 +假内核: 心跳 +假内核: 收到信号 15 收工 +假内核: 起来了 pid= 34287 +假内核: 心跳 +假内核: 收到信号 15 收工 +2026-09-16T12:47:43+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=41387 +2026-09-16T12:47:43+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:47:45+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:47:47+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:47:49+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:47:51+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:47:53+08:00 INFO [内核] 执行排队命令 #9: 扫描 [] +2026-09-16T12:47:53+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:47:55+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:47:57+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:47:59+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:01+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:03+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:05+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:07+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:09+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:11+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:13+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:15+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:17+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:19+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:21+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:23+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:25+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:27+08:00 WARN [内核] 这一轮出错 (会继续跑): syntax error at or near "ORDER" +LINE 1: ...lls SET state = 'waiting' WHERE state = 'pending' ORDER BY i... + ^ + +2026-09-16T12:48:29+08:00 INFO [内核] 常驻调度退出 (驱动没动, 下次启动会认领它们) +2026-09-16T12:48:44+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=41909 +2026-09-16T12:49:29+08:00 INFO [内核] 常驻调度退出 (驱动没动, 下次启动会认领它们) +2026-09-16T12:49:37+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=42256 +2026-09-16T12:52:05+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=42878 +2026-09-16T12:57:05+08:00 INFO [内核] 常驻调度退出 (驱动没动, 下次启动会认领它们) +2026-09-16T12:57:12+08:00 INFO [内核] 常驻调度退出 (驱动没动, 下次启动会认领它们) +2026-09-16T12:57:20+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=43583 +2026-09-16T12:57:29+08:00 ERROR [内核] 已经有一个内核在跑了 (抢不到调度锁) -- 同一份 PG 内存只能有一个调度器, 别起第二个 +2026-09-16T12:57:35+08:00 INFO [内核] 常驻调度退出 (驱动没动, 下次启动会认领它们) +2026-09-16T12:57:41+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=43767 +2026-09-16T13:00:45+08:00 ERROR [内核] 已经有一个内核在跑了 (抢不到调度锁) -- 同一份 PG 内存只能有一个调度器, 别起第二个 +2026-09-16T13:01:31+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=44623 +2026-09-16T13:02:24+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=45120 +2026-09-16T15:28:11+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=25578 +2026-09-16T15:28:35+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=25771 +2026-09-16T15:28:35+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=25771 +状态 驱动名 形态 PID 运行时长 配置 说明 +────── ────────── ────── ─── ──────── ──── ───────────────────────────────────────────────────────────────────────────────── +已退出 Json解码 python — — 一致 把 web.json ({html,css,js}) 解出来打印 -- 程序/Skill 层的最小样本, 由它来解释执行 +停止 样例消费器 python — — 一致 写驱动的另一半样板: 要别人的东西时不认识对方, 只往 calls 表发契约请求, 等内核转发 +停止 样板常驻 python — — 一致 写驱动的样板: 常驻 + 打印心跳 + 往 events 表汇报 (证明驱动不用跟内核握手也能汇报) + +驱动 3 / 有效 3 / 运行 0 / 无效 0 扫描 #19 15:28:35 +2026-09-16T15:29:14+08:00 INFO [内核] 收到信号 15, 准备退出 (驱动不动) +2026-09-16T15:29:14+08:00 INFO [内核] 常驻调度退出 (驱动没动, 下次启动会认领它们) +2026-09-16T15:29:14+08:00 INFO [内核] 常驻调度退出 (驱动没动, 下次启动会认领它们) +2026-09-16T15:35:20+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=29184 +2026-09-16T15:45:16+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=32871 +2026-09-16T15:45:27+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=32937 +2026-09-16T15:45:27+08:00 INFO [内核] 内核 v0.1 常驻调度启动 pid=32937 +状态 驱动名 形态 PID 运行时长 配置 说明 +────── ────────── ────── ─── ──────── ──── ───────────────────────────────────────────────────────────────────────────────── +已退出 Json解码 python — — 一致 把 web.json ({html,css,js}) 解出来打印 -- 程序/Skill 层的最小样本, 由它来解释执行 +停止 样例消费器 python — — 一致 写驱动的另一半样板: 要别人的东西时不认识对方, 只往 calls 表发契约请求, 等内核转发 +停止 样板常驻 python — — 一致 写驱动的样板: 常驻 + 打印心跳 + 往 events 表汇报 (证明驱动不用跟内核握手也能汇报) + +驱动 3 / 有效 3 / 运行 0 / 无效 0 扫描 #41 15:45:28 +2026-09-16T15:45:41+08:00 INFO [内核] 收到信号 15, 准备退出 (驱动不动) +2026-09-16T15:45:41+08:00 INFO [内核] 常驻调度退出 (驱动没动, 下次启动会认领它们) +2026-09-16T15:45:41+08:00 INFO [内核] 常驻调度退出 (驱动没动, 下次启动会认领它们) diff --git a/归档/20260916-日志系统重做前/说明.md b/归档/20260916-日志系统重做前/说明.md new file mode 100644 index 0000000..4ef27da --- /dev/null +++ b/归档/20260916-日志系统重做前/说明.md @@ -0,0 +1,26 @@ +# 归档说明 · 20260916 日志系统重做前 + +## 这是什么 + +日志系统完整化(2026-09-16,见 `设计/04-日志系统.md`)**之前**的 `内核/logs/内核.log` 原文。 +留着当证据,不是运行中的日志——运行中的日志在 `内核/logs/`。 + +## 为什么归档它 + +修前日志文件里混着**内核的命令输出**(不是日志行):引导器起常驻内核时把内核 `stdout` +一起重定向进了 `内核.log`,而内核启动时会打一张驱动清单表格。所以这份文件里能看到: + +``` +状态 驱动名 形态 PID 运行时长 配置 说明 +────── ────────── ────── ─── ──────── ──── ───────────── +``` + +- 位置:本文件第 **118 / 119 / 132 / 133** 行(共 4 行这种污染,另有多处 `假内核: 心跳` —— 那是 + `试跑引导器.py --假内核` 演示时写进来的 stdout,同样不属于日志行) +- 对比:修后的 `内核.log` 只有 `时刻 级别 [来源] 内容` 这一种行;命令输出改去 `内核.out.log` + +## 相关 + +- 设计:`设计/04-日志系统.md`(三条道 / 门槛 / 轮转 / 过滤 / 验收) +- 代码:`内核/日志.py`(内核与引导器共用的唯一实现) +- 自测:`./.venv/bin/python 内核/自测日志.py`(86 项,真写/真轮转/真起子进程跟) diff --git a/环境.efi.json b/环境.efi.json new file mode 100644 index 0000000..ccc6e4c --- /dev/null +++ b/环境.efi.json @@ -0,0 +1,20 @@ +{ + "efi": 1, + "python": ">=3.11", + "venv": ".venv", + "index": "https://pypi.tuna.tsinghua.edu.cn/simple", + "packages": [ + { "name": "psycopg2-binary", "version": ">=2.9.9", "import": "psycopg2", "required": true }, + { + "name": "types-psycopg2", + "version": ">=2.9", + "import": "", + "required": false, + "note": "Pylance/pyright 的类型存根 (PEP 561, 只有 .pyi 不进运行时); 不装它, 严格档对 psycopg2 的调用全是未知类型" + } + ], + "db": { "name": "efi_kernel", "host": "/home/lou/pgdata/socket", "port": 5432, "user": "lou" }, + "driver_root": "驱动", + "stop_timeout": 10, + "log_lines": 200 +} diff --git a/设计/01-驱动规范.md b/设计/01-驱动规范.md new file mode 100644 index 0000000..3677c80 --- /dev/null +++ b/设计/01-驱动规范.md @@ -0,0 +1,226 @@ +# 01 驱动规范 + +> 一个驱动 = 一个文件夹。"内核认不认这个驱动"只看一件事:文件夹根目录下有没有 `配置.efi.json`。 + +## 1. 驱动文件夹 = 内核的唯一识别单位 + +``` +驱动/ + Json解码/ ← 一个驱动就是一个文件夹 + 配置.efi.json ← 入口文件,必需,必须在文件夹根目录(驱动作者写) + json解码.py ← 源码入口(python 形态) + .venv/ ← 可选,有第三方依赖时才要 + logs/ ← 内核写 stdout/stderr(内核建) + 运行.efi.json ← 内核维护的运行时状态(内核写,驱动作者不要手改) + + 另一个驱动/ + 配置.efi.json + main ← exec 形态:直接给可执行文件(不看后缀,看 x 位) +``` + +约定名只有两个,都是 `.efi.json` 这个命名空间: + +| 文件 | 谁写 | 必需 | 生命周期 | +|---|---|---|---| +| `配置.efi.json` | 驱动作者 | ✔ | 静态,人/模型写的声明 | +| `运行.efi.json` | 内核 | 内核自动建 | 动态,每次扫描/启停刷新 | + +`.efi` 在这个框架里 = "内核认的接口文件",不是真的 UEFI 固件,也不限制后缀形态( +`配置.efi.json` / `运行.efi.json` 都是 json)。 + +## 2. 两种驱动形态 + +老板定的:文件夹里"包含源码和 venv,或者 exec 文件"。对应 `runtime` 两个取值: + +| runtime | 文件夹内容 | 内核怎么起 | +|---|---|---| +| `python` | 源码(.py)+ 可选 `.venv/` | `<解释器> `,cwd=驱动根目录 | +| `exec` | 可执行文件(ELF / 脚本 / 二进制) | 直接 exec,内核不找解释器 | + +`python` 形态解释器解析顺序: + +1. `interpreter` 显式给了路径 → 用它 +2. `interpreter = "venv"` → `<驱动目录>/.venv/bin/python`,**不存在就回落 system 并记 warning** +3. `interpreter = "system"` → `python3` +4. 默认 `"venv"` + +## 3. `配置.efi.json` 字段表(内核读的) + +字段名用英文(代码里直接当 key 用,Pylance 友好),说明中文。 + +| 键 | 类型 | 必需 | 默认 | 说明 | +|---|---|---|---|---| +| `efi` | int | ✔ | — | 协议版本。内核只认自己支持的版本,不认识就标 `invalid` 拒载 | +| `name` | str | ✔ | 文件夹名 | 驱动名,全局唯一。重名 → 后者标 `invalid` | +| `runtime` | `"python"` \| `"exec"` | ✔ | — | 形态,见上表 | +| `mode` | `"resident"` \| `"oneshot"` | ✖ | `"resident"` | 常驻等活 / 按需拉起(见 02 第 2 节末) | +| `entry` | str | ✔ | — | 入口文件,**相对驱动根目录**的相对路径,不许 `..` 逃逸 | +| `interpreter` | `"venv"` \| `"system"` \| 路径 | ✖ | `"venv"` | 仅 python 形态有效 | +| `args` | list[str] | ✖ | `[]` | 启动参数 | +| `env` | dict[str,str] | ✖ | `{}` | 追加/覆盖环境变量。(密钥类走环境变量不落盘) | +| `deps` | list[str] | ✖ | `[]` | ~~依赖的驱动名~~ **改用下面的 `provides` / `needs`** —— 驱动不许认识彼此 | +| `provides` | list[str] | ✖ | `[]` | 我**产出**的契约名(中立名,如 `表格:网页`)。**不是驱动名** | +| `needs` | list[str] | ✖ | `[]` | 我**需要**的契约名。谁提供、什么顺序、怎么送达,全由内核匹配 | +| `autostart` | bool | ✖ | `false` | 内核 boot 时是否自动拉起。**默认 false**(手动控制口径) | +| `restart` | `"no"` \| `"on-failure"` | ✖ | `"no"` | 崩溃拉起策略。默认不拉 | +| `logs` | str | ✖ | `logs/` | 日志目录,相对驱动根 | +| `note` | str | ✖ | `""` | 人看的一句话描述 | + +### 最小配置(python 形态) + +```json +{ + "efi": 1, + "name": "Json解码", + "runtime": "python", + "entry": "json解码.py" +} +``` + +### 完整配置(exec 形态 + 依赖) + +```json +{ + "efi": 1, + "name": "抓取器", + "runtime": "exec", + "entry": "bin/抓取器", + "args": ["--limit", "100"], + "env": { "COLLAPLEX_API": "http://127.0.0.1:8899" }, + "needs": ["表格:网页"], + "provides": ["表格:原始数据"], + "autostart": false, + "restart": "on-failure", + "logs": "logs/", + "note": "我不管谁给我网页,也不管谁拿我的数据 —— 内核牵线" +} +``` + +**驱动之间零耦合**:不 import 对方、不互相调用、配置里也不写对方的名字。只声明"我要什么、我产出什么", +其余(谁提供、什么顺序、怎么送达)全归内核(见 02 第 2 节末)。 + +### 驱动怎么读写「内核内存」 + +**驱动 ↔ 内核之间没有通信协议,也没有 socket / 消息格式。** 两边读写同一个 PostgreSQL 库 +(库名等见 02 第 3 节),驱动想汇报什么就往 `events` 表插一行: + +```python +import json, os, psycopg2 + +conn = psycopg2.connect(os.environ["EFI_DB"]) # 连接串由内核注入,不落盘 +with conn, conn.cursor() as cur: + cur.execute( + "INSERT INTO events (source, driver, level, kind, message, data) " + "VALUES (%s, %s, 'info', 'produce', %s, %s)", + ("Json解码", "Json解码", "解码完成", json.dumps({"size": 14315})), + ) +``` + +约束: + +- 驱动自己的 venv 里要带 `psycopg2`(或 `psycopg`);不带也能跑,只是不能汇报 —— 内核不因此判它失败。 +- 连接串只从环境变量 `EFI_DB` 取(内核对每个驱动注入),**不写进 `配置.efi.json`**(密钥不落盘)。 +- 驱动只写 `events`(+ 需要时读自己的行),**不许改 `drivers` / `driver_state`** —— 那两张是内核的地盘。 + +### 驱动怎么被别人调用 + +驱动**不许**直接调用另一个驱动(那就会打架,而且它还"认识"了对方)。要什么就跟内核发请求: + +- **要东西的一方**:往 `calls` 表插一行(`want` 写契约名,**不是驱动名**)→ 等 `state` 变 `done` → 从 PG 取数据 +- **被调的一方**:`resident` 的自己 `LISTEN driver_<名>` 等内核转发;`oneshot` 的由内核按需拉起跑一遍 +- 产出一律写 PG 契约表,**不点对点传输**(驱动之间没有通道) + +权限约定:驱动只许写 `calls`(发起请求)和 `events`(汇报),**不许写 `commands` / `drivers` / `driver_state`** +—— 那三张是内核和引导器的地盘(否则驱动能去停别人的进程,照样打架)。 + +## 4. `运行.efi.json`(内核维护的 json) + +**真相在 PG**(`driver_state` 表,那是内核的内存)。这份 json 是**落盘快照**: +文件夹自包含 —— 拷走一个驱动目录,或者 PG 不在,看这个文件也知道它上次是什么状态。 + +内核**每次扫描 / 启停 / 收尸**都会整份重写(幂等,原子替换:写 `.tmp` → `os.replace`)。 +驱动作者手改它没有任何意义,会被下次刷新覆盖。 + +| 键 | 类型 | 说明 | +|---|---|---| +| `efi` | int | 版本 | +| `name` | str | 驱动名 | +| `state` | str | 见状态机 | +| `pid` | int \| null | 主进程 pid | +| `pgid` | int \| null | 进程组 id,收子树用 | +| `started_at` | str \| null | ISO8601 +08:00 | +| `stopped_at` | str \| null | 同上 | +| `exit_code` | int \| null | 最后一次退出码 | +| `restarts` | int | 累计拉起次数(内核记账,不是配置) | +| `config_hash` | str | `sha256(配置.efi.json)`。**变了必须重启才生效**,内核据此提示"配置已改,待重启" | +| `entry_hash` | str | `sha256(entry 文件)` 可选,同用于"代码已改"提示 | +| `list_version` | int | 本次清单版本号 | +| `kernel` | str | 内核版本 | +| `last_error` | str \| null | 最近一次错误原文(不吞错) | +| `updated_at` | str | 本文件写入时间 | + +### 状态机 + +``` + ┌──────────► stopped ◄──────────┐ + │ │ │ + boot 读旧 json │ start│ │stop 成功 + │ ▼ │ + pid 不在 ──► crashed starting ──► running ─┤ + pid 被复用 ─► exited │ │ │ + fail│ 崩了 │ │ + ▼ ▼ │ + invalid failed ─┘ +``` + +| state | 含义 | +|---|---| +| `stopped` | 没在跑,正常态 | +| `starting` | 已 spawn,未确认存活 | +| `running` | `/proc/` 存在且 cmdline 校验通过 | +| `exited` | 进程没了(正常/异常退出),退出码在 `exit_code` | +| `crashed` | 断电/内核重启后判定:旧 json 有 pid,但 `/proc/` 不存在 | +| `failed` | 拉起就失败(入口不存在、解释器缺失、立即非零退出) | +| `invalid` | 配置不合法,内核拒载(`last_error` 存原因)。**不影响其他驱动** | + +## 5. 校验规则(扫描阶段,逐条查) + +任一不过 → 该驱动 `invalid` + `last_error` 写明第几条,**其余驱动照常工作**。 + +| # | 检查 | 不过的后果 | +|---|---|---| +| 1 | 文件夹根目录有 `配置.efi.json` | 不是驱动,静默跳过(记 debug) | +| 2 | JSON 可解析 | invalid: `配置不是合法 JSON` | +| 3 | `efi` 版本被内核支持 | invalid: `协议版本不支持` | +| 4 | `runtime` ∈ {python, exec} | invalid | +| 5 | `entry` 非空、无 `..`、解析后仍在驱动根内 | invalid: `入口路径越界`(安全) | +| 6 | `entry` 存在;exec 形态还要有 x 位 | invalid / failed | +| 7 | `name` 全局唯一 | 后到的 invalid: `驱动名重复` | +| 8 | `needs` 都有驱动 `provides`(内核全局匹配);依赖关系无环 | invalid: `契约无人提供` / `依赖成环`(只描述契约,不点名驱动) | +| 9 | python 形态:解释器可执行(venv 缺 → 回落 system + warning) | failed | + +## 6. 忽略规则(`.gitignore`) + +运行时产物不进 git —— 和 `web.json` / `1.json` 一样,模型产出物也不是源码: + +``` +运行.efi.json +*/logs/ +__pycache__/ +*.pyc +.venv/ +``` + +## 7. 与"程序 / Skill 层"的关系 + +老板 9/7 的分层是:引导器(UEFI) → 内核 → 驱动 → 程序(Skill)。 + +- **驱动 = 确定性代码能力接入层** → 本文件写的这一层,有 `配置.efi.json` 才是驱动。 +- **程序(Skill) = 声明式、模型驱动** → `web.json`({html, css, js})就是最早的雏形样本,由 + `Json解码` 这类驱动负责解释执行。 + +驱动 ↔ 内核、驱动 ↔ 驱动之间**不设计协议**,全部经 PostgreSQL(`events` 表当总线,见本文件第 3 节 +末与 02 第 3 节)—— 这是老板定的口径,不需要"三选一"。 + +本版(v0.1)落 引导器 + 内核 + 驱动 三层。**程序/Skill 层何时加载、由谁解释,留到 v0.2**, +本节只占位,不在 v0.1 实现。 diff --git a/设计/02-内核设计.md b/设计/02-内核设计.md new file mode 100644 index 0000000..0af0db9 --- /dev/null +++ b/设计/02-内核设计.md @@ -0,0 +1,388 @@ +# 02 内核设计 + +> 内核 = **总调度**(驱动互不认识,一切协调都经它)+ **纯 CLI 界面** + **PostgreSQL 当内存**。 +> 没有通信协议、没有 TUI:命令进来,内核干活,状态写 PG。 + +## 1. 职责边界 + +| 内核做 | 内核不做 | +|---|---| +| **总调度**:契约匹配 / 排序 / 路由 / 级联启停(见 01 第 3 节末) | 不读驱动源码内容(只记 hash) | +| 扫 `驱动/` 找 `配置.efi.json` | 不替驱动装依赖 / 建 venv | +| 校验配置、写注册表 | 不改驱动的 `配置.efi.json` | +| 拉起 / 停止 / 重启驱动进程 | 不解析驱动的输出(驱动自己写 PG) | +| 判活、收尸、记事件到 PG | 不自启、不自动重启(除 `restart: on-failure`) | +| 写驱动文件夹下的状态快照 | 不管自己的环境/包(那是引导器的活,见 03) | + +## 2. 四步流程(原设计逐条落地) + +老板 `内核设计.md` 那 4 行 → 对应实现: + +| 原设计 | 落地 | 产物 | +|---|---|---| +| 1. 遍历文件夹目录下驱动 efi 文件 | 扫 `驱动/` 一级子目录,认 `配置.efi.json` 为驱动标志 | `drivers` 表 upsert | +| 2. 遍历驱动配置文件 | 逐个读 `配置.efi.json` + 9 条校验(见 01 第 5 节) | `drivers.valid/error` | +| 3. 生成驱动 json | 每个驱动文件夹下写 `运行.efi.json` 状态快照;全局清单 = `drivers` 表(要看文件就 `内核.py 清单 > 清单.json`) | 快照 + PG | +| 4. 管理驱动进程用 linux 命令 | `启动/停止/重启/状态/日志` → `/proc` 直读 + 信号 | `driver_state` 表 + `logs/` | + +### 内核 = 总调度(驱动为什么不需要认识彼此) + +**驱动之间零耦合**:不 import 对方、不互相调用、配置里也不写对方的名字。所以**必须**有个总调度 —— +内核是唯一知道全局关系的一方,所有协调都经它。 + +| 内核的调度职责 | 做法 | +|---|---| +| **契约匹配** | 驱动只声明 `provides`(我产出什么)/ `needs`(我要什么),都是中立契约名。内核全局匹配"谁提供 → 谁消费",驱动之间零引用 | +| **启动排序** | 按 `provides/needs` 拓扑排序;无环才启动;成环 → 相关驱动全标 `invalid` 并写明是哪个契约成环 | +| **就绪等待** | 上游没起来不拉下游;上游起来了才按序放行下游 | +| **数据路由** | 驱动不认识彼此,产出/取用只在 PG 里按契约进行(表名由内核约定并注入),内核负责把契约对上 | +| **级联生命周期** | 停上游 → 依赖它的下游一并停(标"上游已停");上游崩 → 下游标"依赖失效",不装作没事 | +| **崩溃处理** | 按 `restart` 策略决定重拉;重拉后按依赖顺序把下游恢复 | + +一句话:**驱动只管"我要什么、我产出什么","谁给谁、什么顺序、谁先谁后"全归内核。** + +### 内核调用:驱动不互相直连,只向内核发请求 + +老板定调:**驱动发起一个请求 = 给内核的一条命令;内核转发;驱动执行完数据落到 PG;再把结果给请求方。** +主驱动要调副驱动,就跟内核说一声,内核去转发 —— 这样驱动之间**不可能打架**(它们连对方是谁都不知道)。 + +``` +主驱动 内核(常驻) PG 副驱动 + │ │ │ │ + ├─ INSERT calls ──────►│ (LISTEN 唤醒) │ │ + │ 契约 + 参数 │ │ │ + │ ├─ 校验:谁提供该契约 / 权限 / 锁 / 超时 / 调用链成环 │ + │ ├─ 已被占用 → 排队 waiting │ │ + │ ├─ 转发 ──────────────────────┼───────────────────────►│ + │ │ │ 副驱动执行 │ + │ │◄── 完成(产出写契约表 / 回填 calls)──────────────────┤ + │ ├─ 写 result + 释放锁 ───────►│ │ + │◄── NOTIFY / 轮询到 done ┤ │ │ + └─ 从 PG 取数据 │ │ │ +``` + +**请求按契约寻址,不按驱动名**:请求方只说"我要 `表格:网页`",内核去匹配谁提供。 +请求方始终不知道、也不需要知道对面是谁,`calls.provider` 那一列是内核自己记的账。 + +```sql +-- 驱动 → 内核的调用请求(内核是唯一处理者) +CREATE TABLE calls ( + id bigserial PRIMARY KEY, + ts timestamptz DEFAULT now(), + caller text NOT NULL, -- 哪个驱动发的 + want text NOT NULL, -- 契约名,不是驱动名 + args jsonb DEFAULT '{}', + state text DEFAULT 'pending', -- pending|waiting|running|done|failed|timeout|denied + provider text, -- 内核匹配出的提供方(请求方看不到) + lock_key text, -- 占用的锁(防打架) + result jsonb, -- 小结果直放;大数据放引用(表名 / 行 id) + error text, + deadline timestamptz, -- 超时线 + started_at timestamptz, + finished_at timestamptz +); +``` + +**防打架六条**(这就是内核存在的理由): + +| # | 打架长什么样 | 内核怎么挡 | +|---|---|---| +| 1 | 两个驱动同时写同一份数据 | 同一 `lock_key` 串行化:先到先执行,后面的排队 | +| 2 | 调用链成环(A 要 B、B 要 A) | 内核记调用链,成环直接 `denied`(与契约依赖成环同一套判定) | +| 3 | 想调一个没起来的驱动 | 先 `/proc` 校验提供方是否 `running`,没起就先起(或按 `mode` 按需拉起),否则拒 | +| 4 | 一个驱动卡死拖垮全局 | 每个请求带 `deadline`,超时内核收权、标 `timeout`、释放锁 | +| 5 | 越权(想用没声明过的契约) | 请求方只能要自己 `needs` 里声明过的契约,否则 `denied` | +| 6 | 驱动乱指挥(去停别人的进程) | **权限分家**:驱动只许写 `calls` / `events`;`commands`(管理命令)只有 CLI 和引导器能写;`drivers` / `driver_state` 只有内核能写 | + +第 6 条 v0.1 用"约定 + 内核校验",v0.2 可上 PG 角色(给驱动一个只有 `calls` / `events` 权限的角色)做硬隔离。 + +### 驱动的两种活法(决定内核怎么"转发") + +| `mode` | 内核怎么对它 | 适合 | +|---|---|---| +| `resident`(默认) | 起一次、常驻等活;转发靠 `NOTIFY driver_<名>`,驱动自己 `LISTEN` | 主驱动、服务型 | +| `oneshot` | 按需:有请求时内核拉起它跑一遍,跑完收尸 | 副驱动、批处理型 | + +两种都走同一个 `进程.py`(启停 / 判活 / 日志),对请求方**没有任何区别** —— 请求方只发契约,不关心对面是常驻还是现拉。 + +## 3. 内存 = PostgreSQL(不设通信协议) + +**驱动 ↔ 内核之间没有协议。** 两边都读写同一个库,`events` 表就是总线: + +``` +驱动进程 ──写──┐ ┌──读/写── 内核(CLI) + ├──► PostgreSQL ◄─────┤ +驱动进程 ──读──┘ efi_kernel └──读/写── 内核(CLI) +``` + +推论(这就是为什么不要协议): + +| 好处 | 说明 | +|---|---| +| 内核可以随时死 | 状态在库里,重跑一条 CLI 就接着干,不需要"恢复会话" | +| 驱动不用跟内核握手 | 驱动直接 `INSERT INTO events` 汇报,内核不用解析 stdout | +| 不用设计消息格式 | 表结构就是格式,`jsonb` 装不确定的载荷 | +| 断电不丢现场 | 22:30 断电后,库里最后一条 `heartbeat`/`exit` 就是死因时间线 | + +### 库与连接 + +| 项 | 值 | +|---|---| +| 库名 | `efi_kernel`(沿用"一项目一库";项目正式命名后一起改) | +| 实例 | 本机 PG 18,数据目录 `/home/lou/pgdata`,端口 5432 | +| 连接 | socket `/home/lou/pgdata/socket`(unix socket 优先,不走 TCP) | +| 驱动侧 | 连接串经环境变量 `EFI_DB` 由内核注入,**不落盘**(密钥不落盘口径) | +| 唯一碰 SQL 的文件 | `内核/db.py`(建表 + 读写 + 事件写入),别处不写 SQL | + +### 表设计(4 张,够用不超配) + +```sql +-- ① 驱动注册表:内核扫描后 upsert +CREATE TABLE drivers ( + name text PRIMARY KEY, + dir text NOT NULL, -- 驱动文件夹绝对路径 + runtime text NOT NULL, -- python | exec + entry text NOT NULL, + interpreter text, + args jsonb DEFAULT '[]', + env jsonb DEFAULT '{}', + deps text[] DEFAULT '{}', + autostart boolean DEFAULT false, + restart text DEFAULT 'no', + config_hash text, -- sha256(配置.efi.json) + entry_hash text, + valid boolean DEFAULT true, + error text, -- invalid 原因原文(不吞错) + scanned_at timestamptz DEFAULT now() +); + +-- ② 运行时状态:一行一驱动,内核每次动作刷新 +CREATE TABLE driver_state ( + name text PRIMARY KEY REFERENCES drivers(name) ON DELETE CASCADE, + state text NOT NULL DEFAULT 'stopped', + pid integer, + pgid integer, + started_at timestamptz, + stopped_at timestamptz, + exit_code integer, + restarts integer DEFAULT 0, + boot_hash text, -- 起进程时的配置指纹 + list_version bigint, + last_error text, + updated_at timestamptz DEFAULT now() +); + +-- ③ 事件流 = 总线:内核写,驱动也写 +CREATE TABLE events ( + id bigserial PRIMARY KEY, + ts timestamptz DEFAULT now(), + source text NOT NULL, -- kernel | <驱动名> + driver text, -- 关联驱动(可空) + level text DEFAULT 'info', -- info | warn | error + kind text, -- start|stop|exit|log|produce|heartbeat|error + message text, + data jsonb +); +CREATE INDEX events_ts_idx ON events (ts DESC); +CREATE INDEX events_driver_idx ON events (driver, ts DESC); + +-- ④ 扫描批次:list_version 自增,用来判"这份状态是不是本次扫描的" +CREATE TABLE scans ( + list_version bigserial PRIMARY KEY, + started_at timestamptz DEFAULT now(), + kernel text, + total int, valid int, invalid int, running int +); +``` + +`boot_hash != drivers.config_hash` → 该驱动显示"配置已改,待重启"(不用 diff 内容)。 + +引导器自己的两张表(`kernel_env` 环境体检 / `kernel_runs` 内核运行台账)定义在 03;内核的 +`commands`(命令表)与 `calls`(驱动调用请求)见第 2 节末。全部同一个库 `efi_kernel`。 + +## 4. 进程管理(第 4 步) + +用 linux 原语,不引入 supervisor / systemd。不论内核自身是否常驻(这个分叉见本节末), +**驱动判活一律以 `/proc` 为准**,不信 PG 里的旧 pid —— pid 会被系统复用。**三条铁律来自踩过的坑**: + +| 铁律 | 为什么 | +|---|---| +| `Popen(cwd=驱动目录, start_new_session=True)` | 独立进程组 → 停的时候能连子树一起收(杀父不等于杀子树) | +| 停之前先读 `/proc//cmdline` 校验,再发信号 | pid 会被系统复用,裸 `kill ` 可能杀到别人的进程 | +| 启动与停止分两条 CLI 调用,一次只起一份 | 同一 shell 里连做会留孤儿互抢端口 | + +### 启动 `内核.py 启动 <名>` + +1. 查 PG:`state == running` 且 `/proc/` 在 + cmdline 匹配 → 拒绝(幂等,不重复起) +2. 拼命令:`[解释器, entry] + args`(exec 形态 `[entry] + args`),`env` = 基线 + `EFI_DB` + 配置 `env` +3. `Popen(cwd=驱动根, stdout/stderr=logs/<名>.log, start_new_session=True)` +4. 状态置 `starting` → 探 `/proc//cmdline` 匹配 → `running`;进程秒退 → `failed` + `exit_code` +5. 刷 PG `driver_state`(pid/pgid/started_at/boot_hash/restarts+1)+ 写 `events(kind='start')` + 刷驱动文件夹快照 + +### 停止 `内核.py 停止 <名>` + +1. 查 PG 拿 pid;无 pid 或 `/proc/` 不在 → 直接置 `stopped`(幂等) +2. **校验 cmdline**:`/proc//cmdline` 必须出现该驱动的入口路径(前缀匹配)→ 不是则判"pid 被复用", + **不杀**,只清状态 + 记 `last_error`(宁可不杀,不可误杀) +3. `SIGTERM` 给**进程组** `killpg(-pgid)` → 等超时(默认 10s) +4. 还活着 → `SIGKILL`,按 pid 逐个收残 +5. 复查 `/proc` 为空才算停干净,`state=stopped` + `stopped_at` + `events(kind='stop')` + +### 状态 `内核.py 状态 [名]` + +不看 `ps` 解析(会被截断),直读 `/proc//cmdline` 精确点名: + +| 判定 | 结果 | +|---|---| +| PG 无 pid / pid 不存在 | `stopped` 或 `crashed`(有退出码且非零 → crashed) | +| pid 在,cmdline 匹配自家 entry | `running`(回填运行时长、CPU、内存 RSS) | +| pid 在,cmdline 不匹配 | `exited`(pid 被复用,不认领、不杀) | + +### 内核自身 = 常驻调度器(定稿:甲) + +内核进程常驻跑着,命令从 PG 进来: + +| 项 | 落法 | +|---|---| +| 内核进程 | 常驻;引导器负责它的存在(启停 / 判活 / 日志 / 记账,见 03) | +| 命令通道 | CLI 客户端把命令写 `commands` 表;内核 `LISTEN` **PG 自带的 LISTEN/NOTIFY** | +| 为什么必须常驻 | 总调度要在**运行时**看着依赖链:契约满足要唤醒下游、上游崩要级联、驱动请求要转发仲裁 —— 只在启动那一下算一遍不够 | +| 手动控制 | 引导器不 autostart,起停由人敲命令 | +| 自造协议 | **零**。没有 socket、没有自定消息格式,全部走 PG(表 + NOTIFY) | + +```sql +-- 甲才需要:命令表(CLI 客户端写,常驻内核消费) +CREATE TABLE commands ( + id bigserial PRIMARY KEY, + ts timestamptz DEFAULT now(), + source text, -- cli | 引导器 + cmd text NOT NULL, -- 启动|停止|重启|扫描|状态... + args jsonb DEFAULT '{}', + state text DEFAULT 'pending', -- pending|running|done|failed + result jsonb, + started_at timestamptz, + finished_at timestamptz +); +``` + +## 5. 断电收尸(本机 22:30 断电,这是必做项) + +内核每次 `扫描` / `boot()` 都**先看 PG 再判**: + +| PG `driver_state` | `/proc//cmdline` | 判定 | 动作 | +|---|---|---|---| +| `running` 有 pid | 存在且匹配 | 认领 → `running` | 不重起,接管 | +| `running` 有 pid | 不存在 | `crashed` | `events(kind='exit', message='断电/被杀')`;`autostart=true` 才拉起 | +| `running` 有 pid | 存在但不匹配 | `exited` | 清 pid,**不杀** | +| `stopped` / 无记录 | — | `stopped` | 无 | + +好处:断电重启后不用翻日志,一条 `内核.py 状态` 就把"上次谁在跑、崩在哪步"全列出来;驱动文件夹里的 +`运行.efi.json` 快照是它的离线副本(文件夹自包含,拷走也带状态)。 + +## 6. 界面 = 纯 CLI + 日志 + +**没有 TUI。** 就两条:对齐的中文输出 + 结构化日志。人是看输出,机器看 `--json`。 + +### 子命令 + +``` +python3 内核/内核.py 列表 # 驱动清单表(默认动作) +python3 内核/内核.py 扫描 # 只扫不启,刷新 PG + 快照 +python3 内核/内核.py 启动 <名> +python3 内核/内核.py 停止 <名> +python3 内核/内核.py 重启 <名> +python3 内核/内核.py 状态 [名] [--json] # --json 给机器读 +python3 内核/内核.py 日志 <名> [-n 200] [-f] # -f = tail -f 实时跟(要能看实时进度) +python3 内核/内核.py 事件 [-n 50] # 全局事件流(PG events 表) +python3 内核/内核.py 清单 # 输出清单 JSON(重定向就是文件) +``` + +### 输出样例(`列表`) + +``` +状态 驱动名 形态 PID 运行时长 配置 说明 +停止 抓取器 exec — — 一致 — +运行 Json解码 python 12345 00:12:31 待重启 配置已改,需重启生效 +无效 坏驱动 python — — — 入口路径越界 +──────────────────────────────────────────────────────────────────────── +驱动 3 / 有效 2 / 运行 1 / 无效 1 扫描 #7 22:50:03 +``` + +### 日志规范(2026-09-16 完整化,细节见 `04-日志系统.md`) + +**三条道,一个文件只装一种内容**(修前命令输出会混进日志文件——实测抓到 4 行表格污染, +存档 `归档/20260916-日志系统重做前/内核.log`): + +| 文件 | 内容 | 从哪看 | +|---|---|---| +| `内核/logs/内核.log` | **只有**结构化日志行 | `内核 日志` / `日志 --内核` | +| `内核/logs/内核.out.log` | 内核进程 stdout/stderr 原始流(命令输出 + 崩溃原文) | `内核 日志 --输出` | +| `内核/logs/引导器.log` | 引导器的动作(体检 / 包 / 移交 / 内核启停) | `UEFI.boot.py 日志` | +| `驱动/<名>/logs/<名>.log` | 驱动原始输出(内核重定向不解析;每轮启动前写一条分隔头) | `日志 <驱动名>` | + +- 行格式:`2026-09-15T22:50:03+08:00 INFO [内核] 扫描完成 drivers=3 valid=2 invalid=1 version=7` + (时刻带时区 / 级别 / `[来源]` / 内容;多行内容照原文写,不截断) +- 级别与门槛:`DEBUG / INFO / WARN / ERROR`,门槛 = `环境.efi.json` 的 `log_level`(低于它的不写); + 错误一律带原文(不吞错、不静默降级) +- 轮转:超过 `log_max_mb`(默认 5)改名 `.1`,留 `log_keep`(默认 3)份; + **只在拉起进程之前轮转**(运行中改名会让进程继续写老 inode = 日志丢了) +- 过滤 / 实时跟 / JSON:`日志 [驱动名|--全部|--内核|--引导器|--输出] [-n 200] [-f] [--级别 X] [-g 关键词] [--json]` +- 驱动自己的结构化汇报:走 PG `events` 表(`source = 驱动名`)—— 这就是"不要协议"的落地方式 +- 实现只有一份:`内核/日志.py`(内核与引导器共用,纯 stdlib) + +## 7. 内核自己的文件布局 + +一套职责一个文件,不合并: + +``` +工作区/内核/ + UEFI.boot.py 引导器(内核的管家,见 03) + 环境.efi.json 项目级配置(引导器写,内核只读,见 03) + 环境状态.efi.json 环境体检快照(引导器写) + 驱动/ 驱动文件夹(一个驱动一个目录) + <驱动名>/ + 配置.efi.json 驱动作者写 + 运行.efi.json 内核写的状态快照 + logs/ 内核收驱动的 stdout/stderr + 内核/ + 内核.py 入口:CLI 子命令分发 + 扫描.py 扫驱动 + 读配置 + 校验 + 写 PG + 进程.py 启动/停止/判活/日志(/proc + 信号)—— 通用进程库 + 状态.py 快照读写 + 收尸判定 + db.py 唯一碰 SQL 的地方:连接/建表/事件 + 日志.py 日志系统:写/解析/过滤/轮转/实时跟(内核与引导器共用, 见 04) + logs/内核.log 内核的结构化日志行(内核自己写) + logs/内核.out.log 内核进程的 stdout/stderr(命令输出 + 崩溃原文) + logs/引导器.log 引导器的动作日志 + 自测*.py 五份自测(进程 / 内核 / 配置 / db / 日志) + 设计/ ← 本目录(01~04 + 图/) +``` + +`进程.py` 是**通用进程库**:内核用它管驱动进程,引导器用它管内核进程(见 03 第 7 节)。 +只此一份实现,不复制。 + +### 配置:内核不再自持配置文件 + +内核读项目根的 `环境.efi.json`(唯一一份配置,引导器管,见 03 第 4 节)。原先设想的"内核自己的 +`配置.efi.json`"取消 —— 两份配置迟早对不上。 + +## 8. 错误处理 + +| 场景 | 内核行为 | +|---|---| +| PG 连不上 | 明确报错退出(内存不在就没法干活),**不降级到文件模式**(避免两份真相) | +| 驱动目录不存在 | 报明确错,**不建空目录**(避免静默假成功) | +| 某驱动配置错 | 只标它 `invalid` + `error` 原因,其他驱动照常(隔离失败) | +| 启动后秒退 | `failed` + `exit_code` + 日志末尾若干行原文 | +| 停止超时 | 升级 SIGKILL,记 `last_error` | +| pid 被复用 | 不杀,清状态,记 `last_error` | +| 快照 json 损坏 | 当无快照处理重建;原子写(`.tmp` + `os.replace`)不留半截文件 | +| 手工删了 `.venv` | 回落 `python3` + WARN,不假死 | + +## 9. v0.1 范围 + +**做**:驱动识别 / 契约匹配与排序 / PG 注册表·状态·事件 / 启停状态日志收尸 / **常驻调度内核(甲)** / +**命令表 + 驱动调用转发(`calls`)** / 纯 CLI。 + +**不做(明确留给后面)**:程序 / Skill 层加载(见 01 第 7 节)、PG 角色级硬隔离(v0.2)、 +开机自启、TUI、资源限额与权限隔离。 diff --git a/设计/03-引导器.md b/设计/03-引导器.md new file mode 100644 index 0000000..c422218 --- /dev/null +++ b/设计/03-引导器.md @@ -0,0 +1,227 @@ +# 03 引导器 UEFI.boot.py + +> 引导器 = 内核的管家,三件事:**管内核环境**、**管包状态**、**管内核进程**。 +> 它不碰驱动 —— 驱动是内核的活。分层是**一层管一层**:引导器管内核,内核管驱动。 + +## 1. 职责边界 + +| 引导器做 | 引导器不做 | +|---|---| +| 内核环境体检(解释器 / venv / 路径 / PG) | 不扫驱动、不管驱动进程(内核的活) | +| venv 重建、包安装(**显式敲命令才动手**) | 不自动修复、不自动装包、不自动升级 | +| 包状态台账(已装 / 缺失 / 版本不符) | 不管驱动的 venv 和依赖(驱动自己的事) | +| 管内核进程(启停 / 判活 / 日志 / 记账) | 不改任何 `配置.efi.json` | +| 其余参数**原样透传**给内核 | 不重复实现内核子命令 | + +## 2. 铁律:引导器零第三方依赖 + +鸡生蛋问题:引导器要检查"内核环境里的包状态",可**引导器自己要是依赖某个包,那包坏了引导器也起不来 —— +就没人来报错了**。所以: + +| 引导器自己的命令(自检 / 环境 / 包) | 纯 stdlib:`subprocess` 调 uv/pip、`importlib.metadata` 读版本、`pathlib` 查路径 | +| 写 PG 台账 | `try: import psycopg2`;没有就**降级**:只写 `环境状态.efi.json` 快照 + 一行 WARN,**不算失败** | +| 透传给内核的命令 | PG 连不上由内核自己报错退出(内核的内存就是 PG,它不降级) | + +分界线一句话:**引导器自己的命令 PG 降级,内核的命令不降级。** +引导器永远要能跑起来、能报错、能给出修复命令。 + +## 3. 三层文件(对称设计) + +每层都是"一份声明 + 一份状态快照": + +| 层 | 声明(人/引导器写) | 状态快照(运行时写) | +|---|---|---| +| 驱动 | `驱动/<名>/配置.efi.json` | `驱动/<名>/运行.efi.json` | +| 内核 / 引导器 | `环境.efi.json` | `环境状态.efi.json` | + +## 4. `环境.efi.json`(项目根,外置声明) + +**引导器是它唯一的写者,内核只读。** 一份配置,不许有第二份(两份真相 = 迟早打架)。 +内核原先设计的"内核自己的 `配置.efi.json`"取消,合并到这一份。 + +| 键 | 类型 | 说明 | +|---|---|---| +| `efi` | int | 版本 | +| `python` | str | 解释器要求,如 `">=3.11"` | +| `venv` | str | venv 相对路径,默认 `.venv` | +| `index` | str | 包索引源。校网必须走国内:`https://pypi.tuna.tsinghua.edu.cn/simple` | +| `packages` | list | 内核依赖清单,见下 | +| `db` | dict | `{name, host, port, user}`,`host` 填 unix socket 目录 | +| `driver_root` | str | 驱动目录,默认 `"驱动"` | +| `stop_timeout` | int | 停进程超时秒,默认 10 | +| `log_lines` | int | 日志命令默认行数,默认 200 | +| `log_level` | str | 日志门槛 `DEBUG/INFO/WARN/ERROR`,默认 `INFO`(低于它的不写) | +| `log_max_mb` | int | 单份日志轮转上限(MB),默认 5;`0` = 不轮转 | +| `log_keep` | int | 轮转后保留份数,默认 3;`0` = 不轮转 | + +`packages` 每项: + +| 键 | 说明 | +|---|---| +| `name` | 安装名(pip 名),如 `psycopg2-binary` | +| `version` | 版本要求,如 `">=2.9.9"`,空 = 不校验 | +| `import` | 导入名(校验用),如 `psycopg2` | +| `required` | `true` 缺失即环境不通过;`false` 缺失只 WARN | + +```json +{ + "efi": 1, + "python": ">=3.11", + "venv": ".venv", + "index": "https://pypi.tuna.tsinghua.edu.cn/simple", + "packages": [ + { "name": "psycopg2-binary", "version": ">=2.9.9", "import": "psycopg2", "required": true } + ], + "db": { "name": "efi_kernel", "host": "/home/lou/pgdata/socket", "port": 5432, "user": "lou" }, + "driver_root": "驱动", + "stop_timeout": 10, + "log_lines": 200 +} +``` + +## 5. 环境体检(`自检` / `环境`) + +6 项,逐项出结论 + **修复命令**,从不含糊: + +| # | 检查 | 方法 | 不过时给什么 | +|---|---|---|---| +| 1 | 解释器版本符合 `python` | 跑解释器报版本,比 `>=` / `<=` / `==` | 报"需要 ≥x,当前 y" | +| 2 | venv 存在 | `.venv/bin/python` 在 | `环境 重建` | +| 3 | **venv 健康** | 跑 `.venv/bin/python -c "import sys;print(sys.prefix)"`,结果必须等于 `.venv` 自身绝对路径;`pyvenv.cfg` 的 `home` 指向存在的解释器 | `环境 重建`(★治"目录搬走后 shebang 还指旧路径"这个病,本项目 `.venv` 现在正是坏的:shebang 指着 `/home/lou/桌面/内核/.venv/...`) | +| 4 | 包状态 | `uv pip list --format json` 读实装版本(没 uv 就 `importlib.metadata`),逐条核 `packages` | 列出缺的 / 版本不符的 + `包 安装` | +| 5 | `driver_root` 可读写 | 存在 + 能建临时文件 | 报错(**不自动建空目录**) | +| 6 | PG 能连 | 实例在跑 + 库 `db.name` 存在 + 真连一次 | 给建库/起库命令 | + +判定 → 汇总 `ok: true/false`,落 `环境状态.efi.json`(快照)+ PG `kernel_env`(台账)。 + +## 6. 包状态(`包`) + +- `包` → 台账表:`包名 / 要求 / 实装 / 状态(一致·缺失·版本不符)/ 必需性` +- `包 安装 [名...]` → `uv pip install`(走 `index` 里的国内源,校网 GFW 下这是硬要求);不带名字 = 按清单全装 +- **不自动装、不自动升**(手动控制口径,"一搞就炸") +- uv 不在时回落 `python3 -m venv` + `pip -i ` + +## 7. 内核进程管理(`内核 ...`) + +内核通常是**一次性短命进程**(跑完就退,状态在 PG),引导器把它当受管进程记账: + +| 命令 | 作用 | +|---|---| +| `内核 状态` | 有没有内核在跑(pid + `/proc//cmdline` 校验);上次运行(起止 / 耗时 / 退出码);今天跑了几次 | +| `内核 启动 [--守护]` | 拉起内核进程;`--守护` = 常驻模式(预留,v0.1 不实现) | +| `内核 停止` / `内核 重启` | 停 / 重启内核进程 | +| `内核 日志 [选项]` | 内核日志(默认看结构化日志 `内核.log`;`--输出` 看命令输出/崩溃原文 `内核.out.log`) | +| `日志 [选项]` | 引导器自己的动作日志(`内核/logs/引导器.log`;`--内核`/`--输出` 转看内核那两份) | + +**复用 `内核/进程.py`** —— 启停 / 判活 / cmdline 校验 / 收子树全用同一份实现。 +绝不为引导器再写一套(两套实现 = 各有一套坑,还各自以为对方对)。 + +边界:内核是**总调度**(见 02 第 2 节末),引导器只保证"它活着、它跑得起来"—— +拉起、判活、重启、收日志,**不干预它怎么调度驱动**。 + +(内核自身常驻已定稿 = 甲:**常驻调度器**。所以引导器管的这个内核进程是长期存在的, +`内核 启动/停止/状态/日志` 就是它的生命周期;引导器只保证它活着,不干预它怎么调度。) + +记账两张表(同库 `efi_kernel`): + +```sql +-- 环境/包体检台账 +CREATE TABLE kernel_env ( + id bigserial PRIMARY KEY, + ts timestamptz DEFAULT now(), + python_version text, + venv_path text, + venv_healthy boolean, + packages jsonb, -- [{name, want, got, ok}] + pg_ok boolean, + driver_root_ok boolean, + ok boolean, + detail text +); + +-- 内核运行台账(每次运行一行) +CREATE TABLE kernel_runs ( + id bigserial PRIMARY KEY, + started_at timestamptz DEFAULT now(), + finished_at timestamptz, + argv text, + mode text, -- oneshot | daemon + pid integer, + exit_code integer, + seconds real, + ok boolean, + detail text +); +``` + +## 8. CLI 全表 + +顶层固定 4 个词(`自检` / `环境` / `包` / `内核`)归引导器,其余**原样透传**内核 —— +重名因此不冲突:`启动 抓取器` 是驱动,`内核 启动` 是内核。 + +| 命令 | 谁干 | +|---|---| +| `UEFI.boot.py` | 自检 → 内核默认动作(列表) | +| `UEFI.boot.py --check` \| `自检` | 只体检,不启内核 | +| `UEFI.boot.py 环境 [--json]` | 体检(同上) | +| `UEFI.boot.py 环境 重建` | 按 `python` 要求重建 venv(显式) | +| `UEFI.boot.py 包 [--json]` | 包状态台账 | +| `UEFI.boot.py 包 安装 [名...]` | 装包(显式) | +| `UEFI.boot.py 内核 状态\|启动\|停止\|重启\|日志` | 内核进程管理 | +| `UEFI.boot.py 日志 [选项]` | 引导器自己的动作日志(体检 / 包 / 移交 / 内核启停 / WARN) | +| `UEFI.boot.py 列表\|扫描\|启动 <驱动>\|停止 <驱动>\|重启 <驱动>\|状态\|日志 <驱动>\|事件\|清单` | 透传给内核 | + +日志选项(`日志` / `内核 日志` / 透传的 `日志 <驱动>` 认同一套,解析只有一份 `日志.解析选项`): + +| 开关 | 含义 | +|---|---| +| `-n 200` | 尾部行数(含轮转的历史份一起数) | +| `-f` | 实时跟(先吐 10 行,Ctrl+C 停;一次只能跟一个来源) | +| `--级别 WARN` | 只要这个级别及以上(驱动原始日志按字样猜级别) | +| `-g 关键词` | 只要含它的行(不区分大小写) | +| `--json` | JSON Lines(结构化日志拆字段;裸行给空来源 + 猜的级别) | +| `--全部` / `--内核` / `--引导器` / `--输出` | 换来源 | + +参数写错 → 打用法 + 退出码 1(不猜、不静默用默认值)。引导器每次跑都轮转一次自己的日志 +(短命进程,天然没有"运行中改名"的风险)。 + +## 9. 启动时序 + +``` +$ python3 UEFI.boot.py 列表 + │ + ├─ ① 体检(纯 stdlib,任何情况都能报) + │ 解释器版本 → venv 存在/健康 → 包状态 → 驱动目录 → PG + │ 不通过 → 打印原因 + 修复命令,退出 1(不往下走,不自动改) + │ + ├─ ② 记账 + │ 写 环境状态.efi.json(必做,原子替换) + │ 写 PG kernel_env(尽力而为,连不上就 WARN) + │ + ├─ ③ 移交 + │ fork 内核进程(argv 透传) → 等 → 记 kernel_runs → 返回它的退出码 + │ 内核自己写 内核/logs/内核.log(结构化行) + │ --守护 时内核 stdio 重定向到 内核/logs/内核.out.log(命令输出 + 崩溃原文) + │ + └─ ④ 退出不管子进程(驱动是独立进程组,不随引导器死) +``` + +## 10. 边界 + +| 场景 | 行为 | +|---|---| +| 引导器自己的命令 + PG 不通 | WARN + 只写 json 快照,**不失败** | +| 透传内核的命令 + PG 不通 | 由内核报错退出,引导器原样返回非 0 | +| venv 不健康 | 明确报"不健康 + 为什么" + `环境 重建`,**不自动重建** | +| 包缺失 | 列出缺哪个 + `包 安装`,**不自动装** | +| uv 不在 | 回落 `python3 -m venv` + `pip -i `,并 WARN | +| `driver_root` 不存在 | 报错退出 1(不建空目录) | +| 内核文件缺失 | 报错退出 1,指路径 | +| 内核抛异常 | 原样打印堆栈,退出码非 0 | +| 内核秒退非 0 | `kernel_runs.ok=false` + `detail` 存日志末尾若干行 | + +## 11. v0.1 范围 + +**做**:环境体检 / venv 重建 / 包台账 / 装包 / 内核进程记账与启停 / 参数透传。 + +**不做**:自动修复、自动装包、开机自启、常驻守护(`--守护` 只留接口)、驱动 env 代管。 diff --git a/设计/04-日志系统.md b/设计/04-日志系统.md new file mode 100644 index 0000000..7547c0d --- /dev/null +++ b/设计/04-日志系统.md @@ -0,0 +1,132 @@ +# 04 · 日志系统(三条道 · 门槛 · 轮转 · 过滤) + +> 2026-09-16 落地。实现只有一份:`内核/日志.py`(纯 stdlib,内核与引导器共用,照 `进程.py` / `db.py` / `文本.py` 的规矩——只此一份,不复制)。 +> 本文件只讲日志系统;驱动规范 / 内核 / 引导器见 `01-驱动规范.md` / `02-内核设计.md` / `03-引导器.md`。 + +## 0. 这一版解决什么(修前的三个毛病,都是实测出来的) + +| 毛病 | 实测证据 | 本版做法 | +|---|---|---| +| **命令输出混进日志文件** | 引导器起常驻内核时把内核 `stdout` 一起重定向进 `内核.log`;内核启动时会打驱动清单表格 → `内核.log` 里躺着 `状态 驱动名 形态 …` 和 `──────`(存档:`归档/20260916-日志系统重做前/内核.log`,4 行这种污染) | 三条道拆开:日志行归内核自己写(`内核.log`),进程 stdio 归 `内核.out.log` | +| **引导器没有历史** | 引导器的 WARN 只 `print` 到 stderr,跑完就没了;"上次几点体检、谁改的配置、内核是不是它起的"查不到 | 引导器每次动作落 `内核/logs/引导器.log`(开始 / 收工 / 移交 / 启停内核 / 所有 WARN) | +| **日志只涨不缩、只能整份看** | `日志 <名> [-n] [-f]` 一种看法,没有级别过滤、没有 JSON、没有历史轮转;常驻驱动写几天就是几十 MB | 门槛 + 轮转 + 过滤 + JSON + 台账(下表) | + +三条道是给同一件事的三个视角:**人在终端看 stdout、机器读 `--json`、翻旧账看文件**。 + +## 1. 三条道(一个文件只装一种内容) + +| 文件 | 谁写 | 内容 | 从哪看 | +|---|---|---|---| +| `内核/logs/内核.log` | 内核 `说()`(= `日志.记`) | **只有**结构化日志行 | `内核 日志` / `日志 --内核` | +| `内核/logs/内核.out.log` | 引导器把内核进程的 stdout+stderr 重定向到这 | 内核的**命令输出**(表格 / JSON)+ 未捕获的**崩溃原文** | `内核 日志 --输出` / `日志 --输出` | +| `内核/logs/引导器.log` | 引导器 `记日志()` | 引导器的动作(体检 / 包 / 移交 / 内核启停 / WARN) | `日志`(引导器顶层命令) | +| `驱动/<名>/logs/<名>.log` | 内核把驱动的 stdout+stderr 重定向到这(**不解析内容**) | 驱动的原始输出,每轮启动前插一条分隔头 | `日志 <驱动名>` / `日志 --全部` | + +**为什么不给 `.out.log` 也做结构化**:里面的内容是别人(内核 CLI 层 / Python 解释器)的原始流,内核**不解析**——硬解析等于对别人的输出做假设,改天换个格式就全乱。所以这一份只做"原样保留 + 可 tail",级别靠字样猜(只在过滤时用)。 + +**守护模式为什么关掉日志行的 stderr 副本**:守护时 stdio 全进 `.out.log`,日志行若再从 stderr 走一遍,同一句话会在 `.out.log` 里出现两次。引导器起守护内核时设 `EFI_LOG_CONSOLE=0`;前台跑(人盯着终端)不设,日志行实时打 stderr。 + +## 2. 行格式(固定字段,一行一条) + +``` +2026-09-16T20:56:02+08:00 WARN [内核] 样板常驻: 日志超过上限, 已轮转 (旧份改名 .1) +└── 时刻(带时区) └级别 └来源 └内容(原样,不截断) +``` + +| 字段 | 取值 | 说明 | +|---|---|---| +| 时刻 | ISO8601 带时区 | 本机是 `+08:00`;跨时区/断电对比时能对齐 | +| 级别 | `DEBUG` / `INFO` / `WARN` / `ERROR` | 左对齐 5 格(列要对得齐);`DEBUG < INFO < WARN < ERROR` | +| 来源 | `内核` / `引导器` / 驱动名 | 方括号裹住,机器好拆 | +| 内容 | 任意文本 | 多行内容照原文写(不截断、不转义) | + +驱动原始日志(`.out.log` / 驱动日志)**不是**这个格式——它们是别人的 stdout。读的时候 `日志.解析行()` 拆不开就给 `None`,级别用 `日志.猜级别()`(含 `ERROR/Traceback/失败` → ERROR;含 `WARN/警告/超时/retry` → WARN;其余 INFO)。这一点必须在报告里说清:**猜的,不是规范**。 + +## 3. 门槛(写不写)与轮转(留多少) + +| 项 | 配置键 | 默认 | 行为 | +|---|---|---|---| +| 级别门槛 | `log_level` | `INFO` | 低于它的**不写**(`DEBUG` 就不会落盘);空串 = 不筛 | +| 单份上限 | `log_max_mb` | `5` | 超过就轮转;`0` = 不轮转 | +| 保留份数 | `log_keep` | `3` | 轮转后留几份历史(`<文件>.1`/`.2`/`.3`);`0` = 不轮转 | +| 默认行数 | `log_lines` | `200` | `日志` 命令不写 `-n` 时给几行 | + +**轮转**:`<文件>` → `<文件>.1`、`.1` → `.2`……最老的一份删掉。两个实现要点: + +1. **只在"拉起进程之前"轮转**。运行中的进程按 fd 追加写,中途改名会让它继续写老 inode(等于日志丢了)。所以内核拉起驱动前、引导器拉起内核前各轮转一次——常驻进程写满一份就走新的,简单且不会出错。 +2. **读的时候把历史份也算上**(老 → 新拼接),所以 `-n 200` 拿到的是"跨轮转的最近 200 行",不是"当前文件里的 200 行"。要看单份加 `含轮转=False`(CLI 暂不暴露,留给 `--json` 消费方)。 + +引导器的日志每次跑都轮转一次(它是短命进程,天然没有"运行中改名"的风险)。 + +## 4. CLI(内核与引导器共用同一套开关) + +| 开关 | 含义 | +|---|---| +| `日志` | 内核侧:驱动日志**台账**(谁有日志 / 占用 / 行数 / 最后改动);引导器侧:看引导器自己的日志 | +| `日志 <驱动名>` | 某个驱动的日志 | +| `日志 --全部` | 所有驱动各一段(`== 名字 ==` 分隔,末尾给"几个没日志") | +| `日志 --内核` / `--引导器` / `--输出` | 换来源(内核结构化 / 引导器动作 / 内核 stdio) | +| `-n 200` | 尾部行数 | +| `-f` | 实时跟(先吐 10 行,Ctrl+C 停;**一次只能跟一个来源**) | +| `--级别 WARN` | 只要这个级别及以上(驱动原始日志按字样猜) | +| `-g 关键词` | 只要含它的行(不区分大小写) | +| `--json` | JSON Lines(结构化日志拆成 `{时刻,级别,来源,消息}`;裸行给 `来源:""` + 猜的级别) | + +两种入口都对上同一份解析(`日志.解析选项`),所以 `UEFI.boot.py 日志 --级别 WARN`、`UEFI.boot.py 内核 日志 --输出`、 +`UEFI.boot.py 日志 样板常驻 -g 心跳` 写法一致。参数写错一律**退出码 1 + 打用法**,不猜。 + +## 5. 实现摆放(`内核/日志.py`) + +| 函数 | 作用 | +|---|---| +| `记(路径, 级别, 来源, 消息, 门槛, 控制台)` | 写一条(门槛不过返回 `False`;写失败返回 `False` **不抛**,不拦调用方干活) | +| `追加(路径, 文本)` | 追加一行(父目录自动建) | +| `解析行(行)` / `猜级别(行)` / `命中(行, 级别, 关键词)` | 拆字段 / 猜级别 / 过滤判据 | +| `尾(路径, 行数, 级别, 关键词, 含轮转)` | 尾读(过滤在取尾**之前**) | +| `跟(路径, 级别, 关键词)` | 实时跟:**按行缓冲**(只打完整行,半行留到下一轮)+ 轮转/清空后位置归零 | +| `轮转(路径, 上限字节, 保留份数)` / `轮转名单(路径)` / `带序路径(路径, 序)` | 轮转与历史文件枚举 | +| `解析选项(参数, 默认行数)` / `打印(行表, json输出)` / `转json行(行表)` | CLI 参数与输出(内核、引导器共用) | +| `内核日志路径(根)` / `内核输出路径(根)` / `引导器日志路径(根)` / `驱动日志路径(目录, 名)` | **路径集中定义**——不再各处拼字符串(引导器踩过"常量被同名函数遮蔽"的坑) | +| `控制台开()` | 读 `EFI_LOG_CONSOLE`(引导器起守护时置 0) | + +`进程.启动()` 新增一个可选参数 `分隔`:非空时在日志里先写一条 `==== 启动 <称呼> <命令> <时刻> ====`。 +追加写的日志多轮启动会连成一片,分隔头把它切开,也给人一个 tail 定位锚点(实测:`日志 样板常驻` 一眼能分出哪段是这次起的)。**起始字节偏移改在写分隔头之后取**——秒退死因只算"这一次"的输出(原来那条铁律不能破)。 + +## 6. 错误处理与边界 + +| 场景 | 行为 | +|---|---| +| 日志文件不存在 | 尾读给空表(不抛);CLI 打 `还没有 <称呼> 的日志文件: <路径>`,退出码 0("还没跑过"不是错) | +| 日志写不进去(权限/盘满) | `记()` 返回 `False`,**不抛**——日志失败绝不能拦内核干活;但调用方该报的错照原文报(不静默吞) | +| 轮转失败(改名撞车/权限) | 返回 `False`,不抛;日志继续追加(下次启动再试) | +| `-f` 跟的过程中文件被轮转 | 大小比位置小 → 位置归 0、缓冲清空,重头读(不会卡在旧偏移上什么都不出) | +| `-f` 加到一半被读到 | 按行缓冲:半行留到下一轮(不打印半个行,不乱码) | +| 驱动日志是别人的格式 | 解析不了就给 `None` + 猜级别,`--json` 里 `来源` 给空串(**如实,不编**) | +| `-f` + `--全部` 一起给 | 报"一次只能跟一个来源",退出码 1 | +| 级别/行数参数写错 | 明确报错 + 打用法,退出码 1(不静默用默认值) | + +## 7. 验收(都是真机,不 mock) + +`./.venv/bin/python 内核/自测日志.py`——86 项,10 组:级别门槛 / 行解析与猜级别 / 写(门槛过滤)/ 尾读(行数·级别·关键词)/ **轮转(真造 6MB 超限文件,验证 `.1`/`.2` 与"最老被挤掉")** / **实时跟(真起一个子进程跟日志,追加两条看它只吐 ERROR 那条)** / 选项解析(含所有报错分支)/ JSON Lines / **启动分隔头(真起进程,走 `进程.启动`)** / 清场与残留。 +隔离:全部文件在 `tempfile.mkdtemp()` 里,跑完删掉;开头结尾各拍一次生产 `内核/logs/*.log` 快照核对,**一个字节都不碰**。 + +真机端到端(2026-09-16 实测一遍): + +```bash +python3 UEFI.boot.py 内核 启动 --守护 # 内核.log 只有结构化行; 驱动清单进了 内核.out.log +./.venv/bin/python 内核/内核.py 启动 样板常驻 # 驱动日志出现 ==== 启动 样板常驻 … ==== 分隔头 +head -c 6000000 /dev/zero | tr '\0' 'x' >> 驱动/样板常驻/logs/样板常驻.log # 撑过 5MB +./.venv/bin/python 内核/内核.py 停止 样板常驻 && ./.venv/bin/python 内核/内核.py 启动 样板常驻 +ls -la 驱动/样板常驻/logs/ # 样板常驻.log.1 (6.0MB) + 新的小文件 +./.venv/bin/python 内核/内核.py 日志 # 台账: 占用 / 行数 / 最后改动 (+1 份历史) +python3 试跑引导器.py # 端到端 11 项全过, 残留无 +uvx pyright && uvx basedpyright # 0 errors / 0 warnings +``` + +## 8. 本版不做(留给后面) + +- **日志落库**:不建 `logs` 表。结构化汇报走 `events` 表(驱动与自己往那写),日志文件管"翻旧账 + tail + 重定向"。两份真相没意义。 +- **日志压缩 / 归档到别的盘**:轮转只改名不压缩(`gzip` 会让 tail 变复杂,暂不需要)。 +- **按时间窗查询**(`--从 … --到 …`):`-n` + `-g` 够用,真需要时再加(`解析行` 已经给出时刻字段,加它便宜)。 +- **多来源合并跟**(`-f` 同时跟内核 + 全部驱动):一次一个来源,简单可靠;要合并用 `日志 --全部`。 +- **日志格式的向后兼容**:旧格式(无来源字段)解析不了就退化成本行匹配,不报错。 diff --git a/设计/图/00-总览.drawio b/设计/图/00-总览.drawio new file mode 100644 index 0000000..84c94a1 --- /dev/null +++ b/设计/图/00-总览.drawio @@ -0,0 +1,123 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/设计/图/00-总览.png b/设计/图/00-总览.png new file mode 100644 index 0000000..9ca97ea Binary files /dev/null and b/设计/图/00-总览.png differ diff --git a/设计/图/01-引导器-启动时序.drawio b/设计/图/01-引导器-启动时序.drawio new file mode 100644 index 0000000..2510195 --- /dev/null +++ b/设计/图/01-引导器-启动时序.drawio @@ -0,0 +1,88 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/设计/图/01-引导器-启动时序.png b/设计/图/01-引导器-启动时序.png new file mode 100644 index 0000000..eef8221 Binary files /dev/null and b/设计/图/01-引导器-启动时序.png differ diff --git a/设计/图/02-内核-总调度.drawio b/设计/图/02-内核-总调度.drawio new file mode 100644 index 0000000..d36a9be --- /dev/null +++ b/设计/图/02-内核-总调度.drawio @@ -0,0 +1,94 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/设计/图/02-内核-总调度.png b/设计/图/02-内核-总调度.png new file mode 100644 index 0000000..8413e8f Binary files /dev/null and b/设计/图/02-内核-总调度.png differ diff --git a/设计/图/03-内核调用-时序.drawio b/设计/图/03-内核调用-时序.drawio new file mode 100644 index 0000000..714e021 --- /dev/null +++ b/设计/图/03-内核调用-时序.drawio @@ -0,0 +1,103 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/设计/图/03-内核调用-时序.png b/设计/图/03-内核调用-时序.png new file mode 100644 index 0000000..e144803 Binary files /dev/null and b/设计/图/03-内核调用-时序.png differ diff --git a/设计/图/04-驱动-生命周期.drawio b/设计/图/04-驱动-生命周期.drawio new file mode 100644 index 0000000..cff34e7 --- /dev/null +++ b/设计/图/04-驱动-生命周期.drawio @@ -0,0 +1,116 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/设计/图/04-驱动-生命周期.png b/设计/图/04-驱动-生命周期.png new file mode 100644 index 0000000..c8554aa Binary files /dev/null and b/设计/图/04-驱动-生命周期.png differ diff --git a/设计/图/gen.py b/设计/图/gen.py new file mode 100644 index 0000000..467051d --- /dev/null +++ b/设计/图/gen.py @@ -0,0 +1,345 @@ +#!/usr/bin/env python3 +"""内核项目设计图生成器 —— 按老板标准格式(椭圆起止/菱形判断/红虚线失败分支/平行四边形输入输出)。 + +生成 5 张图到本目录: + 00-总览.drawio 四层架构 + PG 内存总线 + 01-引导器-启动时序.drawio 引导器三职责的启动主链 + 02-内核-总调度.drawio 扫描 → 契约匹配 → 排序 → 就绪 → 拉起 + 03-内核调用-时序.drawio 主驱动 / PG / 内核 / 副驱动 四方时序(零通道) + 04-驱动-生命周期.drawio 驱动状态机 invalid→stopped→starting→running→… + +边元组可带第 6 项 waypoints=[(x,y),...](绝对坐标),用于把回环/跨列线走到空白带。 +""" +import sys +import xml.etree.ElementTree as ET +from pathlib import Path + +OUT = Path(__file__).resolve().parent + + +def esc(s: str) -> str: + return (s.replace('&', '&').replace('<', '<').replace('>', '>') + .replace('"', '"').replace('\n', ' ')) + + +VS = { + 'box': 'rounded=0;whiteSpace=wrap;html=1;', + 'start': 'ellipse;whiteSpace=wrap;html=1;fillColor=#d5e8d4;strokeColor=#82b366;', + 'io': 'shape=parallelogram;perimeter=parallelogramPerimeter;whiteSpace=wrap;html=1;', + 'sub': 'shape=mxgraph.flowchart.predefined_process;whiteSpace=wrap;html=1;', + 'diamond': 'rhombus;whiteSpace=wrap;html=1;', + 'db': 'shape=cylinder3;whiteSpace=wrap;html=1;boundedLbl=1;backgroundOutline=1;size=15;fillColor=#dae8fc;strokeColor=#6c8ebf;', + 'note': 'shape=note;whiteSpace=wrap;html=1;size=15;fillColor=#fff2cc;strokeColor=#d6b656;align=left;spacingLeft=6;', + 'hdr': 'rounded=1;whiteSpace=wrap;html=1;fillColor=#dae8fc;strokeColor=#6c8ebf;fontStyle=1;', + 'cont': 'swimlane;html=1;startSize=30;fillColor=#f5f5f5;strokeColor=#666666;fontStyle=1;', +} + + +def ED(ex, ey, nx, ny, dashed=False, color='#000000', bidir=False): + s = ('edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;' + 'horizontal=1;labelBackgroundColor=#ffffff;' + f'exitX={ex};exitY={ey};exitDx=0;exitDy=0;entryX={nx};entryY={ny};entryDx=0;entryDy=0;') + if bidir: + s += 'startArrow=classic;endArrow=classic;' + if dashed or color != '#000000': + s += f'dashed=1;strokeColor={color};fontColor={color};' + return s + + +def V(**k): # 竖直向下:底出口 → 顶入口 + return ED(0.5, 1, 0.5, 0, **k) + + +def R(**k): # 向右:右出口 → 左入口 + return ED(1, 0.5, 0, 0.5, **k) + + +def L(**k): # 向左:左出口 → 右入口 + return ED(0, 0.5, 1, 0.5, **k) + + +RED = {'dashed': True, 'color': '#cc0000'} +GRAY = {'dashed': True, 'color': '#888888'} + + +def doc(page_w, page_h, hdr, hdr_box, nodes, edges, containers=()): + """hdr_box=(x,y,w,h);nodes=(id,text,kind,x,y,w,h,parent);containers=(id,title,x,y,w,h)""" + hx, hy, hw, hh = hdr_box + L = ['', + ' ', + f' ', + ' ', + ' ', + ' ', + f' ', + f' ', + ' '] + for cid, title, x, y, w, h in containers: + L.append(f' ') + L.append(f' ') + L.append(' ') + for nid, text, kind, x, y, w, h, parent in nodes: + L.append(f' ') + L.append(f' ') + L.append(' ') + for rec in edges: + eid, src, dst, style, label = rec[0], rec[1], rec[2], rec[3], rec[4] + pts = rec[5] if len(rec) > 5 else None + v = f' value="{esc(label)}"' if label else '' + L.append(f' ') + if pts: + L.append(' ') + L.append(' ') + for px, py in pts: + L.append(f' ') + L.append(' ') + L.append(' ') + else: + L.append(' ') + L.append(' ') + L += [' ', ' ', ' ', ''] + return '\n'.join(L) + + +# ───────────────────────── 00 总览 ───────────────────────── +def g00(): + nodes = [ + ('cli', 'UEFI.boot.py <子命令>\n列表 / 启动 / 停止 / 状态 / 日志', 'box', 20, 44, 480, 50, 'C1'), + ('out', '答案与日志直接打终端(纯 CLI,没有 TUI)', 'box', 540, 44, 480, 50, 'C1'), + ('env', '管内核环境\n解释器 / venv 健康 / 路径', 'box', 20, 46, 320, 68, 'C2'), + ('pkg', '管包状态\n缺什么 / 版本不符 / 索引源', 'box', 360, 46, 320, 68, 'C2'), + ('kpr', '管内核进程\n拉起 / 判活 / 日志 / 记账', 'box', 700, 46, 320, 68, 'C2'), + ('kcmd', '命令循环\ncommands + LISTEN/NOTIFY', 'box', 20, 46, 240, 70, 'C3'), + ('kmatch', '契约匹配与排序\nprovides ↔ needs 拓扑', 'box', 280, 46, 240, 70, 'C3'), + ('karb', '转发仲裁\n锁 / 超时 / 越权 / 成环', 'box', 540, 46, 240, 70, 'C3'), + ('kproc', '驱动进程与状态\n/proc 判活 · 快照', 'box', 800, 46, 220, 70, 'C3'), + ('r1', '主驱动 resident\n常驻等活 · LISTEN driver_<名>', 'box', 15, 46, 270, 70, 'C4'), + ('r2', '副驱动 oneshot\n按需拉起 · 跑完收尸', 'box', 315, 46, 270, 70, 'C4'), + ('skill', 'web.json = {html, css, js}\n由驱动解释执行(样本:Json解码)', 'box', 20, 46, 340, 80, 'C6'), + ('t1', 'drivers', 'db', 20, 46, 190, 46, 'C5'), + ('t2', 'driver_state', 'db', 230, 46, 190, 46, 'C5'), + ('t3', 'events', 'db', 440, 46, 190, 46, 'C5'), + ('t4', 'calls', 'db', 650, 46, 190, 46, 'C5'), + ('t5', 'commands', 'db', 860, 46, 160, 46, 'C5'), + ('t6', 'scans', 'db', 20, 108, 190, 46, 'C5'), + ('t7', 'kernel_env', 'db', 230, 108, 190, 46, 'C5'), + ('t8', 'kernel_runs', 'db', 440, 108, 190, 46, 'C5'), + ('t9', '契约数据表(驱动产出 / 取用,表名由内核约定并注入)', 'db', 650, 108, 370, 46, 'C5'), + ('sum', '一句话:引导器管内核(环境 / 包 / 进程)→ 内核管驱动(总调度 / 转发 / 反打架)→ 驱动互不认识、零通道。\n' + '所有状态与请求都落在 PostgreSQL 里 —— 零 socket、零自造协议。', + 'note', 60, 1000, 1040, 80, '1'), + ] + edges = [ + ('e1', 'C1', 'C2', V(), '命令 + 参数'), + ('e2', 'C2', 'C3', V(), '启动并看住内核进程'), + ('e3', 'C3', 'C4', ED(0.5, 1, 0.5, 0, bidir=True), '启停 / 转发 / 判活'), + ('e4', 'C3', 'C5', ED(0.5, 1, 0.5, 0, bidir=True), '', + [(1120, 570), (1120, 770)]), + ('e5', 'C4', 'C6', R(**GRAY), 'v0.2'), + ('e6', 'C6', 'C5', ED(0.5, 1, 0.85, 0, **GRAY), 'v0.2 加载'), + ('e7', 'C4', 'C5', ED(0.35, 1, 0.35, 0, **GRAY), '只写 calls / events,读契约表'), + ] + containers = [ + ('C1', '用户 / CLI(人敲命令)', 60, 100, 1040, 110), + ('C2', '① 引导器 UEFI.boot.py(短命 CLI · 零第三方依赖)', 60, 240, 1040, 140), + ('C3', '② 内核(常驻 · 总调度 · 纯 CLI 命令 + PG 当内存)', 60, 410, 1040, 160), + ('C4', '③ 驱动(互不认识 · 零通道 · 只声明「我要什么 / 我产出什么」)', 60, 600, 600, 140), + ('C6', '④ 程序 / Skill(声明式 · v0.2 占位)', 720, 600, 380, 140), + ('C5', 'PostgreSQL efi_kernel = 内存 / 总线(唯一通道)', 60, 770, 1040, 180), + ] + return doc(1160, 1120, '内核项目 总览 / 用户级虚拟操作系统框架\n' + '四层:引导器(UEFI) → 内核 → 驱动 → 程序(Skill)', + (100, 20, 960, 54), nodes, edges, containers) + + +# ───────────────────────── 01 引导器 ───────────────────────── +def g01(): + nodes = [ + ('st', '开始:python3 UEFI.boot.py <子命令>', 'start', 253, 60, 320, 50, '1'), + ('n1', '读 环境.efi.json(唯一一份配置)', 'box', 283, 138, 260, 50, '1'), + ('n2', '环境体检(6 项 · 纯 stdlib)\n解释器 / venv 健康 / 包 / 驱动目录 / PG', 'sub', 233, 216, 360, 62, '1'), + ('d1', '体检通过 ?', 'diamond', 308, 306, 210, 80, '1'), + ('f1', '打印原因 + 修复命令\n退出 1(不自动改)', 'box', 570, 314, 230, 64, '1'), + ('n3', '写 环境状态.efi.json(快照,原子替换)', 'box', 253, 416, 320, 50, '1'), + ('n4', '写 PG kernel_env\n(连不上 → WARN 降级,不算失败)', 'box', 263, 494, 300, 58, '1'), + ('d2', '内核在跑 ?', 'diamond', 308, 580, 210, 80, '1'), + ('n5', '拉起内核进程\n(常驻调度器)', 'box', 570, 588, 230, 64, '1'), + ('n6', '把子命令交给内核(argv 透传)→ 等结果', 'box', 253, 690, 320, 50, '1'), + ('n7', '打印内核输出 + 记 kernel_runs\n(起止 / 耗时 / 退出码)', 'box', 253, 768, 320, 58, '1'), + ('en', '结束(不管子进程:驱动是独立进程组)', 'start', 253, 856, 320, 50, '1'), + ('tip', '引导器自己的命令(自检 / 环境 / 包)PG 连不上只降级;\n透传给内核的命令不降级 —— 内核的内存就是 PG。', + 'note', 60, 950, 700, 66, '1'), + ] + edges = [ + ('e1', 'st', 'n1', V(), ''), + ('e2', 'n1', 'n2', V(), ''), + ('e3', 'n2', 'd1', V(), ''), + ('e4', 'd1', 'n3', V(), '是'), + ('e5', 'd1', 'f1', R(**RED), '否'), + ('e6', 'n3', 'n4', V(), ''), + ('e7', 'n4', 'd2', V(), ''), + ('e8', 'd2', 'n6', ED(0.5, 1, 0.35, 0), '是'), + ('e9', 'd2', 'n5', R(), '否'), + ('e10', 'n5', 'n6', ED(0.5, 1, 0.75, 0), ''), + ('e11', 'n6', 'n7', V(), ''), + ('e12', 'n7', 'en', V(), ''), + ] + return doc(827, 1060, '模块:引导器 UEFI.boot.py(内核的管家)\n' + '输入: 子命令 | 输出: 内核结果 + 环境状态快照 + 台账', + (93, 20, 640, 54), nodes, edges) + + +# ───────────────────────── 02 内核 总调度 ───────────────────────── +def g02(): + nodes = [ + ('st', '开始:内核 boot / 收到「扫描」命令', 'start', 253, 60, 320, 50, '1'), + ('n1', '扫 驱动/:认根目录的 配置.efi.json', 'sub', 253, 138, 320, 50, '1'), + ('n2', '读配置 + 9 条校验\n(入口越界 / 重名 / 版本 / venv…)', 'box', 253, 216, 320, 58, '1'), + ('n3', '建契约图:provides ↔ needs\n只认契约名,不认驱动名', 'io', 253, 302, 320, 58, '1'), + ('d1', '契约齐全且无环 ?', 'diamond', 308, 392, 210, 80, '1'), + ('f1', '相关驱动标 invalid\n(契约无人提供 / 成环)\n不影响其他驱动', 'box', 570, 392, 230, 80, '1'), + ('n4', '拓扑排序 → 得到启动顺序', 'box', 283, 502, 260, 50, '1'), + ('n5', '按序就绪等待:上游起来才放行下游', 'box', 253, 580, 320, 50, '1'), + ('n6', '拉起驱动进程(resident 常驻 / oneshot 按需)', 'box', 253, 658, 320, 50, '1'), + ('n7', '写 PG driver_state + 运行.efi.json 快照', 'box', 253, 736, 320, 50, '1'), + ('n8', '进常驻调度环:听 commands / calls', 'box', 253, 814, 320, 50, '1'), + ('en', '本轮调度结束(内核进程不退出)', 'start', 253, 892, 320, 50, '1'), + ('f2', '上游崩 → 下游标「依赖失效」\n停上游 → 下游级联停', 'box', 600, 658, 200, 76, '1'), + ('f3', '请求越权 / 调用成环 → denied\n超时 → timeout + 释放锁', 'box', 600, 814, 200, 76, '1'), + ] + edges = [ + ('e1', 'st', 'n1', V(), ''), + ('e2', 'n1', 'n2', V(), ''), + ('e3', 'n2', 'n3', V(), ''), + ('e4', 'n3', 'd1', V(), ''), + ('e5', 'd1', 'n4', V(), '是'), + ('e6', 'd1', 'f1', R(**RED), '否'), + ('e7', 'n4', 'n5', V(), ''), + ('e8', 'n5', 'n6', V(), ''), + ('e9', 'n6', 'n7', V(), ''), + ('e10', 'n7', 'n8', V(), ''), + ('e11', 'n8', 'en', V(), ''), + ('e12', 'n6', 'f2', R(**RED), '崩溃 / 级联'), + ('e13', 'n8', 'f3', R(**RED), '反打架'), + ] + return doc(827, 1000, '模块:内核 · 总调度(启动期)\n' + '输入: 驱动目录 | 输出: 启动顺序 + driver_state + 常驻调度环', + (93, 20, 640, 54), nodes, edges) + + +# ───────────────────────── 03 内核调用 时序 ───────────────────────── +def g03(): + nodes = [ + ('a1', '① 写请求\ncalls: want=契约名', 'box', 20, 60, 230, 64, 'CA'), + ('a2', '⑩ 读回数据\n(从 PG 契约表取)', 'box', 20, 890, 230, 70, 'CA'), + ('p1', '② 请求入库\nstate = pending', 'box', 20, 60, 230, 64, 'CP'), + ('p2', '③ NOTIFY 唤醒内核', 'box', 20, 150, 230, 50, 'CP'), + ('p3', '⑦ 产出入库(契约表)', 'box', 20, 620, 230, 58, 'CP'), + ('p4', '⑨ result 回填 + 释放锁', 'box', 20, 800, 230, 58, 'CP'), + ('p5', '⑩ NOTIFY 请求方(或轮询)', 'box', 20, 890, 230, 58, 'CP'), + ('k1', '④ 校验:谁提供 / 权限 / 锁 /\n超时 / 调用链成环', 'box', 20, 150, 260, 70, 'CK'), + ('k2', '⑤ 已被占用 → 排队 waiting', 'box', 20, 270, 260, 50, 'CK'), + ('k3', '⑥ 匹配提供方 → 转发', 'box', 20, 366, 260, 58, 'CK'), + ('k4', '⑧ LISTEN 到完成 → 写 result', 'box', 20, 720, 260, 58, 'CK'), + ('b1', '执行\nresident 收转发 / oneshot 被拉起', 'box', 20, 366, 230, 70, 'CB'), + ('b2', '产出写 PG(不回传)', 'box', 20, 620, 230, 58, 'CB'), + ('note', '防打架六条:① 同一 lock_key 串行化(先到先执行,后面排队)② 调用链成环 → denied ③ 提供方没起 → 先起 / 按需拉起\n' + '④ deadline 超时 → 内核收权并释放锁 ⑤ 只能要自己 needs 里声明过的契约 ⑥ 权限分家:驱动只写 calls / events,\n' + 'commands 只有 CLI 和引导器能写,drivers / driver_state 只有内核能写\n' + '★ 左列(主驱动)与右列(副驱动)之间没有任何连线 —— 这就是「打架无从谈起」的物理保证。', + 'note', 60, 1150, 1170, 150, '1'), + ] + edges = [ + ('e1', 'a1', 'p1', R(), '① INSERT'), + ('e2', 'p1', 'p2', V(), ''), + ('e3', 'p2', 'k1', R(), '③ NOTIFY'), + ('e4', 'k1', 'k2', V(), '否:已被占用'), + ('e5', 'k2', 'k3', V(), '轮到自己'), + ('e6', 'k3', 'b1', R(), '⑥ 转发(提供方由内核匹配)'), + ('e7', 'b1', 'b2', V(), ''), + ('e8', 'b2', 'p3', L(), '⑦ 写契约表'), + ('e9', 'p3', 'k4', R(), '⑧ 内核 LISTEN 到完成'), + ('e10', 'k4', 'p4', ED(0.5, 1, 0.5, 0), '⑨ 写 result + 释放锁'), + ('e11', 'p4', 'p5', V(), ''), + ('e12', 'p5', 'a2', L(), '⑩ 通知 done'), + ] + containers = [ + ('CA', '主驱动(请求方 · 不认识对面是谁)', 60, 110, 270, 1000), + ('CP', 'PostgreSQL efi_kernel(总线)', 350, 110, 270, 1000), + ('CK', '内核(常驻调度器 · 唯一仲裁者)', 640, 110, 300, 1000), + ('CB', '副驱动(提供方)', 960, 110, 270, 1000), + ] + return doc(1320, 1330, '模块:内核调用时序(驱动 → 内核 → 驱动,数据经 PG)\n' + '输入: calls 请求 | 输出: result + 契约表产出(驱动之间零通道)', + (340, 20, 640, 54), nodes, edges, containers) + + +# ───────────────────────── 04 驱动 生命周期 ───────────────────────── +def g04(): + nodes = [ + ('st', '开始:内核扫描驱动', 'start', 253, 60, 320, 50, '1'), + ('d0', '配置合法 ?', 'diamond', 308, 138, 210, 80, '1'), + ('f0', 'invalid\n拒载(不影响其他驱动)', 'box', 590, 146, 220, 64, '1'), + ('n1', 'stopped(没在跑,正常态)', 'box', 283, 250, 260, 50, '1'), + ('n2', 'starting(已 spawn,未确认存活)', 'box', 263, 328, 300, 50, '1'), + ('d1', '探活通过 ?', 'diamond', 308, 406, 210, 80, '1'), + ('f1', 'failed\n入口不存在 / 秒退(记 exit_code)', 'box', 580, 414, 230, 76, '1'), + ('n3', 'running(/proc cmdline 校验通过才算)', 'box', 243, 514, 340, 56, '1'), + ('n4', 'crashed(断电收尸)\nPG 说 running 但 /proc 不在', 'box', 610, 514, 200, 76, '1'), + ('d2', '还在跑 ?', 'diamond', 308, 604, 210, 80, '1'), + ('n5', 'exited(进程没了,记 exit_code)', 'box', 263, 710, 300, 50, '1'), + ('d3', 'restart = on-failure ?', 'diamond', 308, 788, 210, 80, '1'), + ('n6', 'stopped(收尾完成)', 'box', 283, 894, 260, 50, '1'), + ('en', '结束:状态写 PG driver_state + 运行.efi.json 快照', 'start', 213, 972, 400, 50, '1'), + ('tip', 'resident = 常驻等活;oneshot = 按需拉起、跑完收尸。\n两种状态机完全相同,区别只在「谁触发启动」。', + 'note', 60, 1052, 700, 66, '1'), + ] + edges = [ + ('e1', 'st', 'd0', V(), ''), + ('e2', 'd0', 'n1', V(), '是'), + ('e3', 'd0', 'f0', R(**RED), '否'), + ('e4', 'n1', 'n2', V(), '启动'), + ('e5', 'n2', 'd1', V(), ''), + ('e6', 'd1', 'n3', V(), '是'), + ('e7', 'd1', 'f1', R(**RED), '否'), + ('e8', 'n3', 'd2', V(), ''), + ('e9', 'n3', 'n4', R(**RED), '断电 / 被杀'), + ('e10', 'd2', 'n3', ED(0, 0.5, 0, 0.5), '是(持续判活)', [(110, 644), (110, 542)]), + ('e11', 'd2', 'n5', V(), '否'), + ('e12', 'n5', 'd3', V(), ''), + ('e13', 'd3', 'n2', ED(0, 0.5, 0, 0.5, **RED), '是 → 重拉,并按契约恢复下游', [(190, 828), (190, 353)]), + ('e14', 'd3', 'n6', V(), '否'), + ('e15', 'n6', 'en', V(), ''), + ('e16', 'n4', 'n6', V(), '收尸完成'), + ] + return doc(827, 1150, '模块:驱动生命周期(状态机)\n' + '输入: 扫描结果 / 启停命令 | 输出: driver_state + 运行.efi.json', + (93, 20, 640, 54), nodes, edges) + + +def validate(path: Path) -> None: + t = ET.parse(path) + cells = t.getroot().findall('.//mxCell') + ids = {c.get('id') for c in cells} + bad = [(c.get('id'), c.get('source'), c.get('target')) for c in cells + if c.get('edge') == '1' and (c.get('source') not in ids or c.get('target') not in ids)] + nv = len([c for c in cells if c.get('vertex') == '1']) + ne = len([c for c in cells if c.get('edge') == '1']) + print(f'{path.name}: {nv} 节点 {ne} 连线 断链={"无" if not bad else bad}') + assert not bad, bad + + +if __name__ == '__main__': + files = { + '00-总览.drawio': g00(), + '01-引导器-启动时序.drawio': g01(), + '02-内核-总调度.drawio': g02(), + '03-内核调用-时序.drawio': g03(), + '04-驱动-生命周期.drawio': g04(), + } + for name, xml in files.items(): + p = OUT / name + p.write_text(xml, encoding='utf-8') + validate(p) + print('全部生成完毕 ->', OUT) + sys.exit(0) diff --git a/试跑引导器.py b/试跑引导器.py new file mode 100755 index 0000000..53f1e6a --- /dev/null +++ b/试跑引导器.py @@ -0,0 +1,363 @@ +#!/usr/bin/env python3 +"""试跑引导器: 把 UEFI.boot.py 的命令真跑一遍, 原样贴输出 + 逐条判定. + +用法: + python3 试跑引导器.py # 只读验收 (不碰任何文件) + python3 试跑引导器.py --假内核 # 额外演示"引导器管内核进程" (临时换入假内核, 跑完自动还原) + +为什么要有它: + 引导器能不能立得住, 得真跑一遍才算数.老板亲自跑: 每步都打**命令原文 + 原样输出**, + 不加工不摘要; 末了给一张汇总表, 退出码 0 = 没有 FAIL. + +判定口径 (三种, 别混): + PASS 退出码和输出都符合预期 + 预期 按设计就该这样 (例: 内核入口还是 0 字节, 空内核进来秒退是对的, 不算错) + FAIL 真出问题了 (退出码不对 / 输出缺关键内容 / 环境状态文件对不上) + +只读原则: + 默认模式不改任何文件 (体检 --check 只读; 环境 --json 会写环境状态快照, 那是它的正常产出). + --假内核 会临时改 内核/内核.py, 但 finally 里一定按**原样字节**还原, 并补一次 内核 停止 + 兜底, 绝不留孤儿进程. +""" + +from __future__ import annotations + +import json +import subprocess +import sys +from dataclasses import dataclass +from datetime import datetime, timedelta +from pathlib import Path +from typing import Any, cast + +项目根 = Path(__file__).resolve().parent +# 和 UEFI.boot.py 一个套路: 先把 内核/ 塞进模块搜索路径, 再 import 兄弟模块 +# (共用 内核/文本.py 的表格实现 -- 中文是双宽, len() 排出来是歪的, 只此一份实现别复制) +sys.path.insert(0, str(项目根 / "内核")) + +import 文本 # noqa: E402 -- 必须等 sys.path 改完才能 import + +引导器 = 项目根 / "UEFI.boot.py" +内核入口 = 项目根 / "内核" / "内核.py" +状态文件 = 项目根 / "环境状态.efi.json" + +# 假内核: 只为验证"引导器能不能拉起 / 看住 / 停掉一个常驻进程", 不含任何真逻辑. +# 铁律: 收到 SIGTERM 就正常退出 -- 别装死, 否则引导器只能 SIGKILL (那就不算优雅停止). +假内核源码 = '''#!/usr/bin/env python3 +# 假内核 (临时占位): 主人是 试跑引导器.py, 跑完就被还原, 不会留在这里. +import os +import signal +import sys +import time + + +def 收工(号: int, 帧: object) -> None: + # 引导器停止时给整个进程组发 SIGTERM, 这里干净退出 + print("假内核: 收到信号", 号, "收工", flush=True) + sys.exit(0) + + +signal.signal(signal.SIGTERM, 收工) +print("假内核: 起来了 pid=", os.getpid(), flush=True) +while True: + print("假内核: 心跳", flush=True) + time.sleep(2) +''' + + +def _文本(值: object) -> str: + """把 subprocess 的输出片段统一成 str (text=True 时本来就是 str, 这里兜住 bytes / None).""" + if isinstance(值, str): + return 值 + if isinstance(值, (bytes, bytearray)): + return bytes(值).decode("utf-8", "replace") + return "" + + +@dataclass +class 结果: + """一条步骤的记录, 最后用来渲染汇总表.""" + + 名称: str + 命令: str + 退出码: int + 判定: str + 备注: str = "" + + +记录: list[结果] = [] + + +def 跑(参数: list[str], 超时: int = 180) -> tuple[int, str]: + """跑一条引导器命令, 回收输出. + + 参数: + 参数: 追加在 UEFI.boot.py 后面的参数 (列表透传, 不过 shell, 不怕空格/中文). + 超时: 秒.守护启动要等"秒退判定"落地 (约 1 秒), 给足 180 不会误杀. + 返回: + (退出码, 输出); 输出 = stdout 原样 + 若 stderr 有内容再加一段带 [stderr] 标记的尾巴. + 不截断, 不加工 -- 老板要看的就是原生输出. + 超时返回 -1 (常驻内核前台挂着不返回, 这不是错: 见第 8 项的判定). + """ + try: + 完成 = subprocess.run( + [sys.executable, str(引导器), *参数], + cwd=str(项目根), + capture_output=True, + text=True, + timeout=超时, + ) + except subprocess.TimeoutExpired as 超时了: + # 超时不是失败: 裸跑那种"前台起常驻内核"的命令本来就该一直挂着. + # 注意: subprocess.run 超时只 kill 直接子进程, 孙进程 (引导器前台起的常驻内核) + # 会活下来 -- 调用方还得自己收 (第 8 项就是干这个的). + 片段 = _文本(超时了.stdout) + 尾巴 = _文本(超时了.stderr) + 输出 = 片段 + ("[stderr]\n" + 尾巴 if 尾巴.strip() else "") + return -1, 输出 + f"\n[超时 {超时}s] 命令还挂着 (常驻内核就是这个样子, 已按超时收掉)" + 输出 = 完成.stdout + if 完成.stderr.strip(): + 输出 += "[stderr]\n" + 完成.stderr + return 完成.returncode, 输出 + + +def 记(名称: str, 参数: list[str], 退出码: int, 输出: str, 判定: str, 备注: str = "") -> None: + """打一块"命令原文 + 原样输出 + 判定", 同时记进汇总表. + + 参数: + 名称: 这一步在汇总表里的名字 (对着汇总表看能找回来). + 参数: 命令参数 (空列表 = 这一步不是引导器命令, 例如"看快照文件"). + 退出码: 引导器返回的退出码; 非命令步骤传 0 或 -1. + 输出: 原样输出 (命令步骤就是它吐的原文). + 判定: PASS / 预期 / FAIL. + 备注: 一句话说明判据或失败原因; 走旁路打印, 方便老板直接看出"凭什么这么判". + """ + print(f"\n[第 {len(记录) + 1} 项] {名称}") + if 参数: + print(f"$ {Path(sys.executable).name} UEFI.boot.py {' '.join(参数)}") + if 输出.strip(): + print(输出.rstrip()) + print(f" 退出码 {退出码} | 判定 {判定}" + (f" | {备注}" if 备注 else "")) + 记录.append(结果(名称=名称, 命令=" ".join(参数), 退出码=退出码, 判定=判定, 备注=备注)) + + +def 残留内核进程() -> list[int]: + """扫 /proc 找还活着的内核进程. + + 为什么不用 ps: + 软路由上踩过 -- busybox 的 ps 会截断长命令行, 而且会把**我自己的排查命令**也算进去 + (假计数).直读 /proc//cmdline 最稳, 还能排除自己. + """ + 出: list[int] = [] + 目标 = str(内核入口) + for 目录 in Path("/proc").iterdir(): + if not 目录.name.isdigit(): + continue + try: + 命令行 = (目录 / "cmdline").read_bytes().decode("utf-8", "replace") + except OSError: + continue + if 目标 in 命令行: + 出.append(int(目录.name)) + return 出 + + +def 演示假内核() -> None: + """临时把 内核/内核.py 换成假内核, 看引导器能不能真的管住它. + + 四步一条链: 启动 --守护 -> 状态(运行中) -> 日志(看得到它自己打的话) -> 停止(收干净). + 兜底: finally 里先补一次 内核 停止 (万一中途崩了别留孤儿), 再按原样字节还原真内核文件. + """ + 原字节 = 内核入口.read_bytes() + print(f"\n[假内核] 备份 {内核入口.name} ({len(原字节)} 字节) -> 换入临时假内核") + try: + 内核入口.write_text(假内核源码, encoding="utf-8") + + 码, 出 = 跑(["内核", "启动", "--守护"]) + 状态码, 状态出 = 跑(["内核", "状态"]) + 好 = 码 == 0 and 状态码 == 0 and "运行中" in 状态出 + 记( + "假内核: 启动 --守护", + ["内核", "启动", "--守护"], + 码, + 出, + "PASS" if 好 else "FAIL", + "守护起来了, 且 内核 状态 显示 运行中" if 好 else "期望守护启动成功 + 状态显示 运行中", + ) + 记( + "假内核: 内核 状态", + ["内核", "状态"], + 状态码, + 状态出, + "PASS" if 状态码 == 0 else "FAIL", + "能看到 pid / 启动时刻 / 今日运行次数" if 状态码 == 0 else "", + ) + + 码, 出 = 跑(["内核", "日志", "--输出", "-n", "20"]) + 看到 = "假内核" in 出 + 记( + "假内核: 内核 日志 --输出 (它的话在 out.log 里)", + ["内核", "日志", "--输出", "-n", "20"], + 码, + 出, + "PASS" if 码 == 0 and 看到 else "FAIL", + "日志里看得到假内核自己打的话" if 看到 else "期望日志里出现 假内核 字样", + ) + + 码, 出 = 跑(["内核", "停止"]) + 记("假内核: 内核 停止", ["内核", "停止"], 码, 出, "PASS" if 码 == 0 else "FAIL", + "SIGTERM 给进程组, 连子孙一起收" if 码 == 0 else "") + + 剩 = 残留内核进程() + 记( + "假内核: 残留检查", + [], + 0 if not 剩 else -1, + f"残留进程: {剩}" if 剩 else "进程组收干净了 (无残留)", + "PASS" if not 剩 else "FAIL", + ) + finally: + 跑(["内核", "停止"]) # 兜底: 上面哪步炸了也别留孤儿 + 内核入口.write_bytes(原字节) # 还原真内核文件 (哪怕它是 0 字节) + print(f"[假内核] 已还原 {内核入口.name} ({len(原字节)} 字节)") + + +def main(argv: list[str]) -> int: + """把引导器的每条命令跑一遍, 返回 0 = 没有 FAIL.""" + print("=" * 78) + print(f"试跑引导器 -- 端到端验收 {datetime.now().strftime('%Y-%m-%d %H:%M:%S')}") + print(f" 项目根: {项目根}") + print(f" 解释器: {sys.executable}") + print(f" 模式 : {'含假内核进程演示' if '--假内核' in argv else '只读验收 (加 --假内核 演示管进程)'}") + print("=" * 78) + + # 1) 用法: 纯 stdlib 就能打出命令一览, 任何环境都不该崩 + 码, 出 = 跑(["-h"]) + 判 = "PASS" if 码 == 0 and "用法" in 出 else "FAIL" + 记("用法 (-h)", ["-h"], 码, 出, 判, "" if 判 == "PASS" else "期望退出码 0 且输出含 用法") + + # 2) 体检: 全绿才回 0 (阻塞项会回 1), 所以退出码本身就是判据 + 码, 出 = 跑(["--check"]) + 判 = "PASS" if 码 == 0 else "FAIL" + 记("体检 (--check)", ["--check"], 码, 出, 判, + "6 项全过, 退出码 0" if 判 == "PASS" else "有阻塞项: 按上面的修复命令修完再跑") + + # 3) 机器读的体检: --json 必须是纯 JSON (混了人读的表格就没法直接喂给程序) + 码, 出 = 跑(["环境", "--json"]) + 快照: Any = None + 备注 = "" + try: + 快照 = json.loads(出) + except json.JSONDecodeError as 错: + 备注 = f"stdout 不是纯 JSON: {错}" + if 快照 is None: + 判 = "FAIL" + else: + 体检段 = 快照.get("checks") + # isinstance 窄化出来的只是 list[Unknown], 取 len 会被判"类型部分未知"; cast 明确一次 + 项数 = len(cast(list[Any], 体检段)) if isinstance(体检段, list) else 0 + 判 = "PASS" if 码 == 0 and 快照.get("blocking_ok") is True and 项数 == 6 else "FAIL" + 备注 = f"纯 JSON 可解析, checks {项数} 项, blocking_ok={快照.get('blocking_ok')}" + 记("体检 (机器读 环境 --json)", ["环境", "--json"], 码, 出, 判, 备注) + + # 4) 包台账: 显式命令才有台账看, 不自动装不自动升 + 码, 出 = 跑(["包"]) + 判 = "PASS" if 码 == 0 and "psycopg2-binary" in 出 else "FAIL" + 记("包 (台账)", ["包"], 码, 出, 判, + "台账里有 psycopg2-binary 那一行" if 判 == "PASS" else "期望看到 psycopg2-binary") + + # 5) 快照文件: 体检结论要落盘留痕, 而且得是"刚刚"写的 + if not 状态文件.exists(): + 记("环境状态快照", [], -1, f"{状态文件.name} 不存在", "FAIL", "跑一次 环境 --json 应该会写它") + else: + 数据: Any = json.loads(状态文件.read_text(encoding="utf-8")) + try: + 时刻 = datetime.fromisoformat(str(数据.get("ts", ""))) + 新鲜 = datetime.now(时刻.tzinfo) - 时刻 < timedelta(minutes=5) + except ValueError: + 新鲜 = False + 项数 = len(数据.get("checks") or []) + 好 = 数据.get("efi") == 1 and 新鲜 and 项数 == 6 + 记( + "环境状态快照", + [], + 0 if 好 else -1, + f"{状态文件.name}: efi={数据.get('efi')} ts={数据.get('ts')} " + f"checks={项数} 新鲜={新鲜} blocking_ok={数据.get('blocking_ok')}", + "PASS" if 好 else "FAIL", + "刚落盘 (5 分钟内) 且 6 项齐全" if 好 else "快照对不上: 看 ts / checks 数", + ) + + # 6) 内核状态: 还没跑内核时, 它应该老实说"已停止", 而不是编一个假状态 + 码, 出 = 跑(["内核", "状态"]) + 记("内核 状态 (没跑时)", ["内核", "状态"], 码, 出, "PASS" if 码 == 0 else "FAIL", + "状态 + 入口 + 今日运行次数 + 台账来源" if 码 == 0 else "") + + # 7) 透传: 引导器不认识的话一律交给内核, 退出码照内核的回传 + 码, 出 = 跑(["列表"]) + 记("透传 (列表)", ["列表"], 码, 出, "PASS" if 码 == 0 else "FAIL", + "退出码按内核的退出码回传" if 码 == 0 else "") + + # 8) 裸跑 (无参数): 体检 -> 移交内核, 内核的默认动作 = 常驻调度 (甲). + # 三种都算过: 1 内核不在跑 -> 前台常驻 (会一直挂着, 用短超时收掉); + # 2 已经有内核 -> 被独一份调度锁挡下 (该挡就得挡); + # 3 内核自己退了 (退出码 0). + 码, 出 = 跑([], 超时=8) + if 码 == -1: + 判, 备注 = "PASS", "裸跑 = 前台起常驻内核 (设计如此, 会一直挂着); 超时收掉前台壳, 常驻内核留给第 11 项收" + elif 码 == 0: + 判, 备注 = "PASS", "裸跑: 体检 -> 移交内核 -> 内核自己退出 (退出码 0)" + elif "已经有一个内核在跑" in 出: + 判, 备注 = "PASS", "被独一份调度锁挡下 (已经有一个内核在跑) -- 挡得对" + else: + 判, 备注 = "FAIL", "既没正常退出, 也不是'已经有一个内核在跑'" + 记("裸跑 (无参数)", [], 码, 出, 判, 备注) + + # 9) 内核启停: 内核入口空着时, 空内核秒退是**对的**, 引导器该如实报"不像常驻进程" + 空内核 = 内核入口.stat().st_size == 0 + 码, 出 = 跑(["内核", "启动", "--守护"]) + if 空内核: + 判, 备注 = "预期", "内核入口 0 字节: 空内核进来就退是对的 (退出码 1 = 我没把它当常驻)" + elif 码 == 0: + 判, 备注 = "PASS", "守护起来了" + else: + 判, 备注 = "FAIL", "内核有内容但守护没起来: 看上面的日志尾巴" + 记("内核 启动 --守护", ["内核", "启动", "--守护"], 码, 出, 判, 备注) + + 码, 出 = 跑(["内核", "日志", "-n", "10"]) + 记("内核 日志 -n 10", ["内核", "日志", "-n", "10"], 码, 出, "PASS" if 码 == 0 else "FAIL") + + 码, 出 = 跑(["内核", "停止"]) + 记("内核 停止 (幂等)", ["内核", "停止"], 码, 出, "PASS" if 码 == 0 else "FAIL", + "没在跑也回 0 (幂等), 顺手给没写 finished_at 的运行记录收尸" if 码 == 0 else "") + + # 10) 可选: 假内核演示 (只在 --假内核 且内核入口还空着时做, 绝不覆盖老板写的代码) + if "--假内核" in argv: + if 空内核: + 演示假内核() + else: + 记("假内核演示", [], -1, f"跳过: {内核入口.name} 已有内容, 不覆盖", "预期") + + # 收尾: 全跑完再看一眼有没有漏网的进程 + 剩 = 残留内核进程() + + print("\n" + "=" * 78) + print("汇总") + print("=" * 78) + 行表 = [[str(i), r.名称, str(r.退出码), r.判定, r.备注] for i, r in enumerate(记录, 1)] + 文本.打印(文本.表格(["#", "步骤", "退出码", "判定", "备注"], 行表, + [文本.右, 文本.左, 文本.右, 文本.左, 文本.左])) + + 过 = sum(1 for r in 记录 if r.判定 == "PASS") + 预期数 = sum(1 for r in 记录 if r.判定 == "预期") + 败 = sum(1 for r in 记录 if r.判定 == "FAIL") + print(f"\n 合计: PASS {过} | 预期 {预期数} | FAIL {败}") + print(f" 残留: {'无' if not 剩 else f'还有 {剩} 没收 -- 收: python3 UEFI.boot.py 内核 停止'}") + print(f" 结论: {'全过' if 败 == 0 else '有 FAIL, 往上翻对应那块的原样输出'}") + if 空内核: + print(" 提醒: 内核入口还是 0 字节, '常驻'那两项要等 内核/内核.py 写出来才作数") + return 0 if 败 == 0 else 1 + + +if __name__ == "__main__": + sys.exit(main(sys.argv[1:])) diff --git a/驱动/Json解码/json解码.py b/驱动/Json解码/json解码.py new file mode 100644 index 0000000..33a372f --- /dev/null +++ b/驱动/Json解码/json解码.py @@ -0,0 +1,15 @@ +import json +import os +from pathlib import Path + +# 路径不写死了 (内核会把驱动起在驱动文件夹里, 谁也不知道它被拷到哪): +# 先看环境变量 JSON_PATH, 再退回"驱动文件夹往上三层"的项目根 web.json +lujing = Path(os.environ.get("JSON_PATH") or (Path(__file__).resolve().parent.parent.parent / "web.json")) + +with open(lujing, "r", encoding="utf-8") as f: # 打开 json 文件 + jsonData = json.load(f) + +print("文件:", lujing) +print("HTML:\n", jsonData["html"]) +print("caa:\n", jsonData["css"]) +print("js:\n", jsonData["js"]) diff --git a/驱动/Json解码/配置.efi.json b/驱动/Json解码/配置.efi.json new file mode 100644 index 0000000..acd402d --- /dev/null +++ b/驱动/Json解码/配置.efi.json @@ -0,0 +1,9 @@ +{ + "efi": 1, + "name": "Json解码", + "runtime": "python", + "mode": "oneshot", + "entry": "json解码.py", + "interpreter": "system", + "note": "把 web.json ({html,css,js}) 解出来打印 -- 程序/Skill 层的最小样本, 由它来解释执行" +} diff --git a/驱动/样例消费器/请求.py b/驱动/样例消费器/请求.py new file mode 100644 index 0000000..397dfd7 --- /dev/null +++ b/驱动/样例消费器/请求.py @@ -0,0 +1,106 @@ +"""样例消费器: 演示"驱动要别人的东西"的正确姿势. + +[关键点: 我不认识对方] + 我需要 `样板:心跳` 这个**契约**, 但我不知道,也不关心谁提供它 (所以配置里写的是契约名, + 不是驱动名).做法就三步: + 1. 往 calls 表插一行 (caller=我, want=契约名); + 2. 等 state 变成 done (内核转发 -> 提供方干活 -> 回填结果); + 3. 从 result 里取数据. + 这样驱动之间永远不会打架: 它们连对方是谁都不知道, 中间只有一个内核在牵线. +""" + +import json +import os +import signal +import sys +import time +from typing import Any + +# 我要什么 (中立契约名, 不是驱动名!) +契约 = "样板:心跳" +# 等结果的超时 (秒): 等不到就如实说等不到, 别无限等 +等结果秒 = 20.0 +# 两次请求之间歇多久 (秒) +间隔秒 = 60.0 + + +def 连库() -> Any: + """拿 EFI_DB 连 PG; 没库 / 没 psycopg2 就返回 None.""" + 串 = os.environ.get("EFI_DB", "") + if not 串: + print("样例消费器: 没拿到 EFI_DB (不是内核起的? 直接跑就这样)", flush=True) + return None + try: + import psycopg2 # type: ignore[import-untyped] + except ImportError: + print("样例消费器: 这个 python 没装 psycopg2, 没法发请求", flush=True) + return None + try: + 连接对象 = psycopg2.connect(串) + 连接对象.autocommit = True + return 连接对象 + except Exception as 错: + print("样例消费器: 连不上 PG:", 错, flush=True) + return None + + +def 要一次(连接对象: Any) -> int: + """往 calls 表插一行 (want 填契约名), 返回调用 id; 失败返回 0. + + chain 是我自己带的调用链, 内核拿它挡"A->B->A"这种环 (我不知道链上有谁, 只负责如实带上). + """ + 游标 = 连接对象.cursor() + 游标.execute( + "INSERT INTO calls (caller, want, args, lock_key)" + " VALUES (%s, %s, %s::jsonb, %s) RETURNING id", + ("样例消费器", 契约, json.dumps({"chain": ["样例消费器"]}, ensure_ascii=False), 契约), + ) + 行 = 游标.fetchone() + 游标.close() + return int(行[0]) if 行 else 0 + + +def 等结果(连接对象: Any, 调用id: int) -> dict[str, Any]: + """轮询那行 calls 直到有结论 (done / failed / denied / timeout), 返回整行.""" + 线 = time.monotonic() + 等结果秒 + while time.monotonic() < 线: + 游标 = 连接对象.cursor() + 游标.execute("SELECT state, provider, result, error FROM calls WHERE id = %s", (调用id,)) + 行 = 游标.fetchone() + 游标.close() + if 行 is None: + return {"state": "丢了", "error": "那行 calls 不见了"} + 状态值 = str(行[0]) + if 状态值 in ("done", "failed", "denied", "timeout"): + return {"state": 状态值, "provider": 行[1], "result": 行[2], "error": 行[3]} + time.sleep(0.5) + return {"state": "等超时", "error": f"等了 {等结果秒} 秒还没结论"} + + +def 收工(号: int, 帧: object) -> None: + """收到信号干净退出.""" + print("样例消费器: 收到信号", 号, "收工", flush=True) + sys.exit(0) + + +def main() -> int: + """常驻: 每隔一阵要一次 `样板:心跳` 这把契约, 把结果打出来.""" + signal.signal(signal.SIGTERM, 收工) + signal.signal(signal.SIGINT, 收工) + 连接对象 = 连库() + print("样例消费器: 起来了 pid=", os.getpid(), flush=True) + if 连接对象 is None: + # 没有库就没法发请求: 老实待着, 不装成功 + while True: + time.sleep(间隔秒) + while True: + 调用id = 要一次(连接对象) + print(f"样例消费器: 发了个请求 (calls#{调用id}) 要 {契约!r} -- 我不知道谁给我", flush=True) + 结论 = 等结果(连接对象, 调用id) + print(f"样例消费器: 结论 {结论.get('state')} 提供方={结论.get('provider')} " + f"结果={结论.get('result')} 错误={结论.get('error')}", flush=True) + time.sleep(间隔秒) + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/驱动/样例消费器/配置.efi.json b/驱动/样例消费器/配置.efi.json new file mode 100644 index 0000000..ce5f680 --- /dev/null +++ b/驱动/样例消费器/配置.efi.json @@ -0,0 +1,12 @@ +{ + "efi": 1, + "name": "样例消费器", + "runtime": "python", + "mode": "resident", + "entry": "请求.py", + "interpreter": "system", + "needs": ["样板:心跳"], + "autostart": false, + "restart": "no", + "note": "写驱动的另一半样板: 要别人的东西时不认识对方, 只往 calls 表发契约请求, 等内核转发" +} diff --git a/驱动/样板常驻/心跳.py b/驱动/样板常驻/心跳.py new file mode 100644 index 0000000..109a2f6 --- /dev/null +++ b/驱动/样板常驻/心跳.py @@ -0,0 +1,129 @@ +"""样板常驻驱动: 写驱动照着这个抄就行. + +[驱动要知道的三件事] + 1. **没有协议**: 内核不给你发消息, 你也不用回答谁.想汇报就 `INSERT INTO events` 一行 + (见下面 汇报()) -- 内核只读表, 不解析你的 stdout. + 2. **连接串从环境变量 EFI_DB 拿**, 不写进 配置.efi.json (不落盘); + 自己 venv 里没有 psycopg2 也能跑, 只是不能汇报 (内核不会因此判你失败). + 3. **停就好好停**: 收到 SIGTERM 要干净退出.你装死不退, 内核只能 SIGKILL (那就是被硬拉了, + 日志/状态都不好看). + +[内核交给你的东西] + * 工作目录 = 你自己的驱动文件夹 (cwd); + * stdout/stderr 被重定向到 <驱动文件夹>/logs/<驱动名>.log; + * 环境变量: 内核进程的环境 + EFI_DB + 你在 配置.efi.json 里写的 env. +""" + +import json +import os +import signal +import sys +import time +from typing import Any + +# 上报间隔 (秒): 心跳事件别写太密, events 表是总线, 刷满了别人看不见有用的事 +上报秒 = 30.0 +# 屏幕心跳间隔 (秒): stdout 会进日志文件, 老板 tail 日志时能看见它在活着 +打印秒 = 5.0 + + +def 汇报(连接对象: Any, message: str, kind: str = "heartbeat") -> None: + """往 events 表插一行 (驱动唯一的汇报方式: 无协议, 就是往表里写).""" + if 连接对象 is None: + return + try: + 游标 = 连接对象.cursor() + 游标.execute( + "INSERT INTO events (source, driver, level, kind, message, data)" + " VALUES (%s, %s, 'info', %s, %s, %s::jsonb)", + ("样板常驻", "样板常驻", kind, message, json.dumps({"pid": os.getpid()}, ensure_ascii=False)), + ) + 游标.close() + except Exception as 错: # 汇报失败不该把驱动搞死: 记一行 stdout 就够了 + print("汇报失败:", 错, flush=True) + + +def 连库() -> Any: + """拿 EFI_DB 连 PG; 没库/没 psycopg2 就返回 None (照样能跑, 只是不汇报).""" + 串 = os.environ.get("EFI_DB", "") + if not 串: + print("没拿到 EFI_DB (不是内核起的? 直接跑就这样)", flush=True) + return None + try: + import psycopg2 # type: ignore[import-untyped] + except ImportError: + print("这个 python 没装 psycopg2, 跳过汇报", flush=True) + return None + try: + 连接对象 = psycopg2.connect(串) + 连接对象.autocommit = True + return 连接对象 + except Exception as 错: + print("连不上 PG:", 错, flush=True) + return None + + +def 收工(号: int, 帧: object) -> None: + """收到信号就干净退出 (别装死, 否则只能被 SIGKILL).""" + print("样板常驻: 收到信号", 号, "收工", flush=True) + sys.exit(0) + + +def 服务调用(连接对象: Any) -> int: + """收内核转发过来的调用请求 (LISTEN driver_<我的名字>, 内核用 pg_notify 叫醒). + + 这就是"被调的一方"该干的事: + * 常驻驱动自己 LISTEN driver_<驱动名> (通道名只跟"我是谁"有关, 跟谁在调我没关系); + * 收到就干活, 把结果写回 calls.result, 状态置 done; + * **不许碰别人的表, 也不许知道是谁在调我** -- 所以这里只 update calls 自己那行. + 返回: 这一轮处理了几条 (内核的 calls 表是唯一的通道, 没有 socket / 没有消息格式). + """ + if 连接对象 is None: + return 0 + 处理了 = 0 + try: + while 连接对象.notifies: + 通知 = 连接对象.notifies.pop(0) + 调用id = int(通知.payload or 0) + 游标 = 连接对象.cursor() + # 真实驱动在这里产出数据并写进自己的契约表; 样板就回一句"我干了" + 游标.execute( + "UPDATE calls SET state = 'done', result = %s::jsonb, finished_at = now() WHERE id = %s", + (json.dumps({"产出": "样板常驻的产出", "调用": 调用id}, ensure_ascii=False), 调用id), + ) + 游标.close() + print("样板常驻: 接住转发来的调用", 调用id, "并回填结果", flush=True) + 处理了 += 1 + except Exception as 错: # 一次处理失败不该把常驻驱动搞死 + print("样板常驻: 处理调用出错:", 错, flush=True) + return 处理了 + + +def main() -> int: + """常驻循环: 打印心跳 (日志) + 定期往 events 汇报 (总线).""" + signal.signal(signal.SIGTERM, 收工) + signal.signal(signal.SIGINT, 收工) + 连接对象 = 连库() + print("样板常驻: 起来了 pid=", os.getpid(), " 工作目录=", os.getcwd(), flush=True) + print("样板常驻: EFI_DB =", ("有" if os.environ.get("EFI_DB") else "无"), flush=True) + if 连接对象 is not None: + 游标 = 连接对象.cursor() + 游标.execute("LISTEN driver_样板常驻") # 内核转发调用时会 pg_notify 这个通道 + 游标.close() + print("样板常驻: 已经在 LISTEN driver_样板常驻", flush=True) + 汇报(连接对象, "样板常驻起来了", kind="start") + 上次上报 = time.monotonic() + while True: + time.sleep(打印秒) + print("样板常驻: 心跳", flush=True) + if 连接对象 is not None: + 连接对象.poll() # 把网上的 NOTIFY 收进内存 (psycopg2 自带) + 服务调用(连接对象) + 现在 = time.monotonic() + if 现在 - 上次上报 >= 上报秒: + 汇报(连接对象, "样板常驻心跳") + 上次上报 = 现在 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/驱动/样板常驻/配置.efi.json b/驱动/样板常驻/配置.efi.json new file mode 100644 index 0000000..db55954 --- /dev/null +++ b/驱动/样板常驻/配置.efi.json @@ -0,0 +1,12 @@ +{ + "efi": 1, + "name": "样板常驻", + "runtime": "python", + "mode": "resident", + "entry": "心跳.py", + "interpreter": "system", + "provides": ["样板:心跳"], + "autostart": false, + "restart": "no", + "note": "写驱动的样板: 常驻 + 打印心跳 + 往 events 表汇报 (证明驱动不用跟内核握手也能汇报)" +}