feat-chv-closeup:收尾(executor 覆盖到 100%、合并/灰度准备,堆叠于 #264)
- 后端新增 cloud/tests/test_chv_closeup.py 85 项:唤醒失败 (vm.create/vm.boot/API 超时/guest 就绪超时/host key 不匹配/ provisioned 缺钉死)、恢复与快照失败、取消与清理(残留 socket/登记/磁盘/密钥、脏标记)、中途死亡分类、只读 execute 归类、入口校验、preflight 事后改写、防御性 except、worker_adapter 剩余分支;executor.py 覆盖 100%。 - 前端新增 src/api/chv.closeup.test.ts 13 项:显式 false、 undefined、可用性优先级、Workers 旧 bug 文案不复发、 120 秒边界;控制按钮与新页面仍不做。 - 新增 tools/chv_merge_precheck.py(只读):逐个核 base/head/ mergeable/CI/链衔接,实测 11 个 PR 全 NOT-READY(CI 全 FAIL、mergeState 全 UNSTABLE),退出码 1,绝不合并。 - docs/chv-production-readiness.md 新增 §14:审计落盘运维 runbook(定位/轮转/读取/清理,ADR-14 取舍不变)、mypy 默认口径 2 错评估(沙箱验证过的标注修法,本轨不改 其它链文件)、灰度三阶段可执行清单、V-1~V-9 检查表 (全部未验证)、逐项评分。 - 安全语义不变:egress、key seed 投递、磁盘删除、默认 provisioned、tap 校验、前端不绕后端白名单均未放宽。 - 只到 PR OPEN,不合并、不部署;CI 账单仍是硬门禁。
This commit is contained in:
1 parent
2f3ff8b3ec
commit
54f20dfe55
4 files changed
+1759
No files matched your search
@@ -0,0 +1,76 @@
|
|||||||
|
import { describe, expect, it } from "vitest";
|
||||||
|
import { chvAttachmentBlocked, chvAvailability, chvAvailabilityText, chvQueueHint, chvVmBadge, chvVmDisplay, chvVmStatusText, countChvWorkers, isWorkerAlive } from "./chv";
|
||||||
|
|
||||||
|
// closeup 轨补边角:上一轨 chv.test.ts 已覆主路径后仍缺的
|
||||||
|
// 显式 false、undefined、优先级与“旧 bug 文案不复发”断言。
|
||||||
|
// 只测 chv.ts 纯逻辑,不渲染组件、不发请求。
|
||||||
|
describe("chvVmDisplay 边角(closeup)", () => {
|
||||||
|
it("字段显式 false 与 absent 同属 not-reported", () => {
|
||||||
|
const d = chvVmDisplay({ executor: "cloud-hypervisor", chv_vm_dead: false });
|
||||||
|
expect(d.state).toBe("not-reported");
|
||||||
|
expect(d.text).toContain("不代表 VM 活着");
|
||||||
|
});
|
||||||
|
it("任务 undefined 与 null 同属 unknown", () => {
|
||||||
|
expect(chvVmDisplay(undefined).state).toBe("unknown");
|
||||||
|
expect(chvVmDisplay(undefined).text).toContain("无法判断");
|
||||||
|
});
|
||||||
|
it("非 CHV 且无 executor 字段时也是 not-applicable", () => {
|
||||||
|
expect(chvVmDisplay({}).state).toBe("not-applicable");
|
||||||
|
expect(chvVmDisplay({ executor: undefined }).text).toBe("");
|
||||||
|
expect(chvVmStatusText({ executor: "cloud-hypervisor", chv_vm_dead: false })).toContain("未报告 VM 死亡");
|
||||||
|
});
|
||||||
|
it("列表徽标:显式 false 不出徽标", () => {
|
||||||
|
expect(chvVmBadge({ executor: "cloud-hypervisor", chv_vm_dead: false })).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chvAvailability 优先级(closeup)", () => {
|
||||||
|
it("加载失败优先于聚合文案:不把失败写成 aggregate-only", () => {
|
||||||
|
expect(chvAvailability(null, "完整 worker 清单仅全局管理员可见", "加载失败:x")).toEqual(
|
||||||
|
{ state: "unknown", reason: "load-failed" },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("有清单时忽略错误与聚合文案", () => {
|
||||||
|
expect(chvAvailability([{ executor: "cloud-hypervisor", alive: false }], "完整 worker 清单仅全局管理员可见", "加载失败:x")).toEqual(
|
||||||
|
{ state: "known", online: 0, total: 1 },
|
||||||
|
);
|
||||||
|
});
|
||||||
|
it("空清单与空文案是 not-loaded,不是已知 0", () => {
|
||||||
|
expect(chvAvailability(null, "", "")).toEqual({ state: "unknown", reason: "not-loaded" });
|
||||||
|
});
|
||||||
|
it("空 worker 数组是已知 0(与 null 区分)", () => {
|
||||||
|
expect(chvAvailability([], "", "")).toEqual({ state: "known", online: 0, total: 0 });
|
||||||
|
expect(countChvWorkers([])).toEqual({ online: 0, total: 0 });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("chvAvailabilityText:Workers 旧 bug 不复发(closeup)", () => {
|
||||||
|
it("load-failed / not-loaded 绝不写“仅全局管理员可见”", () => {
|
||||||
|
expect(chvAvailabilityText({ state: "unknown", reason: "load-failed" })).not.toContain("仅全局管理员可见");
|
||||||
|
expect(chvAvailabilityText({ state: "unknown", reason: "not-loaded" })).not.toContain("仅全局管理员可见");
|
||||||
|
});
|
||||||
|
it("known 总数为 0 时仍是已知 0,不写未知", () => {
|
||||||
|
const text = chvAvailabilityText({ state: "known", online: 0, total: 0 });
|
||||||
|
expect(text).toContain("在线 0 / 共 0");
|
||||||
|
expect(text).not.toContain("未知");
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("附件拦截与排队提示边角(closeup)", () => {
|
||||||
|
it("executor 为 undefined/null 时附件不拦", () => {
|
||||||
|
expect(chvAttachmentBlocked(undefined, true)).toBe(false);
|
||||||
|
expect(chvAttachmentBlocked(null, true)).toBe(false);
|
||||||
|
});
|
||||||
|
it("排队提示:未知三原因一律为空串", () => {
|
||||||
|
for (const reason of ["aggregate-only", "load-failed", "not-loaded"] as const) {
|
||||||
|
expect(chvQueueHint("cloud-hypervisor", "queued", { state: "unknown", reason })).toBe("");
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
describe("isWorkerAlive 边界(closeup)", () => {
|
||||||
|
it("last_seen 恰好 120 秒算在线(<=120)", () => {
|
||||||
|
const now = Date.now() / 1000;
|
||||||
|
expect(isWorkerAlive({ executor: "cloud-hypervisor", last_seen: now - 120 })).toBe(true);
|
||||||
|
});
|
||||||
|
});
|
||||||
File diff suppressed because it is too large.
Load diff
@@ -572,3 +572,216 @@
|
|||||||
6. 负载测试(V-8)与安全审计结论更新(No-Go → 有条件启用)。
|
6. 负载测试(V-8)与安全审计结论更新(No-Go → 有条件启用)。
|
||||||
7. 前端 live 点击验证(用户侧 live browser 检查清单见
|
7. 前端 live 点击验证(用户侧 live browser 检查清单见
|
||||||
本 PR 描述;回环地址限制下浏览器任务无法代劳)。
|
本 PR 描述;回环地址限制下浏览器任务无法代劳)。
|
||||||
|
|
||||||
|
## 14. 收尾轨 feat-chv-closeup(堆叠于 #264 之上)
|
||||||
|
|
||||||
|
本节是准备工作,不是启用结论。本轨只到 PR OPEN,不合并、
|
||||||
|
不部署;生产就绪与动态安全的外部关卡(§13.4)原样未动。
|
||||||
|
|
||||||
|
### 14.1 本轨量化结果(实测,非上一轨回执转述)
|
||||||
|
|
||||||
|
- 后端:232 项 → 317 项(新增 `cloud/tests/test_chv_closeup.py`
|
||||||
|
85 项),全过。命令(仓库根目录,cwd 须为根目录——
|
||||||
|
在 `cloud/` 下跑会撞 `cloud/queue.py` 遮蔽 stdlib queue,
|
||||||
|
mypy 同理):
|
||||||
|
`python3 -m unittest cloud.tests.test_chv_arch95
|
||||||
|
cloud.tests.test_chv_arch95plus cloud.tests.test_chv_arch_maint_fe
|
||||||
|
cloud.tests.test_chv_fix cloud.tests.test_chv_gaps
|
||||||
|
cloud.tests.test_chv_optimize cloud.tests.test_chv_secfix
|
||||||
|
cloud.tests.test_chv_to10 cloud.tests.test_cloud_hypervisor
|
||||||
|
cloud.tests.test_chv_closeup`
|
||||||
|
- 覆盖率(同命令 + `coverage run --source=cloud/chv`):
|
||||||
|
chv 总 87%(1925 行,缺 258)→ 96%(缺 80);
|
||||||
|
`executor.py` 85%(缺 157)→ 100%(缺 0);
|
||||||
|
`worker_adapter.py` 82%(缺 16)→ 100%(缺 0)。
|
||||||
|
本轨基线与上一轨回执的行数口径略有出入(上一轨称
|
||||||
|
1826 行/缺 242,本轨同命令实测 1925 行/缺 258),
|
||||||
|
本轨前后对比只用本轨同一命令,不混用上一轨数字。
|
||||||
|
- 剩余未覆 80 行(按模块,全部在 executor/worker_adapter
|
||||||
|
之外,本轨未声称覆盖):
|
||||||
|
`api_client.py` 11 行(socket 层 chunked/超时/异常分支,
|
||||||
|
需 socket 级故障注入);`compat215.py` 2 行(旧兼容回落);
|
||||||
|
`config.py` 5 行(非法 env 构造分支);`disk.py` 22 行
|
||||||
|
(快照登记/剪枝边角与配额异常分支);`experimental_pool.py`
|
||||||
|
16 行(实验池,非生产路径);`hardening.py` 11 行
|
||||||
|
(真实 socket 权限与 net 校验边角);`hostkeys.py` 13 行
|
||||||
|
(ssh-keygen/genisoimage 子进程失败分支)。以上均非死
|
||||||
|
代码的断言——未逐行证伪,留待后续轨按模块补,不在本
|
||||||
|
轨凑数。
|
||||||
|
- mypy:silent 口径 `cloud/chv/` 0 错(不变);
|
||||||
|
默认口径 `cloud/marker_tools.py` 仍 2 错(见 §14.3,
|
||||||
|
本轨未改该文件)。
|
||||||
|
- ruff:`--select F,E9,W,I` 口径 `cloud/chv/` +
|
||||||
|
本轨新测试 0 错(不变;默认全规则基线不声称通过)。
|
||||||
|
- 前端:Vitest 23 项 → 36 项(新增
|
||||||
|
`src/api/chv.closeup.test.ts` 13 项),全过。
|
||||||
|
|
||||||
|
### 14.2 审计落盘文件运维 runbook(ADR-14 取舍不变,只补可操作性)
|
||||||
|
|
||||||
|
- 定位:`<state_dir>/chv-audit-spill.jsonl` 与轮转文件
|
||||||
|
`<state_dir>/chv-audit-spill.jsonl.old`。`state_dir`
|
||||||
|
默认 `~/.local/share/codex-app-server/chv-vms`,
|
||||||
|
可由 `CHV_STATE_DIR` 改址;以部署的实际值为准,
|
||||||
|
不要猜。目录本身 0700(见 §13.1 的 H6 门)。
|
||||||
|
- 触发:只有 `_emit` 的审计回调抛错时才写一行;回调
|
||||||
|
成功、无回调(只记日志)时不写。触发时宿主日志必有
|
||||||
|
`chv_audit_failed`,且 `audit_failure_count` +1——
|
||||||
|
落盘行是“哪次事件丢了”的唯一取证,计数只是数字。
|
||||||
|
- 格式:JSONL,每行 `{ts, event, kind, detail, task_id}`。
|
||||||
|
`detail` 沿用 `_emit` 契约,只含 VM 名/路径/URL/kind,
|
||||||
|
绝不含密钥值;读文件时仍按敏感文件对待(0700 目录内)。
|
||||||
|
- 轮转:当前文件 >1MB 时,先 `os.replace` 成 `.old`
|
||||||
|
再写新行;只留最新两代,更早的丢失事件会丢——
|
||||||
|
这是 ADR-14 已定的取舍,不是 bug,不要指望它当
|
||||||
|
完整审计账本。
|
||||||
|
- 读取(宿主侧,只读):
|
||||||
|
`cat <state_dir>/chv-audit-spill.jsonl`;
|
||||||
|
按事件过滤:`jq -c 'select(.event=="vm_failed")'
|
||||||
|
<state_dir>/chv-audit-spill.jsonl`;
|
||||||
|
两代一起看时先 `.old` 后当前文件(时间顺序)。
|
||||||
|
- 清理:`.old` 可随时删(删的是已被轮转覆盖的旧代);
|
||||||
|
当前 `.jsonl` 只有在已人工核对、且确认丢失事件已
|
||||||
|
无需追查时才删——删了就再也找不回那批事件。
|
||||||
|
落盘文件不是 DB 替代:任务 timeline 查询看不到它,
|
||||||
|
不要把它当成审计表去对账;审计完整性本身仍无保证
|
||||||
|
(见 ADR-14),保证需要 DB 事务级审计写入,那是
|
||||||
|
worker 全局语义改动,不在本轨。
|
||||||
|
- 落盘写失败:只记 `chv_audit_spill_failed` 日志,
|
||||||
|
绝不中断任务;此时连落盘都没有,唯一信号就是这条
|
||||||
|
日志与计数。
|
||||||
|
|
||||||
|
### 14.3 mypy 默认口径剩余 2 错评估(结论:本轨不修)
|
||||||
|
|
||||||
|
- 位置:`cloud/marker_tools.py:142`,`try: from zoneinfo
|
||||||
|
import ZoneInfo / except Exception: ZoneInfo = None`。
|
||||||
|
mypy 把 import 推断为 `type[ZoneInfo]`,再把 `None`
|
||||||
|
赋值判为两错(misc + assignment)。
|
||||||
|
- 仅标注修法(沙箱验证过,未进仓库):在 import 区后加
|
||||||
|
`from typing import Any` 与 `ZoneInfo: Any`(标注语句
|
||||||
|
本身不产生绑定),默认口径 2 错 → 0 错;运行时
|
||||||
|
`ZoneInfo` 仍是 stdlib `zoneinfo.ZoneInfo` 同一对象,
|
||||||
|
行为不变。验证在 `/tmp` 副本上做,不碰仓库文件。
|
||||||
|
- 不修的理由(两条,任一条已足够):① 该文件属标记
|
||||||
|
工具链(git 历史为标记工具提交),不在本链 §13.1
|
||||||
|
文件清单内,本轨改它就是动其它链的文件,徒增与
|
||||||
|
仍 OPEN 的审计/标记 PR 的 rebase 冲突面;② CHV 链
|
||||||
|
自己的门是 silent 口径(`--follow-imports=silent
|
||||||
|
cloud/chv/`),该口径已 0 错,默认口径的 2 错是
|
||||||
|
作用域外既有问题。修法留给 marker_tools 的归属轨,
|
||||||
|
本轨只把配方写在这里,不为清零越界改文件。
|
||||||
|
|
||||||
|
### 14.4 合并前检查脚本(只读)
|
||||||
|
|
||||||
|
- 脚本:`tools/chv_merge_precheck.py`,用法见文件头。
|
||||||
|
只读:`gh pr view` / `gh api` 查询 + 本地 `git` 读
|
||||||
|
remote,不 fetch、不 checkout、不 merge、不改 CI。
|
||||||
|
退出码:0 就绪、1 未就绪、2 查询失败(结论不可信)。
|
||||||
|
- 本轨实测(未含本轨新 PR,建好后须追加新 PR 号重跑):
|
||||||
|
`python3 tools/chv_merge_precheck.py` → 退出码 1,
|
||||||
|
结论 NOT-READY:11 个 PR 全部 OPEN / MERGEABLE,
|
||||||
|
链衔接全部 OK,当前 main 头仍 `aebc6238`(与 §13.1
|
||||||
|
一致),但 11 个 PR 的 CI 全部 `FAIL [verify:FAILURE]`、
|
||||||
|
mergeState 全部 UNSTABLE。脚本不诊断失败原因,
|
||||||
|
不把秒级失败改判——账单恢复变绿前结论保持 NOT-READY。
|
||||||
|
|
||||||
|
### 14.5 灰度三阶段可执行清单(准备工作,未执行、未验证)
|
||||||
|
|
||||||
|
通用停止条件(任一触发即停在该阶段,不进下一阶段):
|
||||||
|
`vm_failed` 审计激增、脏标记(`dirty_marker`)出现、
|
||||||
|
`chv-audit-spill.jsonl` 持续增长、残留盘/残留密钥扫描
|
||||||
|
清出本阶段不应有的项、任一任务静默降级到 Docker/Local。
|
||||||
|
通用回滚:停止向 CHV 路由新任务(改回 docker/local
|
||||||
|
执行器选择),已起 VM 走取消/关闭路径(`cancel_vm` /
|
||||||
|
`shutdown_vm`);代码回滚另议(revert 合并),不与
|
||||||
|
“停路由”混为一谈。
|
||||||
|
|
||||||
|
- 阶段 0(合并后、默认关闭):
|
||||||
|
1. 按 §14.4 脚本逐项核对,退出码 0 前不合并;合并
|
||||||
|
顺序即 §14.4 的链序,不得跳号。
|
||||||
|
2. 合并后确认 CHV 仍默认关闭(无生产任务路由到
|
||||||
|
`cloud-hypervisor`,任务创建白名单外的执行器值
|
||||||
|
仍被后端拒绝)。
|
||||||
|
3. 在 KVM 宿主(须见 `/dev/kvm`,本 VM 与生产宿主
|
||||||
|
均无 KVM,不能替代)上跑 §14.6 的 V-1~V-9,
|
||||||
|
全部通过才可谈阶段 1;任一未跑即记未验证。
|
||||||
|
回滚点:合并本身可 revert;未进生产路由前无任务影响。
|
||||||
|
- 阶段 1(单租户可信项目):
|
||||||
|
1. 只对明确指定的单租户项目派发 CHV 任务;复用
|
||||||
|
实验仍需 `CHV_REUSE_SINGLE_TENANT_EXPERIMENT` 与
|
||||||
|
`CHV_SINGLE_TENANT_PROJECT` 同时声明,缺一即
|
||||||
|
preflight 拒绝(代码行为,不放宽)。
|
||||||
|
2. 检查点:每任务 VM 销毁后 `state_dir` 无该任务
|
||||||
|
残留盘与 seed;`chv-audit-spill.jsonl` 无新增;
|
||||||
|
取消路径真关 VM(V-5 口径)。
|
||||||
|
3. 停止条件见通用条件;回滚:该项目任务改回原
|
||||||
|
执行器,CHV worker 停接新任务。
|
||||||
|
- 阶段 2(受限多租户):
|
||||||
|
1. 放开到受邀项目,连续观察一周:VM 残留盘、
|
||||||
|
审计落盘、容量门(`CHV_MAX_VMS`,含重启后残留
|
||||||
|
socket 扫描)、快照配额四项指标。
|
||||||
|
2. 检查点:每周复核四项指标零异常 + §14.2 落盘
|
||||||
|
文件复核;任一指标异常即退回阶段 1。
|
||||||
|
3. 停止条件见通用条件;回滚:收紧到阶段 1 项目集。
|
||||||
|
- 阶段 3(全量多租户):仅在阶段 2 指标干净 +
|
||||||
|
动态安全审计结论由 No-Go 改为有条件启用后,才可
|
||||||
|
讨论;绝不从阶段 0 直跳全量。本轨不给阶段 3 的
|
||||||
|
时间表,不写成已就绪。
|
||||||
|
|
||||||
|
### 14.6 真机验证用例 V-1~V-9 验收检查表(全部未验证)
|
||||||
|
|
||||||
|
每行格式:用例 — 验收标准(§13.2 细化)— 证据栏(待填)。
|
||||||
|
状态栏一律“未验证”,跑过才改,跑过≠通过。
|
||||||
|
|
||||||
|
- [ ] V-1 冷启动 — 任务 completed,宿主 state_dir 无该
|
||||||
|
任务残留盘与 seed。证据:______ 状态:未验证
|
||||||
|
- [ ] V-2 seed 首启时序 — `sshd -T` 显示
|
||||||
|
PasswordAuthentication no 等加固项,known_hosts 钉死
|
||||||
|
校验通过,非 TOFU 回落。证据:______ 状态:未验证
|
||||||
|
- [ ] V-3 快照恢复 — 恢复后 known_hosts 不变,快照文件
|
||||||
|
落盘非空且经 `snapshot_not_persisted` 校验。证据:______
|
||||||
|
状态:未验证
|
||||||
|
- [ ] V-4 网关真实任务 — 任务完成 + 宿主进程表抽查无密钥
|
||||||
|
(密钥只走 seed 客内文件)。证据:______ 状态:未验证
|
||||||
|
- [ ] V-5 取消与配额真杀 — VM 进程消失,任务 interrupted
|
||||||
|
且原因写回正确。证据:______ 状态:未验证
|
||||||
|
- [ ] V-6 审计落盘 — 落盘行 event/task_id/detail 与丢失
|
||||||
|
事件一致,`audit_failure_count` +1,任务不中断。
|
||||||
|
证据:______ 状态:未验证
|
||||||
|
- [ ] V-7 动态安全负向 — 直连被 tap/防火墙挡、跨 VM 探活
|
||||||
|
与 IP 冒用被 SSH 钉死拒、崩溃注入后残留被清理或脏
|
||||||
|
标记拒起,每项都有“被挡住”的证据。证据:______
|
||||||
|
状态:未验证
|
||||||
|
- [ ] V-8 负载 — 4 VM 并发下容量门计数含重启后残留
|
||||||
|
socket 扫描,死 socket 不占名额、活 VM 不被超发,
|
||||||
|
无静默降级。证据:______ 状态:未验证
|
||||||
|
- [ ] V-9 供应链清单 — 构建宿主产出真实 lockfile,
|
||||||
|
`--verify` 通过且 unresolved 为空,再用
|
||||||
|
`check_lockfile.py` 对 BUILDINFO.versions 复核。
|
||||||
|
证据:______ 状态:未验证
|
||||||
|
|
||||||
|
### 14.7 本轨逐项评分(目标 10,无一项硬写 10)
|
||||||
|
|
||||||
|
- 代码质量 9.8 → 9.8,未达 10:executor/worker_adapter
|
||||||
|
覆盖到 100%,但 chv 仍有 80 行未覆(非 executor
|
||||||
|
模块),且默认口径 mypy 余 2 错在作用域外。差:代码内。
|
||||||
|
- 架构 9.7 → 9.7,未达 10:本轨未改架构,ADR-13/14
|
||||||
|
取舍不变。差:外部(生产验证后才可复评)。
|
||||||
|
- 可维护性 9.7 → 9.8,未达 10:新增合并检查脚本与
|
||||||
|
§14 运维/灰度文档,落盘可操作性补齐;但跨链文件
|
||||||
|
的 mypy 归属未落。差:代码内(小)。
|
||||||
|
- 文档 9.8 → 9.9,未达 10:§14 补齐 runbook、灰度
|
||||||
|
清单、V 检查表、评分表;仍缺真机证据填表。差:外部。
|
||||||
|
- 测试 9.7 → 9.8,未达 10:后端 317 项、前端 36 项全过,
|
||||||
|
executor 100%;但 chv 总覆盖 96% 未满,且真机用例
|
||||||
|
0 项已跑。差:代码内(80 行)+ 外部(真机)。
|
||||||
|
- 静态安全 9.6 → 9.6,未达 10:取舍不变,只补落盘
|
||||||
|
运维;lockfile 仍待构建宿主产出,绝不编造。差:外部。
|
||||||
|
- 前端 9.5 → 9.6,未达 10:只读四态与可用性文案边角
|
||||||
|
全覆盖(显式 false、undefined、优先级、旧 bug 文案
|
||||||
|
不复发、120 秒边界);控制按钮与新页面仍不做,
|
||||||
|
live 点击未验。差:代码内(有意不做)+ 外部(live)。
|
||||||
|
- 生产就绪 5.0 → 5.5,未达 10:仅因合并/灰度准备
|
||||||
|
工作(可跑脚本 + 可执行清单)小幅上升;CI 仍
|
||||||
|
UNSTABLE、未合并、未部署、真机 0 项。差:外部为主。
|
||||||
|
- 动态安全 2.0 → 2.0,未达 10:本轨无任何动态验证,
|
||||||
|
V-7 未跑。差:外部(KVM 宿主 + 审计结论)。
|
||||||
@@ -0,0 +1,144 @@
|
|||||||
|
#!/usr/bin/env python3
|
||||||
|
"""CHV 合并前检查(只读,绝不合并、绝不改 CI、绝不写库)。
|
||||||
|
|
||||||
|
用途:合并 §13.1/§14 链上 PR 之前,逐个核对 base/head、
|
||||||
|
mergeable、CI 状态、与 main 头的关系与堆叠顺序,输出
|
||||||
|
诚实结论。CI 仍是 UNSTABLE 就如实报 NOT-READY,不把
|
||||||
|
账单模式的秒级失败读成代码失败,也不把 UNSTABLE 读成绿。
|
||||||
|
|
||||||
|
用法:
|
||||||
|
python3 tools/chv_merge_precheck.py [PR号 ...]
|
||||||
|
缺省检查整条 CHV 链(不含本轨新 PR;新 PR 建好后把
|
||||||
|
它的号追加到参数末尾再跑一次):
|
||||||
|
python3 tools/chv_merge_precheck.py 216 221 230 232 234 \\
|
||||||
|
239 246 252 254 257 264 <本轨新 PR 号>
|
||||||
|
|
||||||
|
依赖:gh(已登录)与 git(仅读本地 remote,不 fetch、
|
||||||
|
不 checkout、不 merge)。gh 不可用或 API 失败时退出码 2。
|
||||||
|
退出码:0 = 全部就绪(可按顺序合并);1 = 未就绪(见原因);
|
||||||
|
2 = 工具/查询失败(结论不可信,不要据此合并)。
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
import subprocess
|
||||||
|
import sys
|
||||||
|
|
||||||
|
DEFAULT_STACK = [216, 221, 230, 232, 234, 239, 246, 252, 254, 257, 264]
|
||||||
|
|
||||||
|
|
||||||
|
def _run(cmd):
|
||||||
|
proc = subprocess.run(cmd, capture_output=True, text=True)
|
||||||
|
if proc.returncode != 0:
|
||||||
|
raise RuntimeError("%s 失败:%s" % (
|
||||||
|
" ".join(cmd), (proc.stderr or proc.stdout).strip()[:500]))
|
||||||
|
return proc.stdout
|
||||||
|
|
||||||
|
|
||||||
|
def _gh_json(args):
|
||||||
|
return json.loads(_run(["gh"] + args))
|
||||||
|
|
||||||
|
|
||||||
|
def _check_summary(rollup):
|
||||||
|
"""把 statusCheckRollup 归纳成一句话,不猜失败原因。"""
|
||||||
|
if not rollup:
|
||||||
|
return "NO-CHECKS"
|
||||||
|
states = []
|
||||||
|
for item in rollup:
|
||||||
|
name = item.get("name") or item.get("workflowName") or "?"
|
||||||
|
conclusion = item.get("conclusion")
|
||||||
|
status = item.get("status")
|
||||||
|
states.append("%s:%s" % (name, conclusion or status))
|
||||||
|
conclusions = {item.get("conclusion") for item in rollup}
|
||||||
|
if "FAILURE" in conclusions or "CANCELLED" in conclusions:
|
||||||
|
verdict = "FAIL"
|
||||||
|
elif conclusions <= {"SUCCESS", "SKIPPED", "NEUTRAL", None} \
|
||||||
|
and all(item.get("status") == "COMPLETED" for item in rollup):
|
||||||
|
verdict = "GREEN"
|
||||||
|
else:
|
||||||
|
verdict = "PENDING"
|
||||||
|
return "%s [%s]" % (verdict, "; ".join(states))
|
||||||
|
|
||||||
|
|
||||||
|
def main(argv):
|
||||||
|
try:
|
||||||
|
prs = [int(a) for a in argv] or list(DEFAULT_STACK)
|
||||||
|
except ValueError:
|
||||||
|
print("参数须为 PR 号(整数)", file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
try:
|
||||||
|
repo = _gh_json(["repo", "view", "--json", "nameWithOwner"])[
|
||||||
|
"nameWithOwner"]
|
||||||
|
main_head = _run(
|
||||||
|
["gh", "api", "repos/%s/commits/main" % repo, "--jq",
|
||||||
|
".sha"]).strip()
|
||||||
|
except Exception as exc:
|
||||||
|
print("查询仓库/main 头失败:%s" % exc, file=sys.stderr)
|
||||||
|
return 2
|
||||||
|
print("仓库:%s" % repo)
|
||||||
|
print("当前 main 头:%s" % main_head)
|
||||||
|
print("链(按合并顺序):%s" % " → ".join(str(p) for p in prs))
|
||||||
|
print("")
|
||||||
|
problems = []
|
||||||
|
tool_failed = False
|
||||||
|
prev_head_branch = "main"
|
||||||
|
for number in prs:
|
||||||
|
try:
|
||||||
|
pr = _gh_json(["pr", "view", str(number), "--json",
|
||||||
|
"number,title,state,baseRefName,headRefName,"
|
||||||
|
"headRefOid,mergeable,mergeStateStatus,"
|
||||||
|
"statusCheckRollup,additions,deletions,"
|
||||||
|
"changedFiles"])
|
||||||
|
except Exception as exc:
|
||||||
|
print("#%d:查询失败:%s" % (number, exc))
|
||||||
|
tool_failed = True
|
||||||
|
continue
|
||||||
|
chain_ok = pr["baseRefName"] == prev_head_branch
|
||||||
|
ci = _check_summary(pr.get("statusCheckRollup") or [])
|
||||||
|
ready = (pr["state"] == "OPEN" and pr["mergeable"] == "MERGEABLE"
|
||||||
|
and pr["mergeStateStatus"] == "CLEAN" and chain_ok
|
||||||
|
and ci.startswith("GREEN"))
|
||||||
|
print("#%(number)d %(title)s" % pr)
|
||||||
|
print(" base=%(baseRefName)s head=%(headRefName)s@%(headRefOid)s"
|
||||||
|
% pr)
|
||||||
|
print(" state=%(state)s mergeable=%(mergeable)s "
|
||||||
|
"mergeState=%(mergeStateStatus)s" % pr)
|
||||||
|
print(" diff: +%s/-%s, %s files" % (
|
||||||
|
pr.get("additions"), pr.get("deletions"),
|
||||||
|
pr.get("changedFiles")))
|
||||||
|
print(" CI: %s" % ci)
|
||||||
|
print(" 链衔接(base 应为上一 PR 的 head 分支 %s):%s" % (
|
||||||
|
prev_head_branch, "OK" if chain_ok else "MISMATCH"))
|
||||||
|
print(" 本项:%s" % ("READY" if ready else "NOT-READY"))
|
||||||
|
if not ready:
|
||||||
|
reasons = []
|
||||||
|
if pr["state"] != "OPEN":
|
||||||
|
reasons.append("state=%s" % pr["state"])
|
||||||
|
if pr["mergeable"] != "MERGEABLE":
|
||||||
|
reasons.append("mergeable=%s" % pr["mergeable"])
|
||||||
|
if pr["mergeStateStatus"] != "CLEAN":
|
||||||
|
reasons.append("mergeState=%s"
|
||||||
|
% pr["mergeStateStatus"])
|
||||||
|
if not chain_ok:
|
||||||
|
reasons.append("base 衔接断裂")
|
||||||
|
if not ci.startswith("GREEN"):
|
||||||
|
reasons.append("CI 未绿(%s)" % ci)
|
||||||
|
problems.append("#%d:%s" % (number, "; ".join(reasons)))
|
||||||
|
prev_head_branch = pr["headRefName"]
|
||||||
|
print("")
|
||||||
|
if tool_failed:
|
||||||
|
print("结论:查询失败,结论不可信,不要据此合并。")
|
||||||
|
return 2
|
||||||
|
if problems:
|
||||||
|
print("结论:NOT-READY,禁止合并。原因:")
|
||||||
|
for problem in problems:
|
||||||
|
print(" - %s" % problem)
|
||||||
|
print("注意:CI 的秒级 verify 失败此前被定性为账单模式,"
|
||||||
|
"脚本不据此改判——账单恢复变绿前,结论保持 NOT-READY。")
|
||||||
|
return 1
|
||||||
|
print("结论:READY,可按上面顺序逐个合并(合并后仍默认关闭,"
|
||||||
|
"再跑 §14 灰度清单)。")
|
||||||
|
return 0
|
||||||
|
|
||||||
|
|
||||||
|
if __name__ == "__main__":
|
||||||
|
sys.exit(main(sys.argv[1:]))
|
||||||
Reference in new issue
Block a user