From ebd74a87ee88750ba4fda98b81a4f7738154d10a Mon Sep 17 00:00:00 2001 From: lili Date: Thu, 23 Jul 2026 00:48:19 -0700 Subject: [PATCH] fix(release): pin staging runtime trees --- deploy/activate-staging-rc.sh | 5 + deploy/prepare-staging-rc.sh | 11 ++- deploy/runtime-tree-manifest.py | 108 +++++++++++++++++++++ deploy/tests/test-runtime-tree-manifest.py | 67 +++++++++++++ deploy/tests/test-staging-rc.sh | 3 + 5 files changed, 193 insertions(+), 1 deletion(-) create mode 100644 deploy/runtime-tree-manifest.py create mode 100644 deploy/tests/test-runtime-tree-manifest.py diff --git a/deploy/activate-staging-rc.sh b/deploy/activate-staging-rc.sh index c0a9d071..63e2c6f9 100755 --- a/deploy/activate-staging-rc.sh +++ b/deploy/activate-staging-rc.sh @@ -337,6 +337,7 @@ valid_prepared_release() { [ -d "$target/game-studio/dist" ] || return 1 [ -d "$target/game-admin/dist-stage" ] || return 1 [ -x "$target/cheap-worker/.venv/bin/python" ] || return 1 + [ -f "$target/evidence/runtime-tree.json" ] || return 1 if [ -n "$expected" ]; then [ "$(git -C "$target" rev-parse HEAD)" = "$expected" ] || return 1 fi @@ -596,6 +597,10 @@ set +a cd "$release" sha256sum -c evidence/artifacts.sha256 >"$evidence/artifact-verification.txt" +python3 deploy/runtime-tree-manifest.py verify \ + --manifest evidence/runtime-tree.json cheap-worker tier2/gen-worker \ + >"$evidence/runtime-tree-verification.log" +chmod 600 "$evidence/runtime-tree-verification.log" log_step "生成并校验 DB-only 存储审计" storage_audit_output="$(RC_RELEASE="$release" EXPECTED_STORAGE_AUDIT_REF="$expected_storage_audit_ref" \ diff --git a/deploy/prepare-staging-rc.sh b/deploy/prepare-staging-rc.sh index 9702b404..ed93aee0 100755 --- a/deploy/prepare-staging-rc.sh +++ b/deploy/prepare-staging-rc.sh @@ -206,7 +206,9 @@ release_tool_tests=( python3 -m unittest deploy/tests/test-dogfood-reviewer-security.py printf 'RUN deploy/dogfood-ops/tests/test_dogfood_ops.py\n' python3 -m unittest deploy/dogfood-ops/tests/test_dogfood_ops.py - printf 'RELEASE_TOOL_TESTS_PASS shell=%s python=3\n' "${#release_tool_tests[@]}" + printf 'RUN deploy/tests/test-runtime-tree-manifest.py\n' + python3 -m unittest deploy/tests/test-runtime-tree-manifest.py + printf 'RELEASE_TOOL_TESTS_PASS shell=%s python=4\n' "${#release_tool_tests[@]}" ) >"$evidence/release-tool-tests.log" 2>&1 printf '[7/7] 记录制品与启动坐标\n' @@ -221,9 +223,15 @@ printf '[7/7] 记录制品与启动坐标\n' printf 'tier2_entry=%s/tier2/gen-worker/service/app.py\n' "$release" } >"$evidence/artifact-paths.env" +python3 deploy/runtime-tree-manifest.py capture \ + --output "$evidence/runtime-tree.json" cheap-worker tier2/gen-worker \ + >"$evidence/runtime-tree-capture.log" +chmod 600 "$evidence/runtime-tree.json" "$evidence/runtime-tree-capture.log" + { sha256sum game-cloud/huijing-server/target/huijing-server.jar find game-studio/dist game-admin/dist-stage -type f -print0 | sort -z | xargs -0 sha256sum + sha256sum "$evidence/runtime-tree.json" } >"$evidence/artifacts.sha256" { @@ -244,6 +252,7 @@ printf '[7/7] 记录制品与启动坐标\n' printf 'frontend_secret_scan=PASS\n' printf 'frontend_secret_scan_patterns_sha256=%s\n' "$frontend_forbidden_patterns_sha256" printf 'frontend_secret_scan_sha256=%s\n' "$(sha256sum "$evidence/frontend-secret-scan.log" | awk '{print $1}')" + printf 'runtime_tree_manifest_sha256=%s\n' "$(sha256sum "$evidence/runtime-tree.json" | awk '{print $1}')" } >"$evidence/verification-summary.txt" [ -z "$(git status --porcelain --untracked-files=no)" ] || { printf '构建修改了 tracked 文件,拒绝将其标为 ready。\n' >&2; exit 6; } diff --git a/deploy/runtime-tree-manifest.py b/deploy/runtime-tree-manifest.py new file mode 100644 index 00000000..343b6f00 --- /dev/null +++ b/deploy/runtime-tree-manifest.py @@ -0,0 +1,108 @@ +#!/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()) diff --git a/deploy/tests/test-runtime-tree-manifest.py b/deploy/tests/test-runtime-tree-manifest.py new file mode 100644 index 00000000..4c302977 --- /dev/null +++ b/deploy/tests/test-runtime-tree-manifest.py @@ -0,0 +1,67 @@ +#!/usr/bin/env python3 +"""staging RC Python 运行树清单回归测试。""" + +from __future__ import annotations + +import json +import subprocess +import sys +import tempfile +import unittest +from pathlib import Path + + +SCRIPT = Path(__file__).parents[1] / "runtime-tree-manifest.py" + + +class RuntimeTreeManifestTest(unittest.TestCase): + def setUp(self) -> None: + self.temporary = tempfile.TemporaryDirectory() + self.root = Path(self.temporary.name) + self.runtime = self.root / "runtime" + self.runtime.mkdir() + (self.runtime / "app.py").write_text("print('ok')\n", encoding="utf-8") + (self.runtime / "python-link").symlink_to("app.py") + self.manifest = self.root / "runtime-tree.json" + + def tearDown(self) -> None: + self.temporary.cleanup() + + def run_tool(self, command: str, *, expect_success: bool = True) -> subprocess.CompletedProcess: + result = subprocess.run( + [sys.executable, str(SCRIPT), command, + "--output" if command == "capture" else "--manifest", str(self.manifest), "runtime"], + cwd=self.root, + capture_output=True, + text=True, + ) + self.assertEqual(result.returncode == 0, expect_success, result.stderr) + return result + + def test_capture_and_verify_identical_tree(self) -> None: + self.run_tool("capture") + self.run_tool("verify") + payload = json.loads(self.manifest.read_text(encoding="utf-8")) + self.assertEqual(payload["schema"], "staging-runtime-tree/1") + self.assertEqual(len(payload["entries"]), 2) + + def test_rejects_file_content_drift(self) -> None: + self.run_tool("capture") + (self.runtime / "app.py").write_text("print('changed')\n", encoding="utf-8") + self.run_tool("verify", expect_success=False) + + def test_rejects_added_file(self) -> None: + self.run_tool("capture") + (self.runtime / "injected.py").write_text("raise SystemExit\n", encoding="utf-8") + self.run_tool("verify", expect_success=False) + + def test_rejects_symlink_target_drift(self) -> None: + self.run_tool("capture") + (self.runtime / "other.py").write_text("print('other')\n", encoding="utf-8") + (self.runtime / "python-link").unlink() + (self.runtime / "python-link").symlink_to("other.py") + self.run_tool("verify", expect_success=False) + + +if __name__ == "__main__": + unittest.main() diff --git a/deploy/tests/test-staging-rc.sh b/deploy/tests/test-staging-rc.sh index 9f877746..8369a7f7 100755 --- a/deploy/tests/test-staging-rc.sh +++ b/deploy/tests/test-staging-rc.sh @@ -243,6 +243,9 @@ rg -Fq "frontend_forbidden_patterns='admin123|test1|mock-token|mock-studio-token "$PREPARE" || fail "prepare 前端扫描模式未完整覆盖 test1、mock-studio-token 与既有模式" rg -q "printf 'patterns=%s" "$PREPARE" || fail "prepare 扫描证据未记录实际模式集" rg -q 'frontend_secret_scan_patterns_sha256=' "$PREPARE" || fail "prepare 摘要未绑定扫描模式集证据" +rg -q 'runtime-tree-manifest\.py capture' "$PREPARE" || fail "prepare 未签发 Python 运行树清单" +rg -q 'runtime_tree_manifest_sha256=' "$PREPARE" || fail "prepare 摘要未绑定 Python 运行树清单" +rg -q 'runtime-tree-manifest\.py verify' "$ACTIVATE" || fail "activate 未校验 Python 运行树漂移" frontend_forbidden_patterns="$(sed -n "s/^frontend_forbidden_patterns='\\(.*\\)'$/\\1/p" "$PREPARE")" [ -n "$frontend_forbidden_patterns" ] || fail "无法从 prepare 解析前端禁止模式集" for forbidden_sample in admin123 test1 mock-token mock-studio-token bootstrap-admin.token DOGFOOD_PASSWORD_SEED; do