fix(release): pin staging runtime trees
This commit is contained in:
parent
68f8b4fa86
commit
ebd74a87ee
@ -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" \
|
||||
|
||||
@ -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; }
|
||||
|
||||
108
deploy/runtime-tree-manifest.py
Normal file
108
deploy/runtime-tree-manifest.py
Normal file
@ -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())
|
||||
67
deploy/tests/test-runtime-tree-manifest.py
Normal file
67
deploy/tests/test-runtime-tree-manifest.py
Normal file
@ -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()
|
||||
@ -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
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user