#!/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", # 对齐 Muse P3 + P1 vsock:authd 三件套、authd/sentinel # 叶子模块与 vsock 通知脚本,均是仓库内脚本,版本随 # repo_commit。缺了它们 generate 会记 6 项 unresolved, # verify 与 check 双双失败(717efc4 真机实测)。 # round-11 真机修复(层 1):叶子树新增 errors.py 与 stub # __init__.py(authd_init_stub.py),同款 repo_commit 登记, # 缺了它们新镜像的 lockfile 同样会 unresolved。 "chv-authd": "repo_commit", "git-credential-chv-authd": "repo_commit", "chv-authd.service": "repo_commit", "cloud/chv/authd.py": "repo_commit", "cloud/chv/sentinel.py": "repo_commit", "cloud/chv/errors.py": "repo_commit", "cloud/chv/__init__.py": "repo_commit", "vsock_notify": "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-,headless-shell 的是 chromium_headless_shell-, 各自映射到同名 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))