#!/usr/bin/env bash # staging RC 脚本回归:只用假 SSH 与本地假 HTTP 服务,不连接远端、不启动 systemd。 set -euo pipefail ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" ACTIVATE="$ROOT_DIR/deploy/activate-staging-rc.sh" PREPARE="$ROOT_DIR/deploy/prepare-staging-rc.sh" SMOKE="$ROOT_DIR/deploy/smoke-test.sh" STORAGE_AUDIT="$ROOT_DIR/deploy/generate-staging-storage-audit.sh" RESTORE_VERIFY="$ROOT_DIR/deploy/verify-mysql-dump-restore.sh" GENERATION_GATE_REF="sha256:$(printf '0%.0s' $(seq 1 64)):r1/$(printf '0%.0s' $(seq 1 64)).attestation" TMP_DIR="$(mktemp -d)" SERVER_PID="" cleanup() { if [ -n "$SERVER_PID" ]; then kill "$SERVER_PID" 2>/dev/null || true wait "$SERVER_PID" 2>/dev/null || true fi rm -rf "$TMP_DIR" } trap cleanup EXIT fail() { printf 'TEST_FAIL %s\n' "$*" >&2 exit 1 } bash -n "$ACTIVATE" "$PREPARE" "$SMOKE" "$STORAGE_AUDIT" "$RESTORE_VERIFY" rg -q 'docker exec -i .*MYSQL_CONTAINER' "$STORAGE_AUDIT" || fail "存储审计未把 SQL stdin 接入 MySQL 容器" # 假 SSH 只捕获参数和 stdin 中的远端脚本;不执行任何远端命令。 cat >"$TMP_DIR/fake-ssh" <<'SH' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$@" >"$RC_TEST_TMP/ssh-args.txt" cat >"$RC_TEST_TMP/remote-script.sh" SH chmod +x "$TMP_DIR/fake-ssh" export RC_TEST_TMP="$TMP_DIR" RC_COMMIT="0000000000000000000000000000000000000000" \ GENERATION_GATE_EVIDENCE_REF="$GENERATION_GATE_REF" \ BOOTSTRAP_ADMIN_TOKEN_FILE=/root/game-staging/credentials/bootstrap-admin.token \ SSH_BIN="$TMP_DIR/fake-ssh" \ bash "$ACTIVATE" >"$TMP_DIR/activate-wrapper.log" rg -q '^ProxyCommand=none$' "$TMP_DIR/ssh-args.txt" || fail "SSH 未显式禁用代理" rg -q '^root@100\.64\.0\.7$' "$TMP_DIR/ssh-args.txt" || fail "SSH 默认目标不是 mini-desktop 直连地址" bash -n "$TMP_DIR/remote-script.sh" rg -q 'pre-migration-ruoyi-vue-pro\.sql\.gz' "$TMP_DIR/remote-script.sh" || fail "缺迁移前数据库快照" rg -q 'generate-staging-storage-audit\.sh' "$TMP_DIR/remote-script.sh" || fail "缺实时存储审计" rg -q 'verify-mysql-dump-restore\.sh' "$TMP_DIR/remote-script.sh" || fail "缺隔离恢复验证" rg -q 'failure_handler' "$TMP_DIR/remote-script.sh" || fail "缺失败回滚处理" rg -q 'start_stack.*48080 4173 4174 8300 9501 true' "$TMP_DIR/remote-script.sh" || fail "缺正式五端口启动" rg -q 'mv -Tf.*active_pointer' "$TMP_DIR/remote-script.sh" || fail "active 指针不是原子替换" rg -Fq 'spring.cloud.nacos.discovery.enabled=${discovery_enabled}' "$TMP_DIR/remote-script.sh" || \ fail "backend launcher 没有显式控制 Nacos discovery" rg -q 'start_candidate_probe.*14173 14174 18300 19501' "$TMP_DIR/remote-script.sh" || \ fail "候选非数据面探针未启动" rg -q 'verify_generation_gate_evidence' "$TMP_DIR/remote-script.sh" || fail "缺生成 R1 证据校验" ! rg -q 'git .*cat-file.*expected_commit' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 仍要求属于被证明 commit,存在自引用" rg -q 'attestation_root' "$TMP_DIR/remote-script.sh" || fail "缺独立 R1 attestation 根目录" rg -q 'flock -n' "$TMP_DIR/remote-script.sh" || fail "activation 缺单实例部署锁" rg -q 'candidate_backend=SKIPPED_NO_ISOLATED_DATA_PLANE' "$TMP_DIR/remote-script.sh" || \ fail "候选探活没有明确跳过共享数据面 backend" rg -q 'run_flyway_migrations' "$TMP_DIR/remote-script.sh" || fail "迁移未拆成独立受控步骤" rg -q 'restore_database_snapshot' "$TMP_DIR/remote-script.sh" || fail "迁移后失败缺数据库快照补偿" rg -q 'FIRST_RC_FULL_ROLLBACK=UNAVAILABLE' "$TMP_DIR/remote-script.sh" || fail "首个 RC 未明确禁止宣称完整回滚" rg -q 'manage-dogfood-reviewers\.py' "$TMP_DIR/remote-script.sh" || fail "激活未调用审核账号轮换工具" rg -q 'provision-dogfood-with-nacos-transaction\.sh' "$TMP_DIR/remote-script.sh" || \ fail "激活未在 admin 禁用前执行账号预置事务" rg -q 'ADMIN_TOKEN_FILE="\$bootstrap_admin_token_file"' "$TMP_DIR/remote-script.sh" || \ fail "激活账号预置未使用 bootstrap token 文件" rg -q 'PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0' \ "$TMP_DIR/remote-script.sh" || fail "激活未核对 40 人账号终验" rg -q '请先运行受控 new-api import' "$TMP_DIR/remote-script.sh" || \ fail "额度池不足没有给出受控 new-api import 指引" rg -q -- '--ack ROTATE_DOGFOOD_REVIEWERS' "$TMP_DIR/remote-script.sh" || fail "审核账号轮换 ACK 不精确" rg -q 'reviewer-runtime-verification\.json' "$TMP_DIR/remote-script.sh" || fail "缺 reviewer/readonly 登录与旧 token 失效运行态证据" rg -q 'rm -f.*bootstrap_admin_token_file' "$TMP_DIR/remote-script.sh" || fail "轮换成功后未删除 bootstrap token 文件" ! rg -q 'admin123' "$ACTIVATE" || fail "激活脚本写入了默认公开密码" for required_marker in actor_prompt_eval judge_prompt_eval game_content_gold live_prompt full_gate fresh_25 historical_11 production_shadow_20 runtime_eligible; do rg -q "$required_marker" "$TMP_DIR/remote-script.sh" || fail "生成门未核对 R1 字段:$required_marker" done gate_line="$(rg -n 'verify_generation_gate_evidence' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" snapshot_line="$(rg -n '迁移前数据库快照' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" [ "$gate_line" -lt "$snapshot_line" ] || fail "生成门校验没有发生在数据库快照/迁移前" candidate_probe_line="$(rg -n '启动不接共享数据面的候选探针' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" stop_old_line="$(rg -n '候选非数据面探针全绿,停止旧栈' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" restore_verify_line="$(rg -n '在隔离临时 MySQL 容器中验证快照可恢复' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" migration_line="$(rg -n '^run_flyway_migrations$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" [ "$candidate_probe_line" -lt "$stop_old_line" ] || fail "候选非数据面探针没有在停止旧栈前完成" [ "$stop_old_line" -lt "$snapshot_line" ] || fail "迁移快照前旧栈仍可能写入,失败补偿会丢数据" [ "$snapshot_line" -lt "$restore_verify_line" ] || fail "隔离恢复验证没有使用停写后的最终快照" [ "$restore_verify_line" -lt "$migration_line" ] || fail "未先完成隔离恢复验证就执行正式迁移" reviewer_line="$(rg -n 'manage-dogfood-reviewers\.py' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" provision_line="$(rg -n 'provision-dogfood-with-nacos-transaction\.sh' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" provision_pass_line="$(rg -n 'PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" runtime_verify_line="$(rg -n '狗粮审核账号轮换与运行态验证 PASS' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" stable_smoke_line="$(rg -n 'stable-smoke\.log' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" active_switch_line="$(rg -n '正式栈全绿,原子切换 active 指针' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" [ "$provision_line" -lt "$provision_pass_line" ] || fail "账号预置后未核对终验" [ "$provision_pass_line" -lt "$reviewer_line" ] || fail "admin 禁用发生在账号预置恢复前" [ "$reviewer_line" -lt "$runtime_verify_line" ] || fail "审核账号轮换后未做运行态验证" [ "$runtime_verify_line" -lt "$stable_smoke_line" ] || fail "审核账号运行态验证没有位于写 smoke 前" [ "$stable_smoke_line" -lt "$active_switch_line" ] || fail "active 指针在 smoke 前切换" token_delete_line="$(rg -n '^delete_bootstrap_token$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" [ "$active_switch_line" -lt "$token_delete_line" ] || fail "bootstrap token 未在 smoke+active 后最后删除" failure_handler_body="$(sed -n '/^failure_handler()/,/^}/p' "$TMP_DIR/remote-script.sh")" ! printf '%s\n' "$failure_handler_body" | rg -q '^ delete_bootstrap_token' || \ fail "激活失败路径删除了补偿后重试所需的 bootstrap token" if rg -q '/root/games-development-ai' "$TMP_DIR/remote-script.sh"; then fail "激活脚本引用了远端脏旧仓" fi # 生成门引用是激活必填项,且必须在 SSH 前拒绝缺失或不安全路径。 : >"$TMP_DIR/ssh-called" cat >"$TMP_DIR/reject-ssh" <<'SH' #!/usr/bin/env bash printf 'called\n' >>"$RC_TEST_TMP/ssh-called" exit 99 SH chmod +x "$TMP_DIR/reject-ssh" if RC_COMMIT="0000000000000000000000000000000000000000" SSH_BIN="$TMP_DIR/reject-ssh" \ bash "$ACTIVATE" >"$TMP_DIR/missing-generation-gate.log" 2>&1; then fail "缺生成门引用仍允许激活" fi [ ! -s "$TMP_DIR/ssh-called" ] || fail "缺生成门引用后仍发生 SSH" if RC_COMMIT="0000000000000000000000000000000000000000" \ GENERATION_GATE_EVIDENCE_REF="sha256:$(printf '1%.0s' $(seq 1 64)):../gate.txt" \ SSH_BIN="$TMP_DIR/reject-ssh" bash "$ACTIVATE" >"$TMP_DIR/unsafe-generation-gate.log" 2>&1; then fail "不安全生成门相对路径仍允许激活" fi [ ! -s "$TMP_DIR/ssh-called" ] || fail "非法生成门路径后仍发生 SSH" # 真激活缺 bootstrap token 文件路径时必须在 SSH 前 fail-closed,不能回退仓库默认密码。 if RC_COMMIT="0000000000000000000000000000000000000000" \ GENERATION_GATE_EVIDENCE_REF="$GENERATION_GATE_REF" SSH_BIN="$TMP_DIR/reject-ssh" \ bash "$ACTIVATE" >"$TMP_DIR/missing-bootstrap-token.log" 2>&1; then fail "缺 BOOTSTRAP_ADMIN_TOKEN_FILE 仍允许激活" fi [ ! -s "$TMP_DIR/ssh-called" ] || fail "缺 bootstrap token 路径后仍发生 SSH" # 旧变量仅允许受控路径和 hash;任意 URL 必须在 SSH 前失败。 if RC_COMMIT="0000000000000000000000000000000000000000" \ GENERATION_GATE_EVIDENCE_REF="$GENERATION_GATE_REF" \ OBJECT_SNAPSHOT_REF="fake://object-snapshot/rc-test" SSH_BIN="$TMP_DIR/fake-ssh" \ bash "$ACTIVATE" >"$TMP_DIR/invalid-snapshot.log" 2>&1; then fail "任意 OBJECT_SNAPSHOT_REF 仍允许激活" fi # prepare 必须在 ready 前运行发布工具回归与安全关键 Java 测试,并把日志哈希绑定 RC commit。 for test_name in test-staging-rc.sh test-provision-dogfood-users.sh \ test-provision-dogfood-nacos-transaction.sh test-import-newapi-pool.sh \ test-dogfood-smoke.sh; do rg -q "$test_name" "$PREPARE" || fail "prepare 未运行发布工具回归:$test_name" done rg -q 'release-tool-tests\.log' "$PREPARE" || fail "prepare 未保存发布工具测试日志" rg -q 'security-java-tests\.log' "$PREPARE" || fail "prepare 未保存安全关键 Java 测试日志" rg -q 'DogfoodStartupValidatorTest' "$PREPARE" || fail "prepare 未运行 Dogfood 启动安全测试" rg -q 'DogfoodSecurityStartupValidatorTest' "$PREPARE" || fail "prepare 未运行 Dogfood 配置安全测试" rg -q 'TokenAuthenticationFilterTest' "$PREPARE" || fail "prepare 未运行令牌认证过滤器测试" rg -q 'AuthenticationEntryPointImplTest' "$PREPARE" || fail "prepare 未运行未认证响应测试" rg -q 'RuntimePackageServiceImplTest' "$PREPARE" || fail "prepare 未运行 artifact CAS 测试" rg -q 'RuntimePackageApiImplDogfoodTest' "$PREPARE" || fail "prepare 未运行 runtime RPC 安全测试" rg -q 'AdminNewapiQuotaControllerTest' "$PREPARE" || fail "prepare 未运行配额修复入口测试" rg -q 'NewapiQuotaClaimConsumerTest' "$PREPARE" || fail "prepare 未运行配额 MQ 消费测试" rg -q 'NewapiQuotaServiceImplTest' "$PREPARE" || fail "prepare 未运行配额服务测试" rg -q 'test-dogfood-reviewer-security\.py' "$PREPARE" || fail "prepare 未运行审核账号安全测试" rg -q 'test_dogfood_ops\.py' "$PREPARE" || fail "prepare 未运行波次证据账本测试" rg -q 'backend_security_tests=PASS' "$PREPARE" || fail "prepare 未把安全 Java 测试结果绑定 RC" rg -q 'security_java_tests_sha256=' "$PREPARE" || fail "prepare 摘要未绑定安全测试日志哈希" rg -q 'verify_security_java_reports' "$PREPARE" || fail "prepare 未校验安全测试报告实际包含全部目标类" rg -q 'security-java-tests-summary\.tsv' "$PREPARE" || fail "prepare 未保存可审计的 Java 测试计数摘要" rg -q 'security_java_tests_summary_sha256=' "$PREPARE" || fail "prepare 摘要未绑定 Java 测试计数证据" rg -q 'FRONTEND_SECRET_SCAN_PASS' "$PREPARE" || fail "prepare 未扫描前端制品中的默认凭据" rg -q 'frontend_secret_scan_sha256=' "$PREPARE" || fail "prepare 摘要未绑定前端凭据扫描证据" ! rg -q 'backend_tests=NOT_RUN' "$PREPARE" || fail "prepare 仍宣称后端测试未运行" venv_line="$(rg -n '准备 commit 内独立 Python 虚拟环境' "$PREPARE" | cut -d: -f1)" tool_test_line="$(rg -n '运行发布工具回归测试' "$PREPARE" | cut -d: -f1)" [ "$venv_line" -lt "$tool_test_line" ] || fail "发布工具回归未使用 commit 内已建 venv" rg -q 'cheap-worker/\.venv/bin.*PATH' "$PREPARE" || fail "发布工具回归未显式使用 commit 内 Python" rg -q '原子切换 active' "$PREPARE" || fail "prepare 完成提示仍未使用 active 指针" # 独立 attestation 不属于被证明 commit:内容寻址文件应通过,宽权限、symlink、commit 漂移必须拒绝。 cat >"$TMP_DIR/exec-ssh" <<'SH' #!/usr/bin/env bash set -euo pipefail while [ "$#" -gt 0 ] && [ "$1" != bash ]; do shift; done [ "${1:-}" = bash ] || exit 97 exec "$@" SH chmod +x "$TMP_DIR/exec-ssh" attestation_root="$(realpath "$TMP_DIR")/attestations" mkdir -p "$attestation_root/r1" attestation_file="$attestation_root/r1/r1.attestation" cat >"$attestation_file" <"$TMP_DIR/attestation-pass.log" rg -q 'GENERATION_ATTESTATION_PASS' "$TMP_DIR/attestation-pass.log" || fail "独立 attestation 未通过" chmod 644 "$attestation_file" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "权限过宽 attestation 未拒绝" fi chmod 600 "$attestation_file" ln -s "$attestation_file" "$attestation_root/r1/symlink.attestation" if RC_COMMIT=0000000000000000000000000000000000000000 \ GENERATION_GATE_EVIDENCE_REF="sha256:$attestation_hash:r1/symlink.attestation" \ GENERATION_ATTESTATION_ROOT="$attestation_root" SSH_BIN="$TMP_DIR/exec-ssh" \ bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "symlink attestation 未拒绝" fi if RC_COMMIT=1111111111111111111111111111111111111111 \ GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then fail "rc_commit 漂移 attestation 未拒绝" fi # 直接验证 DB-only 审计:生成、外部引用 fail-closed、旧 hash/路径校验。 FAKE_RC="$TMP_DIR/fake-rc" mkdir -p "$FAKE_RC/evidence" git -C "$FAKE_RC" init -q git -C "$FAKE_RC" config user.email test@example.invalid git -C "$FAKE_RC" config user.name staging-test printf 'rc\n' >"$FAKE_RC/README" git -C "$FAKE_RC" add README git -C "$FAKE_RC" commit -qm init cat >"$TMP_DIR/fake-audit-mysql" <<'SH' #!/usr/bin/env bash set -euo pipefail cat >/dev/null cat <<'OUT' game_version_rows=53 game_version_package_url_nonempty=0 game_runtime_package_rows=54 game_runtime_package_url_nonempty=0 game_runtime_package_payload_rows=48 game_source_project_rows=41 game_source_project_url_nonempty=0 game_source_project_payload_rows=41 game_aigc_task_rows=78 game_aigc_result_package_url_nonempty=0 infra_file_rows=0 OUT SH chmod +x "$TMP_DIR/fake-audit-mysql" audit_output="$(RC_RELEASE="$FAKE_RC" STORAGE_AUDIT_MYSQL_BIN="$TMP_DIR/fake-audit-mysql" \ bash "$STORAGE_AUDIT")" audit_ref="$(printf '%s\n' "$audit_output" | sed -n 's/^STORAGE_AUDIT_PASS ref=//p')" [[ "$audit_ref" =~ ^sha256:[0-9a-f]{64}:evidence/storage-audit\.tsv$ ]] || fail "审计引用格式非法" rg -q '^object_storage_status=DB_ONLY_NA$' "$FAKE_RC/evidence/storage-audit.tsv" || fail "缺 DB_ONLY_NA" rg -q '^minio_involved_prefix_count=0$' "$FAKE_RC/evidence/storage-audit.tsv" || fail "缺 MinIO 前缀事实" RC_RELEASE="$FAKE_RC" STORAGE_AUDIT_MYSQL_BIN="$TMP_DIR/fake-audit-mysql" \ EXPECTED_STORAGE_AUDIT_REF="$audit_ref" bash "$STORAGE_AUDIT" >/dev/null bad_ref="sha256:$(printf '0%.0s' $(seq 1 64)):evidence/storage-audit.tsv" if RC_RELEASE="$FAKE_RC" STORAGE_AUDIT_MYSQL_BIN="$TMP_DIR/fake-audit-mysql" \ EXPECTED_STORAGE_AUDIT_REF="$bad_ref" bash "$STORAGE_AUDIT" >/dev/null 2>&1; then fail "错误 storage audit hash 未被拒绝" fi cat >"$TMP_DIR/fake-audit-external" <<'SH' #!/usr/bin/env bash set -euo pipefail cat >/dev/null sed 's/game_runtime_package_url_nonempty=0/game_runtime_package_url_nonempty=1/' "$RC_TEST_TMP/audit-values" SH cp "$TMP_DIR/fake-audit-mysql" "$TMP_DIR/audit-values-script" "$TMP_DIR/fake-audit-mysql" "$TMP_DIR/audit-values" chmod +x "$TMP_DIR/fake-audit-external" if RC_RELEASE="$FAKE_RC" STORAGE_AUDIT_MYSQL_BIN="$TMP_DIR/fake-audit-external" \ bash "$STORAGE_AUDIT" >/dev/null 2>&1; then fail "发现外部 URL 时仍签发 DB_ONLY_NA" fi # 隔离恢复验证使用假 Docker 覆盖 run/ready/import/query/stop,不触碰真实容器。 printf 'CREATE DATABASE test;\n' | gzip -9 >"$TMP_DIR/dump.sql.gz" cat >"$TMP_DIR/fake-docker" <<'SH' #!/usr/bin/env bash set -euo pipefail printf '%s\n' "$*" >>"$RC_TEST_TMP/docker-calls.log" case "$1" in run) printf 'fake-container-id\n' ;; stop) ;; exec) if printf '%s\n' "$*" | grep -q 'SELECT 1'; then exit 0 fi if printf '%s\n' "$*" | grep -q -- '-N -B'; then cat >/dev/null cat <<'OUT' flyway_failed_rows=0 game_version_rows=53 game_version_package_url_nonempty=0 game_runtime_package_rows=54 game_runtime_package_url_nonempty=0 game_runtime_package_payload_rows=48 game_source_project_rows=41 game_source_project_url_nonempty=0 game_source_project_payload_rows=41 game_aigc_task_rows=78 game_aigc_result_package_url_nonempty=0 infra_file_rows=0 OUT else cat >/dev/null fi ;; *) exit 2 ;; esac SH chmod +x "$TMP_DIR/fake-docker" DUMP_FILE="$TMP_DIR/dump.sql.gz" EXPECTED_AUDIT_FILE="$FAKE_RC/evidence/storage-audit.tsv" \ DOCKER_BIN="$TMP_DIR/fake-docker" RESTORE_VERIFY_TIMEOUT_SEC=10 \ bash "$RESTORE_VERIFY" >"$TMP_DIR/restore.log" rg -q '^RESTORE_VERIFY_PASS ' "$TMP_DIR/restore.log" || fail "假容器恢复未通过" rg -q '^run .*--network none ' "$TMP_DIR/docker-calls.log" || fail "恢复容器未隔离网络" rg -q '^stop ' "$TMP_DIR/docker-calls.log" || fail "恢复容器未销毁" DUMP_FILE="$TMP_DIR/dump.sql.gz" EXPECTED_AUDIT_FILE="$FAKE_RC/evidence/storage-audit.tsv" \ RESTORE_VERIFY_DRY_RUN=1 bash "$RESTORE_VERIFY" >"$TMP_DIR/restore-dry.log" rg -q '^RESTORE_VERIFY_DRY_RUN_PASS' "$TMP_DIR/restore-dry.log" || fail "恢复 dry-run 输入校验失败" # 本地假服务验证 readiness smoke 全程只有 GET。 cat >"$TMP_DIR/fake-readiness.py" <<'PY' import json import os from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer from socketserver import TCPServer port_file = os.environ["RC_TEST_PORT_FILE"] method_file = os.environ["RC_TEST_METHOD_FILE"] class Handler(BaseHTTPRequestHandler): def log_message(self, _format, *_args): return def send_json(self, payload): body = json.dumps(payload).encode() self.send_response(200) self.send_header("Content-Type", "application/json") self.send_header("Content-Length", str(len(body))) self.end_headers() self.wfile.write(body) def record(self): with open(method_file, "a", encoding="utf-8") as handle: handle.write(self.command + " " + self.path + "\n") def do_GET(self): self.record() if self.path == "/actuator/health": self.send_json({"status": "UP"}) elif self.path.startswith("/app-api/feed/zones"): self.send_json({"code": 0, "data": [{"id": 2}]}) elif self.path.startswith("/app-api/feed/stream"): self.send_json({"code": 0, "data": {"list": [{"gameId": 1, "versionId": 2}], "hasMore": False}}) elif self.path.startswith("/app-api/runtime/package/2"): self.send_json({"code": 0, "data": {"versionId": 2}}) else: self.send_json({"code": 404, "data": None}) def do_POST(self): self.record() self.send_json({"code": 500}) class LocalThreadingHTTPServer(ThreadingHTTPServer): def server_bind(self): # 跳过 HTTPServer.server_bind 的反向 DNS 查询,离线构建机不能依赖主机名解析。 TCPServer.server_bind(self) self.server_name, self.server_port = self.server_address[:2] server = LocalThreadingHTTPServer(("127.0.0.1", 0), Handler) with open(port_file, "w", encoding="utf-8") as handle: handle.write(str(server.server_port)) server.serve_forever() PY export RC_TEST_PORT_FILE="$TMP_DIR/port" export RC_TEST_METHOD_FILE="$TMP_DIR/methods" python3 "$TMP_DIR/fake-readiness.py" & SERVER_PID=$! for _ in $(seq 1 300); do [ -s "$TMP_DIR/port" ] && break sleep 0.1 done [ -s "$TMP_DIR/port" ] || fail "假 readiness 服务未启动" port="$(<"$TMP_DIR/port")" BASE="http://127.0.0.1:$port" STUDIO_BASE="http://127.0.0.1:4173" \ bash "$SMOKE" >"$TMP_DIR/smoke.log" if rg -n -v '^GET ' "$TMP_DIR/methods"; then fail "默认 smoke 出现写请求" fi printf 'TEST_PASS staging RC 存储审计、隔离恢复与只读 smoke 均通过\n'