games-development-ai/deploy/runtime-tree-manifest.py

109 lines
4.1 KiB
Python

#!/usr/bin/env python3
"""签发或校验 staging RC 的 Python 运行树字节清单。"""
from __future__ import annotations
import argparse
import hashlib
import json
import os
import stat
from pathlib import Path
IGNORED_PARTS = frozenset({"__pycache__", ".pytest_cache"})
def sha256_file(path: Path) -> str:
"""流式计算文件摘要,避免大依赖文件一次性进入内存。"""
digest = hashlib.sha256()
with path.open("rb") as handle:
for chunk in iter(lambda: handle.read(1024 * 1024), b""):
digest.update(chunk)
return digest.hexdigest()
def should_ignore(path: Path) -> bool:
"""忽略 Python 运行时可再生缓存,其余新增文件一律计入漂移。"""
return path.suffix == ".pyc" or any(part in IGNORED_PARTS for part in path.parts)
def capture(roots: list[str]) -> dict:
"""采集普通文件与符号链接;符号链接同时绑定文本目标和解析后文件摘要。"""
entries: list[dict] = []
cwd = Path.cwd().resolve()
normalized_roots: list[str] = []
for raw_root in roots:
root = Path(raw_root)
if root.is_absolute() or ".." in root.parts or not root.is_dir() or root.is_symlink():
raise ValueError(f"运行树根必须是仓内非符号链接目录: {raw_root}")
normalized_roots.append(root.as_posix())
for path in root.rglob("*"):
relative = path.relative_to(cwd) if path.is_absolute() else path
if should_ignore(relative):
continue
metadata = path.lstat()
mode = stat.S_IMODE(metadata.st_mode)
if path.is_symlink():
target = os.readlink(path)
resolved = path.resolve(strict=False)
entries.append({
"path": relative.as_posix(),
"type": "symlink",
"mode": mode,
"target": target,
"resolvedSha256": sha256_file(resolved) if resolved.is_file() else None,
})
elif path.is_file():
entries.append({
"path": relative.as_posix(),
"type": "file",
"mode": mode,
"sha256": sha256_file(path),
"size": metadata.st_size,
})
entries.sort(key=lambda item: item["path"])
return {"schema": "staging-runtime-tree/1", "roots": normalized_roots, "entries": entries}
def write_manifest(output: Path, roots: list[str]) -> None:
"""以 0600 原子替换清单,避免半写文件被签入 RC。"""
payload = capture(roots)
output.parent.mkdir(parents=True, exist_ok=True)
temporary = output.with_name(output.name + ".tmp")
temporary.write_text(json.dumps(payload, ensure_ascii=False, sort_keys=True, indent=2) + "\n",
encoding="utf-8")
temporary.chmod(0o600)
temporary.replace(output)
def verify_manifest(manifest: Path, roots: list[str]) -> None:
"""逐结构比较当前树,新增、删除、权限、字节或链接目标漂移均拒绝。"""
expected = json.loads(manifest.read_text(encoding="utf-8"))
actual = capture(roots)
if expected != actual:
raise RuntimeError("Python 运行树与 RC 清单不一致")
def main() -> int:
parser = argparse.ArgumentParser(description="staging RC Python 运行树完整性清单")
subparsers = parser.add_subparsers(dest="command", required=True)
capture_parser = subparsers.add_parser("capture")
capture_parser.add_argument("--output", required=True, type=Path)
capture_parser.add_argument("roots", nargs="+")
verify_parser = subparsers.add_parser("verify")
verify_parser.add_argument("--manifest", required=True, type=Path)
verify_parser.add_argument("roots", nargs="+")
args = parser.parse_args()
if args.command == "capture":
write_manifest(args.output, args.roots)
print(f"RUNTIME_TREE_CAPTURE_PASS manifest={args.output}")
else:
verify_manifest(args.manifest, args.roots)
print(f"RUNTIME_TREE_VERIFY_PASS manifest={args.manifest}")
return 0
if __name__ == "__main__":
raise SystemExit(main())