Files
codex-app-server/chv-guest/check_lockfile.py
T
host a859d731d5
ci / test (push) Failing after 47m51s
fix(chv-hybrid): p2 第三轮:attach 轮询等挂盘落地 + detach 走 sudo 窄规则 umount
第 2 轮复测(0dc8328)p2 仍 FAIL 的两处根因:
1. 挂盘时序竞态:add-disk 返回 06:59:10,root hotplug 路径
   (cloud-init-hotplugd → cloud-final runcmd → chv-data-mount)
   06:59:14–16 才挂上;旧 attach 以 chv 调 chv-data-mount 并判
   rc==0,但 /dev/vd? 属 root:disk、chv 的 blkid/mkfs 必败,
   脚本又静默 exit 0,rc 检查是空洞的成功假象。
2. 卸载权限:挂载是 root 做的,chv 的 plain umount 恒 rc=32
   must be superuser,release 按现写法永远失败、VM 留置。

修法选 (a) 轮询,不选 (b) root 特权挂载:挂盘本身已有 root
路径在跑,host 侧不另开新的 root 挂载通道(新特权面只扩
攻击面);轮询以 chv 只读判定挂载态,零新增特权。
- attach:删 chv 调 chv-data-mount,改为轮询 mountpoint -q
  /home/chv/workspace && test -w(默认 60 秒/2 秒,可经
  CHV_DATA_MOUNT_TIMEOUT_SECONDS / CHV_DATA_MOUNT_INTERVAL_SECONDS
  注入短值);超时记 data_disk_mount_timeout 审计并抛同名
  kind,不写归属标记。attach 返回成功 = 挂盘真实发生。
  resume_seconds 因此真实包含挂盘等待(§2.1 口径),≤2 秒
  目标不改口径、不改目标。
- detach:改走 sudo -n /usr/bin/umount /home/chv/workspace;
  失败即 False,release 留置 VM 不回收(fail-closed)。
- chv-data-mount.sh:盘已找到后 mkfs/mount 失败改 exit 1
  (原全部失败路径 exit 0);无盘仍 exit 0(启动期 no-op,
  不挡 boot)。本脚本只由 root 跑(service/runcmd)。
- build.sh:apt 加 sudo;写 /etc/sudoers.d/chv-data-umount
  窄规则(chv 仅能以 root 跑 umount /home/chv/workspace
  这一条精确 argv,NOPASSWD,无 shell/其它命令/挂载权),
  0440 + visudo -cf 作构建门;buildinfo/lockfile 登记
  sudo 与 chv-data-umount。

特权边界:本轮唯一新增提权面就是上述一条 sudoers 规则;
attach 零提权(只读轮询),挂载权仍只在 root 的
hotplug/service 路径手里。旧镜像(无 sudo/无规则)上
detach 返回 False、release 留置,须重建镜像才恢复回收。

测试:新增 10 项(attach 等三次轮询才成功、超时 loud 失败、
永不以 chv 调 chv-data-mount、detach 精确 sudo argv、
sudo 失败 False、卸载失败 release 留置、客机脚本 exit 码、
build.sh sudo/窄规则/buildinfo 登记);另修 2 处旧断言
(attach 不再调挂载脚本)与 stage4 lockfile 夹具(补 sudo
dpkg 条目),并把 attach 热插段抽为 _hotplug_tenant_disk
(50 行函数上限)。全量 Ran 4688,OK (skipped=15,均为
环境 skip:无 PG DSN/驱动、缺 SearXNG_URL、fallback state
文件在位)。
2026-11-09 08:17:01 +00:00

169 lines
6.4 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
#!/usr/bin/env python3
"""校验已构建镜像的 BUILDINFO 供应链清单(事后 lockfile 检查)。
生成端见 generate_lockfile.py(读 BUILDINFO.versions 生成
lockfile.json,版本唯一来源是 build.sh 从客机读回的
dpkg/pip freeze + chromium 目录名 + codex/repo_commit/gt
声明字段,绝不查构建宿主现场、绝不编造版本号)。
本脚本校验 BUILDINFO:versions 非空、每条有版本、contents
全覆盖、tag 与预期一致、钉死的 Chromium 修订号一致、
可选的冻结清单(--frozen JSON,形如
{"dpkg": ["pkg version", ...]})双向比对,漂移即退出非 0。
用法:
python3 check_lockfile.py out/BUILDINFO.json [--tag TAG]
[--frozen frozen.json]
"""
import json
import sys
# 非包名的内容项:版本来自 BUILDINFO 的声明字段,不是 versions 列表。
DECLARED_SOURCES = {
"codex": "codex_version",
"codex-code-mode-host": "codex_version",
"codex-resources/bwrap": "codex_version",
"/app/host.py": "repo_commit",
"gt": "gt_version",
"chv-sync": "repo_commit",
# 对齐 Muse P2:nspawn 单元启动器 + broker + 浏览器子代理,
# 均是仓库内脚本,版本随 repo_commit(与 chv-sync 同款)。
"chv-cell": "repo_commit",
"chv-broker": "repo_commit",
"chv-browser": "repo_commit",
# feat-chv-opt-landing(阶段 1):数据盘挂载脚本,版本随
# repo_commit(与 chv-sync 同款)。
"chv-data-mount": "repo_commit",
# p2 第三轮:卸载 sudoers 窄规则(build.sh 内联写入,
# 内容随仓库走,版本随 repo_commit)。
"chv-data-umount": "repo_commit",
# feat-chv-opt-landing(阶段 2):客机运行时三分服务,版本
# 随 repo_commit(与 chv-sync 同款)。
"chv-daemon": "repo_commit",
"chv-execd": "repo_commit",
"chv-syncd": "repo_commit",
# feat-chv-opt-landing(阶段 2):cell veth 的 networkd
# 不管配置,版本随 repo_commit。
"79-chv-cell.network": "repo_commit",
}
def _names(entries, pip_style=False):
"""versions 条目 → 小写名集合。dpkg 行是 "name version",
pip freeze 行是 "name==version"。"""
names = set()
for entry in entries or []:
text = str(entry)
if pip_style and "==" in text:
names.add(text.split("==", 1)[0].lower())
else:
parts = text.split(None, 1)
if parts:
names.add(parts[0].lower())
return names
def _check_format(kind, entries):
"""每条须带非空版本:dpkg "name version",pip "name==version"。"""
for entry in entries or []:
text = str(entry)
if kind == "pip" and "==" in text:
if not text.split("==", 1)[1].strip():
print("版本为空的条目:%s %r" % (kind, entry),
file=sys.stderr)
return False
else:
parts = text.split()
if len(parts) < 2 or not parts[1]:
print("版本为空的条目:%s %r" % (kind, entry),
file=sys.stderr)
return False
return True
def main(argv):
if len(argv) < 2:
print("用法:check_lockfile.py BUILDINFO.json [--tag TAG] "
"[--frozen frozen.json]", file=sys.stderr)
return 2
path = argv[1]
tag = None
frozen_path = None
i = 2
while i < len(argv):
if argv[i] == "--tag" and i + 1 < len(argv):
tag = argv[i + 1]
i += 2
elif argv[i] == "--frozen" and i + 1 < len(argv):
frozen_path = argv[i + 1]
i += 2
else:
print("未知参数:%s" % argv[i], file=sys.stderr)
return 2
with open(path) as fh:
data = json.load(fh)
if tag is not None and data.get("tag") != tag:
print("tag 不一致:%s != %s" % (data.get("tag"), tag),
file=sys.stderr)
return 1
versions = data.get("versions") or {}
if not versions.get("dpkg") or not versions.get("pip"):
print("BUILDINFO.versions.dpkg/pip 为空,供应链清单缺失",
file=sys.stderr)
return 1
if not _check_format("dpkg", versions.get("dpkg")):
return 1
if not _check_format("pip", versions.get("pip")):
return 1
# contents 全覆盖:每项须在 dpkg/pip 名单里,或有声明字段版本。
covered = _names(versions.get("dpkg")) | _names(
versions.get("pip"), pip_style=True)
missing = []
for name in data.get("contents") or []:
if name.lower() in covered:
continue
source = DECLARED_SOURCES.get(name)
if source and data.get(source):
continue
if name.lower() in ("chromium", "chromium-headless-shell") \
and versions.get("chromium"):
continue
missing.append(name)
if missing:
print("contents 未覆盖:%s" % sorted(missing), file=sys.stderr)
return 1
# feat-chv-closeup(M4):钉死的 Chromium 修订号须与读回
# 的缓存目录名一致;未钉(pinned_rev 为空)时不校验。
pinned = data.get("chromium_pinned_rev")
if pinned:
expected = "chromium_headless_shell-%s" % pinned
if expected not in (versions.get("chromium") or []):
print("Chromium 修订号与钉死值不符:读回 %s,钉死 %s"
% (versions.get("chromium"), expected),
file=sys.stderr)
return 1
if frozen_path:
with open(frozen_path) as fh:
frozen = json.load(fh)
for kind in ("dpkg", "pip"):
want = set(frozen.get(kind) or [])
got = set(versions.get(kind) or [])
absent = want - got
if absent:
print("%s 漂移:冻结清单里有、构建产物里无:%s"
% (kind, sorted(absent)), file=sys.stderr)
return 1
extra = got - want
if extra:
print("%s 漂移:构建产物里有、冻结清单里无:%s"
% (kind, sorted(extra)), file=sys.stderr)
return 1
print("lockfile 校验通过:tag=%s dpkg=%d pip=%d contents=%d"
% (data.get("tag"), len(versions["dpkg"]),
len(versions["pip"]),
len(data.get("contents") or [])))
return 0
if __name__ == "__main__":
raise SystemExit(main(sys.argv))