ci / test (push) Failing after 47m51s
第 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 文件在位)。
230 lines
8.8 KiB
Python
230 lines
8.8 KiB
Python
#!/usr/bin/env python3
|
||
"""在构建宿主上生成 CHV 客机镜像的供应链 lockfile。
|
||
|
||
事后校验见 check_lockfile.py(校验 BUILDINFO.versions);
|
||
本脚本是生成端:读 BUILDINFO.json,以其中的 versions
|
||
(build.sh 已从客机读回的 dpkg-query / pip freeze,
|
||
Playwright=1 时另有 chromium 标识)为唯一版本来源,
|
||
逐项给 contents 清单配版本,写出 lockfile.json:
|
||
|
||
{"tag": ..., "image_sha256": ... | null,
|
||
"generated_at": UTC ISO 时间,
|
||
"versions": {"dpkg": ["name version", ...],
|
||
"pip": ["name version", ...],
|
||
"declared": ["name version", ...]},
|
||
"unresolved": ["查不到版本的名", ...]}
|
||
|
||
版本来源映射(绝不编造版本号,查不到的进 unresolved):
|
||
- dpkg 包名 → versions.dpkg("name version" 行)
|
||
- pip 包名 → versions.pip("name==version" 行)
|
||
- codex / codex-code-mode-host / codex-resources/bwrap
|
||
→ BUILDINFO.codex_version(随 codex 一起发布)
|
||
- /app/host.py → BUILDINFO.repo_commit
|
||
- chromium / chromium-headless-shell → versions.chromium
|
||
(ms-playwright 缓存目录名)
|
||
|
||
注意:旧版逐项查构建宿主的 dpkg/pip,方法不成立——宿主
|
||
版本与客机版本没有必然关系,且 /app/host.py、codex 三件
|
||
套在宿主上永远查不到,--verify 永远过不了。本版只读
|
||
BUILDINFO.versions,不再现场查宿主。
|
||
|
||
用法(均在构建宿主上跑):
|
||
python3 generate_lockfile.py BUILDINFO.json --output lockfile.json
|
||
python3 generate_lockfile.py --verify lockfile.json \
|
||
--buildinfo BUILDINFO.json
|
||
"""
|
||
|
||
import json
|
||
import sys
|
||
from datetime import datetime, timezone
|
||
|
||
# 非包名的内容项:版本来自 BUILDINFO 的声明字段,不是 dpkg/pip。
|
||
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 _index_dpkg(lines):
|
||
"""dpkg 行 "name version" → {小写名: "name version"}。"""
|
||
index = {}
|
||
for line in lines or []:
|
||
parts = str(line).split(None, 1)
|
||
if len(parts) == 2:
|
||
index[parts[0].lower()] = "%s %s" % (parts[0], parts[1])
|
||
return index
|
||
|
||
|
||
def _index_pip(lines):
|
||
"""pip freeze 行 "name==version" → {小写名: "name version"}。"""
|
||
index = {}
|
||
for line in lines or []:
|
||
if "==" in str(line):
|
||
name, version = str(line).split("==", 1)
|
||
index[name.lower()] = "%s %s" % (name, version.strip())
|
||
return index
|
||
|
||
|
||
def _index_chromium(lines):
|
||
"""chromium 标识行 → 键。完整 Chromium 的目录名是
|
||
chromium-<rev>,headless-shell 的是 chromium_headless_shell-<rev>,
|
||
各自映射到同名 contents 项。"""
|
||
index = {}
|
||
for line in lines or []:
|
||
text = str(line).strip()
|
||
if text.startswith("chromium_headless_shell"):
|
||
index["chromium-headless-shell"] = \
|
||
"chromium-headless-shell %s" % text
|
||
elif text:
|
||
index["chromium"] = "chromium %s" % text
|
||
return index
|
||
|
||
|
||
def generate(buildinfo_path, output_path):
|
||
with open(buildinfo_path) as fh:
|
||
buildinfo = json.load(fh)
|
||
names = buildinfo.get("contents") or []
|
||
if not isinstance(names, list):
|
||
print("BUILDINFO.contents 不是列表", file=sys.stderr)
|
||
return 2
|
||
versions = buildinfo.get("versions") or {}
|
||
dpkg_index = _index_dpkg(versions.get("dpkg"))
|
||
pip_index = _index_pip(versions.get("pip"))
|
||
chromium_index = _index_chromium(versions.get("chromium"))
|
||
dpkg, pip, declared, unresolved = [], [], [], []
|
||
for name in names:
|
||
key = name.lower()
|
||
if name in DECLARED_SOURCES:
|
||
version = buildinfo.get(DECLARED_SOURCES[name])
|
||
if version:
|
||
declared.append("%s %s" % (name, version))
|
||
else:
|
||
unresolved.append(name)
|
||
continue
|
||
if key in chromium_index:
|
||
declared.append(chromium_index[key])
|
||
continue
|
||
if key in dpkg_index:
|
||
dpkg.append(dpkg_index[key])
|
||
continue
|
||
if key in pip_index:
|
||
pip.append(pip_index[key])
|
||
continue
|
||
unresolved.append(name)
|
||
lockfile = {
|
||
"tag": buildinfo.get("tag"),
|
||
"image_sha256": buildinfo.get("image_sha256")
|
||
or buildinfo.get("sha256"),
|
||
"generated_at": datetime.now(timezone.utc).isoformat(),
|
||
"versions": {"dpkg": sorted(dpkg), "pip": sorted(pip),
|
||
"declared": sorted(declared)},
|
||
"unresolved": sorted(unresolved),
|
||
}
|
||
with open(output_path, "w") as fh:
|
||
json.dump(lockfile, fh, indent=2, sort_keys=True)
|
||
fh.write("\n")
|
||
if unresolved:
|
||
print("警告:%d 项查不到版本,已列入 unresolved:%s"
|
||
% (len(unresolved), ", ".join(sorted(unresolved))),
|
||
file=sys.stderr)
|
||
return 0
|
||
|
||
|
||
def verify(lockfile_path, buildinfo_path):
|
||
with open(lockfile_path) as fh:
|
||
lockfile = json.load(fh)
|
||
if buildinfo_path:
|
||
with open(buildinfo_path) as fh:
|
||
buildinfo = json.load(fh)
|
||
if lockfile.get("tag") != buildinfo.get("tag"):
|
||
print("tag 不一致:lockfile %s != BUILDINFO %s"
|
||
% (lockfile.get("tag"), buildinfo.get("tag")),
|
||
file=sys.stderr)
|
||
return 1
|
||
# feat-chv-closeup(M4):钉死的 Chromium 修订号须与
|
||
# lockfile 里记的 chromium-headless-shell 条目一致;
|
||
# BUILDINFO 没钉(pinned_rev 为空)时不校验。
|
||
pinned = buildinfo.get("chromium_pinned_rev")
|
||
if pinned:
|
||
expected = "chromium_headless_shell-%s" % pinned
|
||
declared = (lockfile.get("versions") or {}).get(
|
||
"declared") or []
|
||
hit = [e for e in declared
|
||
if str(e).startswith("chromium-headless-shell ")]
|
||
if not hit or expected not in str(hit[0]):
|
||
print("Chromium 修订号与钉死值不符:lockfile %s,"
|
||
"钉死 chromium_headless_shell-%s"
|
||
% (hit, pinned), file=sys.stderr)
|
||
return 1
|
||
versions = lockfile.get("versions") or {}
|
||
for kind in ("dpkg", "pip"):
|
||
entries = versions.get(kind)
|
||
if not entries:
|
||
print("versions.%s 为空,供应链清单缺失" % kind,
|
||
file=sys.stderr)
|
||
return 1
|
||
for entry in entries:
|
||
parts = str(entry).split()
|
||
if len(parts) < 2 or not parts[1]:
|
||
print("版本为空的条目:%s %r" % (kind, entry),
|
||
file=sys.stderr)
|
||
return 1
|
||
unresolved = lockfile.get("unresolved") or []
|
||
if unresolved:
|
||
print("清单不完整,unresolved 非空:%s" % sorted(unresolved),
|
||
file=sys.stderr)
|
||
return 1
|
||
print("lockfile 校验通过:tag=%s dpkg=%d pip=%d declared=%d"
|
||
% (lockfile.get("tag"), len(versions["dpkg"]),
|
||
len(versions["pip"]),
|
||
len(versions.get("declared") or [])))
|
||
return 0
|
||
|
||
|
||
def main(argv):
|
||
if "--verify" in argv:
|
||
lockfile_path = argv[argv.index("--verify") + 1]
|
||
buildinfo_path = None
|
||
if "--buildinfo" in argv:
|
||
buildinfo_path = argv[argv.index("--buildinfo") + 1]
|
||
return verify(lockfile_path, buildinfo_path)
|
||
if len(argv) < 2:
|
||
print("用法:generate_lockfile.py BUILDINFO.json "
|
||
"--output lockfile.json | --verify lockfile.json "
|
||
"[--buildinfo BUILDINFO.json]", file=sys.stderr)
|
||
return 2
|
||
output_path = None
|
||
if "--output" in argv:
|
||
output_path = argv[argv.index("--output") + 1]
|
||
if not output_path:
|
||
print("缺少 --output", file=sys.stderr)
|
||
return 2
|
||
return generate(argv[1], output_path)
|
||
|
||
|
||
if __name__ == "__main__":
|
||
raise SystemExit(main(sys.argv))
|