diff --git a/deploy/activate-staging-rc.sh b/deploy/activate-staging-rc.sh index 17217e7b..e86a95a6 100755 --- a/deploy/activate-staging-rc.sh +++ b/deploy/activate-staging-rc.sh @@ -736,8 +736,43 @@ restore_active_pointer() { fi } +verify_active_pointer() { + local target="$1" expected_realpath actual_realpath expected_commit actual_commit + if [ "$target" = "none" ]; then + [ ! -e "$active_pointer" ] && [ ! -L "$active_pointer" ] || { + log_step "active 指针应为空但仍存在 path=$active_pointer" + return 1 + } + return 0 + fi + + [ -L "$active_pointer" ] || { + log_step "active 指针不是符号链接 path=$active_pointer" + return 1 + } + expected_realpath="$(realpath "$target")" || return 1 + actual_realpath="$(readlink -f "$active_pointer")" || return 1 + [ "$actual_realpath" = "$expected_realpath" ] || { + log_step "active 指针目标不一致 expected=$expected_realpath actual=$actual_realpath" + return 1 + } + + expected_commit="$(git -C "$expected_realpath" rev-parse HEAD)" || return 1 + [[ "$expected_commit" =~ ^[0-9a-f]{40}$ ]] || return 1 + [ "$(basename "$expected_realpath")" = "$expected_commit" ] || { + log_step "active RC 目录名与提交不一致 path=$expected_realpath commit=$expected_commit" + return 1 + } + actual_commit="$(git -C "$actual_realpath" rev-parse HEAD)" || return 1 + [ "$actual_commit" = "$expected_commit" ] || { + log_step "active 指针提交不一致 expected=$expected_commit actual=$actual_commit" + return 1 + } +} + failure_handler() { - local rc=$? + local rc=$? rollback_rc=0 rollback_service_rc=not-run rollback_pointer_rc=not-run + local maintenance_release_rc=not-run trap - ERR set +e log_step "ACTIVATION FAIL rc=${rc},开始保留证据并恢复旧栈" @@ -759,32 +794,61 @@ failure_handler() { fi fi if [ "$switch_started" -eq 1 ]; then - stop_stack "$stable_prefix" 48080 4173 4174 8300 9501 + rollback_service_rc=0 + stop_stack "$stable_prefix" 48080 4173 4174 8300 9501 || rollback_service_rc=$? if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -eq 1 ]; then log_step "恢复旧版本服务 target=$previous" - rollback_rc=0 - start_stack "$previous" rollback "$stable_prefix" 48080 4173 4174 8300 9501 true \ - || rollback_rc=$? - if [ "$rollback_rc" -eq 0 ]; then - verify_stack rollback "$stable_prefix" 48080 4173 4174 8300 9501 || rollback_rc=$? + if [ "$rollback_service_rc" -eq 0 ]; then + start_stack "$previous" rollback "$stable_prefix" 48080 4173 4174 8300 9501 true \ + || rollback_service_rc=$? fi - if [ "$rollback_rc" -eq 0 ]; then - release_maintenance_gate || rollback_rc=$? + if [ "$rollback_service_rc" -eq 0 ]; then + verify_stack rollback "$stable_prefix" 48080 4173 4174 8300 9501 \ + || rollback_service_rc=$? fi - printf 'rollback_service_rc=%s\n' "$rollback_rc" >"$evidence/rollback-result.txt" - if [ "$rollback_rc" -ne 0 ]; then - log_step "旧栈五服务恢复或复核失败,保留维护门" - rc=73 + if [ "$rollback_service_rc" -ne 0 ]; then + rollback_rc="$rollback_service_rc" fi else - printf 'rollback_service_rc=not-available\nFIRST_RC_FULL_ROLLBACK=UNAVAILABLE\n' >"$evidence/rollback-result.txt" + rollback_service_rc=not-available + rollback_rc=73 + fi + + # 切换事务一旦开始,就重建 canonical active;不能依赖 pointer_changed 标记覆盖所有失败窗口。 + if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -eq 1 ]; then + rollback_pointer_rc=0 + restore_active_pointer "$previous" || rollback_pointer_rc=$? + if [ "$rollback_pointer_rc" -eq 0 ]; then + verify_active_pointer "$previous" || rollback_pointer_rc=$? + fi + if [ "$rollback_pointer_rc" -ne 0 ] && [ "$rollback_rc" -eq 0 ]; then + rollback_rc="$rollback_pointer_rc" + fi fi fi - if [ "$pointer_changed" -eq 1 ]; then - restore_active_pointer "$previous" + + # 数据库、旧栈和 active 指针全部恢复后,解除维护门才是失败补偿的最后一步。 + if [ "$maintenance_gate_active" -eq 1 ] && [ "$rollback_rc" -eq 0 ]; then + maintenance_release_rc=0 + release_maintenance_gate || maintenance_release_rc=$? + if [ "$maintenance_release_rc" -ne 0 ]; then + rollback_rc="$maintenance_release_rc" + fi fi - if [ "$switch_started" -eq 0 ] && [ "$maintenance_gate_active" -eq 1 ]; then - release_maintenance_gate || rc=74 + if [ "$switch_started" -eq 1 ]; then + { + printf 'rollback_service_rc=%s\n' "$rollback_service_rc" + printf 'rollback_pointer_rc=%s\n' "$rollback_pointer_rc" + printf 'maintenance_release_rc=%s\n' "$maintenance_release_rc" + printf 'rollback_rc=%s\n' "$rollback_rc" + if [ "$previous_was_active" -ne 1 ] || [ "$previous_valid" -ne 1 ]; then + printf 'FIRST_RC_FULL_ROLLBACK=UNAVAILABLE\n' + fi + } >"$evidence/rollback-result.txt" + fi + if [ "$rollback_rc" -ne 0 ]; then + log_step "失败补偿未完整恢复,保留维护门 rollback_rc=$rollback_rc" + rc=73 fi if [ "$bootstrap_token_validated" -eq 1 ]; then log_step "激活失败,保留 0600 bootstrap admin token 供补偿后重试或人工吊销" diff --git a/deploy/collect-dogfood-account-report.sh b/deploy/collect-dogfood-account-report.sh index 96439aa9..3e4ae267 100755 --- a/deploy/collect-dogfood-account-report.sh +++ b/deploy/collect-dogfood-account-report.sh @@ -7,6 +7,7 @@ readonly EXPECTED_SIGNER_HOST='minione-ubuntu-infra' readonly SSH_TARGET='root@100.64.0.7' readonly SIGNER_ISSUER='r1-mini-infra' readonly SIGNER_PRIVATE_KEY='/root/dogfood-r1-signer/r1-ed25519-private.pem' +readonly STAGING_RELEASES_ROOT='/root/game-staging/releases' readonly REPORT_SCHEMA='dogfood-account-database-report/2' readonly QUERY_ID='dogfood-account-readiness-v2' readonly QUERY_SCHEMA='dogfood-account-readiness-row/2' @@ -46,11 +47,12 @@ output="$output_dir/$(basename "$output")" test_mode="${DOGFOOD_COLLECTOR_TEST_MODE:-0}" if [ "$test_mode" = 1 ]; then - # 测试钩子只替换进程和私钥,SSH 目标、查询、issuer 与报告 schema 仍由生产常量固定。 + # 测试钩子只替换进程、私钥和本地 fixture 根;生产 SSH 目标、issuer 与报告 schema 仍固定。 ssh_bin="${DOGFOOD_COLLECTOR_TEST_SSH_BIN:?}" signer_key="${DOGFOOD_COLLECTOR_TEST_SIGNER_KEY:?}" openssl_bin="${DOGFOOD_COLLECTOR_TEST_OPENSSL_BIN:?}" docker_bin="${DOGFOOD_COLLECTOR_TEST_DOCKER_BIN:?}" + runtime_releases_root="${DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT:?}" else [ "$(id -u)" = 0 ] || die '采集器必须以 mini-infra root signer 身份运行' [ "$(hostname -s)" = "$EXPECTED_SIGNER_HOST" ] || die '采集器只能在固定 mini-infra signer 主机运行' @@ -58,6 +60,7 @@ else signer_key="$SIGNER_PRIVATE_KEY" openssl_bin='/usr/bin/openssl' docker_bin='/usr/bin/docker' + runtime_releases_root="$STAGING_RELEASES_ROOT" fi [ -x "$ssh_bin" ] || die '固定 SSH 客户端不可执行' [ -x "$openssl_bin" ] || die 'OpenSSL 客户端不可执行' @@ -119,9 +122,83 @@ SQL tmp_rows="$output_dir/.account-report-rows.$$" tmp_newapi_rows="$output_dir/.account-report-newapi-rows.$$" +tmp_runtime_state_before="$output_dir/.account-report-runtime-before.$$" +tmp_runtime_state_after="$output_dir/.account-report-runtime-after.$$" tmp_report="$output_dir/.account-report.$$" tmp_signature="$output_dir/.account-report-signature.$$" -trap 'rm -f "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$tmp_signature"' EXIT +trap 'rm -f "$tmp_rows" "$tmp_newapi_rows" "$tmp_runtime_state_before" "$tmp_runtime_state_after" "$tmp_report" "$tmp_signature"' EXIT + +collect_runtime_state() { + local destination="$1" quoted_root remote_command + printf -v quoted_root '%q' "$runtime_releases_root" + remote_command="$(cat <"$destination" || die '固定 staging active/RC_STATUS/activation evidence 校验失败' + chmod 600 "$destination" +} + +collect_runtime_state "$tmp_runtime_state_before" +if [ "$(awk 'END { print NR + 0 }' "$tmp_runtime_state_before")" -ne 1 ]; then + die 'staging 运行态观测输出行数非法' +fi +IFS=$'\t' read -r state_schema observed_rc_commit observed_activation_run_id \ + observed_completed_at observed_release observed_evidence <"$tmp_runtime_state_before" +[ "$state_schema" = DOGFOOD_RUNTIME_STATE_V1 ] || die 'staging 运行态观测 schema 非法' +[ "$observed_rc_commit" = "$rc_commit" ] || die 'expected rcCommit 与 staging active commit 漂移' +[ "$observed_activation_run_id" = "$activation_run_id" ] || die 'expected activation run-id 与 staging 事实漂移' remote_mysql_command='set -euo pipefail; . /root/huijing-dev.env; : "${MYSQL_ROOT_PASSWORD:?}"; docker exec -i game-staging-mysql sh -lc '\''MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B ruoyi-vue-pro'\''' printf '%s\n' "$FIXED_QUERY" | "$ssh_bin" \ @@ -132,17 +209,24 @@ printf '%s\n' "$FIXED_NEWAPI_QUERY" | "$docker_bin" exec -i "$NEWAPI_PG_CONTAINE 'PGPASSWORD="$POSTGRES_PASSWORD" exec psql -U root -d new-api -q -t -A -F "|" -v ON_ERROR_STOP=1' \ >"$tmp_newapi_rows" || die '固定 new-api PostgreSQL 只读查询失败' chmod 600 "$tmp_newapi_rows" +collect_runtime_state "$tmp_runtime_state_after" +cmp -s "$tmp_runtime_state_before" "$tmp_runtime_state_after" || \ + die '账号采集期间 staging active/activation evidence 发生漂移' captured_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')" -python3 - "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$activation_run_id" "$rc_commit" "$captured_at" \ - "$REPORT_SCHEMA" "$SIGNER_ISSUER" "$QUERY_ID" "$QUERY_SCHEMA" <<'PY' +python3 - "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$observed_activation_run_id" \ + "$observed_rc_commit" "$captured_at" "$REPORT_SCHEMA" "$SIGNER_ISSUER" "$QUERY_ID" "$QUERY_SCHEMA" \ + "$observed_completed_at" "$observed_release" "$observed_evidence" <<'PY' import json import re import sys from pathlib import Path rows_path, newapi_rows_path, report_path = map(Path, sys.argv[1:4]) -activation_run_id, rc_commit, captured_at, schema, issuer, query_id, query_schema = sys.argv[4:] +( + activation_run_id, rc_commit, captured_at, schema, issuer, query_id, query_schema, + activation_completed_at, active_release, activation_evidence, +) = sys.argv[4:] expected_roles = { **{f"dogfoodc{index:02d}": "creator" for index in range(1, 11)}, **{f"dogfoodp{index:02d}": "player" for index in range(1, 31)}, @@ -254,6 +338,10 @@ report = { "schema": schema, "issuer": issuer, "activationRunId": activation_run_id, + "activationStatus": "PASS", + "activationCompletedAt": activation_completed_at, + "activeReleaseRef": active_release, + "activationEvidenceRef": activation_evidence, "rcCommit": rc_commit, "capturedAt": captured_at, "queryId": query_id, diff --git a/deploy/dogfood-ops/README.md b/deploy/dogfood-ops/README.md index e0e2eded..9f1b579c 100644 --- a/deploy/dogfood-ops/README.md +++ b/deploy/dogfood-ops/README.md @@ -82,14 +82,16 @@ deploy/dogfood-ops/init-wave.sh \ ### 3.1 账号报告 `/2` -账号报告不能手写。采集器必须在 mini-infra 以 root signer 身份运行。它先固定通过 `root@100.64.0.7` 查询 `game-staging-mysql/ruoyi-vue-pro`,在 MySQL `READ ONLY` 事务内取得 40 条 `CLAIMED` 和 12 条 `FREE` 池记录;再固定查询 mini-infra 本机 `infra-postgres/new-api`,从容器已有的 `POSTGRES_PASSWORD` 设置 `PGPASSWORD`,不把密码放入参数、日志或报告。每条池记录必须按 `newapiUserId + newapiTokenId` 唯一命中 canonical `neice_NNN` 用户/token,且 token 同时满足 `status=1`、`unlimited_quota=false`、`expired_time=-1`、`remain_quota>0`。任一禁用、耗尽、ID 漂移、重复行或多 token 匹配都会在签名前失败。 +账号报告不能手写。采集器必须在 mini-infra 以 root signer 身份运行。它先固定通过 `root@100.64.0.7` 读取 `/root/game-staging/releases/active` 的 canonical commit、该 release 的单行 `RC_STATUS`,以及 `RC_STATUS` 指向的 activation evidence;只有 `activation.log` 最后两个非空记录分别证明同一 commit 的 `RC_ACTIVATION_PASS` 和同一路径的 `ACTIVATION_EVIDENCE` 才继续。命令行 `--rc-commit` 与 `--activation-run-id` 只是 expected 值,不能成为报告事实来源;任一参数与运行态不一致均拒签。采集器在数据库查询后再次读取相同状态,active 指针、RC_STATUS 或 evidence 发生漂移也拒签。 + +运行态身份稳定后,采集器在 MySQL `READ ONLY` 事务内取得 40 条 `CLAIMED` 和 12 条 `FREE` 池记录;再固定查询 mini-infra 本机 `infra-postgres/new-api`,从容器已有的 `POSTGRES_PASSWORD` 设置 `PGPASSWORD`,不把密码放入参数、日志或报告。每条池记录必须按 `newapiUserId + newapiTokenId` 唯一命中 canonical `neice_NNN` 用户/token,且 token 同时满足 `status=1`、`unlimited_quota=false`、`expired_time=-1`、`remain_quota>0`。任一禁用、耗尽、ID 漂移、重复行或多 token 匹配都会在签名前失败。 ```bash # 在可信工作站从指定 RC commit 导出脚本,不使用当前未提交工作树。 set -euo pipefail umask 077 : "${RC_COMMIT:?请先设置 commit-pinned RC 的 40 位哈希}" -: "${ACTIVATION_RUN_ID:?请先设置激活 run-id}" +: "${ACTIVATION_RUN_ID:?请先设置 active release 的 activation run-id,例如 20260723T010203Z}" [[ "$RC_COMMIT" =~ ^[0-9a-f]{40}$ ]] [[ "$ACTIVATION_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] PINNED_SCRIPT="$(mktemp)" @@ -133,12 +135,12 @@ install -m 600 "$TRANSFER_DIR/wave0-account-report.json.sig" /受控证据根/wa rm -rf "$TRANSFER_DIR" ``` -随后填写: +报告中的 `rcCommit`、`activationRunId`、`activationCompletedAt`、`activeReleaseRef`、`activationEvidenceRef` 和 `activationStatus=PASS` 全部来自上述两次一致的 mini-desktop 观测,不复制命令参数。随后填写: ```json { "schema": "dogfood-accounts/2", - "activationRunId": "activation-20260723T010203Z", + "activationRunId": "20260723T010203Z", "databaseReportRef": "attachments/wave0-account-report.json", "databaseReportSignatureRef": "attachments/wave0-account-report.json.sig", "databaseReportSha256": "<报告文件SHA-256>", diff --git a/deploy/tests/test-collect-dogfood-account-report.sh b/deploy/tests/test-collect-dogfood-account-report.sh index f2d7adda..1b9898b9 100755 --- a/deploy/tests/test-collect-dogfood-account-report.sh +++ b/deploy/tests/test-collect-dogfood-account-report.sh @@ -48,9 +48,23 @@ openssl pkey -in "$TMP_DIR/private.pem" -pubout -out "$TMP_DIR/public.pem" >/dev cat >"$TMP_DIR/fake-ssh" <<'SH' #!/usr/bin/env bash set -euo pipefail -printf '%s\n' "$*" >"$DOGFOOD_TEST_SSH_ARGS" -cat >"$DOGFOOD_TEST_SQL_CAPTURE" -cat "$DOGFOOD_TEST_MYSQL_ROWS" +printf '%s\n' "$*" >>"$DOGFOOD_TEST_SSH_ARGS" +remote_command="${!#}" +if [[ "$remote_command" == *DOGFOOD_RUNTIME_STATE_V1* ]]; then + state_call_count=0 + if [ -f "$DOGFOOD_TEST_STATE_CALL_COUNT" ]; then + state_call_count="$(cat "$DOGFOOD_TEST_STATE_CALL_COUNT")" + fi + state_call_count="$((state_call_count + 1))" + printf '%s\n' "$state_call_count" >"$DOGFOOD_TEST_STATE_CALL_COUNT" + if [ "$state_call_count" -eq 2 ] && [ -n "${DOGFOOD_TEST_DRIFT_TARGET:-}" ]; then + ln -sfn "$DOGFOOD_TEST_DRIFT_TARGET" "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active" + fi + bash -c "$remote_command" +else + cat >"$DOGFOOD_TEST_SQL_CAPTURE" + cat "$DOGFOOD_TEST_MYSQL_ROWS" +fi SH chmod +x "$TMP_DIR/fake-ssh" @@ -74,11 +88,46 @@ export DOGFOOD_TEST_MYSQL_ROWS="$TMP_DIR/mysql.tsv" export DOGFOOD_TEST_DOCKER_ARGS="$TMP_DIR/docker-args" export DOGFOOD_TEST_PG_SQL_CAPTURE="$TMP_DIR/newapi.sql" export DOGFOOD_TEST_NEWAPI_ROWS="$TMP_DIR/newapi.tsv" +export DOGFOOD_TEST_STATE_CALL_COUNT="$TMP_DIR/state-call-count" +export DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT="$TMP_DIR/releases" +mkdir -p "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT" +export DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT="$(cd "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT" && pwd -P)" + +active_commit=1111111111111111111111111111111111111111 +active_run_id=20260723T010203Z +active_release="$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/$active_commit" +active_evidence="$active_release/evidence/activation-$active_run_id" +mkdir -p "$active_evidence" +ln -s "$active_release" "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active" +printf 'active commit=%s at=2026-07-23T01:02:03Z evidence=%s\n' \ + "$active_commit" "$active_evidence" >"$active_release/RC_STATUS" +cat >"$active_evidence/activation.log" </dev/null 2>&1; then + fail "$name 未被拒绝" + fi + [ ! -e "$TMP_DIR/$name.json" ] && [ ! -e "$TMP_DIR/$name.json.sig" ] || \ + fail "$name 失败后遗留半成品" +} report="$TMP_DIR/account-report.json" +reset_state_probe "$SCRIPT" \ - --activation-run-id activation-20260723T010203Z \ - --rc-commit 1111111111111111111111111111111111111111 \ + --activation-run-id "$active_run_id" \ + --rc-commit "$active_commit" \ --output "$report" [ -f "$report" ] && [ -f "$report.sig" ] || fail '没有生成报告及 detached signature' @@ -103,6 +152,8 @@ if grep -Eqi 'SELECT[^;]*(key|access_token|password)' "$TMP_DIR/newapi.sql"; the fi grep -q "SIGNER_PRIVATE_KEY='/root/dogfood-r1-signer/r1-ed25519-private.pem'" "$SCRIPT" || \ fail '生产 signer 私钥路径不是固定值' +grep -q "STAGING_RELEASES_ROOT='/root/game-staging/releases'" "$SCRIPT" || \ + fail '生产 staging releases 根不是固定值' grep -q 'realpath.*signer_key' "$SCRIPT" || fail '生产 signer 私钥未校验 canonical 路径' grep -q "stat -c '%u'.*signer_key" "$SCRIPT" || fail '生产 signer 私钥未校验 root 所有权' @@ -116,6 +167,14 @@ assert report["issuer"] == "r1-mini-infra" assert report["queryId"] == "dogfood-account-readiness-v2" assert report["querySchema"] == "dogfood-account-readiness-row/2" assert report["rcCommit"] == "1" * 40 +assert report["activationRunId"] == "20260723T010203Z" +assert report["activationStatus"] == "PASS" +assert report["activationCompletedAt"] == "2026-07-23T01:02:03Z" +assert report["activeReleaseRef"].endswith("/" + "1" * 40) +assert report["activationEvidenceRef"].endswith( + "/1111111111111111111111111111111111111111/evidence/activation-20260723T010203Z" +) +assert report["activationEvidenceRef"].startswith(report["activeReleaseRef"] + "/evidence/") assert len(report["accounts"]) == 40 assert len(report["reserves"]) == 12 for item in report["accounts"] + report["reserves"]: @@ -129,11 +188,92 @@ for secret_name in ("tokenKey", "password", "accessToken", "refreshToken"): assert secret_name not in serialized PY +# 参数只是 expected:与 mini-desktop 当前 active 事实不一致时,签名器必须拒绝。 +assert_refused_without_output forged-commit \ + --activation-run-id "$active_run_id" \ + --rc-commit 2222222222222222222222222222222222222222 +assert_refused_without_output forged-run-id \ + --activation-run-id 20260723T111111Z \ + --rc-commit "$active_commit" + +# active、RC_STATUS、activation evidence 任一缺失都不能退回使用命令参数。 +rm "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active" +assert_refused_without_output missing-active \ + --activation-run-id "$active_run_id" --rc-commit "$active_commit" +ln -s "$active_release" "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active" + +mv "$active_release/RC_STATUS" "$active_release/RC_STATUS.missing" +assert_refused_without_output missing-rc-status \ + --activation-run-id "$active_run_id" --rc-commit "$active_commit" +mv "$active_release/RC_STATUS.missing" "$active_release/RC_STATUS" + +mv "$active_evidence" "$active_evidence.missing" +assert_refused_without_output missing-activation-evidence \ + --activation-run-id "$active_run_id" --rc-commit "$active_commit" +mv "$active_evidence.missing" "$active_evidence" + +# RC_STATUS 与 evidence 自身必须绑定 active commit、规范 run-id 和成功终态。 +cp "$active_release/RC_STATUS" "$TMP_DIR/RC_STATUS.valid" +sed "s/commit=$active_commit/commit=2222222222222222222222222222222222222222/" \ + "$TMP_DIR/RC_STATUS.valid" >"$active_release/RC_STATUS" +assert_refused_without_output rc-status-commit-drift \ + --activation-run-id "$active_run_id" --rc-commit "$active_commit" +cp "$TMP_DIR/RC_STATUS.valid" "$active_release/RC_STATUS" + +malformed_evidence="$active_release/evidence/activation-manual" +mv "$active_evidence" "$malformed_evidence" +sed "s#evidence=$active_evidence#evidence=$malformed_evidence#" \ + "$TMP_DIR/RC_STATUS.valid" >"$active_release/RC_STATUS" +sed "s#ACTIVATION_EVIDENCE=$active_evidence#ACTIVATION_EVIDENCE=$malformed_evidence#" \ + "$malformed_evidence/activation.log" >"$TMP_DIR/activation.log.malformed" +mv "$TMP_DIR/activation.log.malformed" "$malformed_evidence/activation.log" +chmod 600 "$malformed_evidence/activation.log" +assert_refused_without_output malformed-evidence-run-id \ + --activation-run-id "$active_run_id" --rc-commit "$active_commit" +mv "$malformed_evidence" "$active_evidence" +cp "$TMP_DIR/RC_STATUS.valid" "$active_release/RC_STATUS" +cat >"$active_evidence/activation.log" <"$active_evidence/activation.log" <"$drift_release/RC_STATUS" +cat >"$drift_evidence/activation.log" </dev/null 2>&1; then + fail '采集期间 active 指针漂移仍被签发' +fi +[ ! -e "$TMP_DIR/pointer-drift.json" ] && [ ! -e "$TMP_DIR/pointer-drift.json.sig" ] || \ + fail '指针漂移失败后遗留半成品' +ln -sfn "$active_release" "$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/active" +unset DOGFOOD_TEST_DRIFT_TARGET + # 精确规模是签发前置条件,39 个 CLAIMED 账号必须失败且不得留下半成品。 head -n 51 "$TMP_DIR/mysql.tsv" >"$TMP_DIR/incomplete.tsv" export DOGFOOD_TEST_MYSQL_ROWS="$TMP_DIR/incomplete.tsv" -if "$SCRIPT" --activation-run-id activation-20260723T010203Z \ - --rc-commit 1111111111111111111111111111111111111111 \ +if "$SCRIPT" --activation-run-id "$active_run_id" \ + --rc-commit "$active_commit" \ --output "$TMP_DIR/incomplete.json" >/dev/null 2>&1; then fail '不完整 40+12 数据仍被签发' fi @@ -157,8 +297,8 @@ for mutation in disabled zero id-drift multi-row; do ;; esac export DOGFOOD_TEST_NEWAPI_ROWS="$TMP_DIR/newapi-$mutation.tsv" - if "$SCRIPT" --activation-run-id activation-20260723T010203Z \ - --rc-commit 1111111111111111111111111111111111111111 \ + if "$SCRIPT" --activation-run-id "$active_run_id" \ + --rc-commit "$active_commit" \ --output "$TMP_DIR/$mutation.json" >/dev/null 2>&1; then fail "new-api 异常仍被签发 mutation=$mutation" fi diff --git a/deploy/tests/test-staging-rc.sh b/deploy/tests/test-staging-rc.sh index 06c42f9f..395b40d1 100755 --- a/deploy/tests/test-staging-rc.sh +++ b/deploy/tests/test-staging-rc.sh @@ -193,6 +193,17 @@ printf '%s\n' "$failure_handler_body" | rg -q 'verify_stack.*rollback' || \ fail "回滚后未逐项复核五服务" printf '%s\n' "$failure_handler_body" | rg -q 'release_maintenance_gate' || \ fail "旧栈完整恢复后未解除维护门" +rg -q '^verify_active_pointer()' "$TMP_DIR/remote-script.sh" || \ + fail "active 指针恢复后缺 realpath/commit 复核函数" +pointer_restore_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'restore_active_pointer.*previous' | cut -d: -f1)" +pointer_verify_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'verify_active_pointer.*previous' | cut -d: -f1)" +rollback_release_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'release_maintenance_gate' | cut -d: -f1)" +[ -n "$pointer_restore_line" ] && [ -n "$pointer_verify_line" ] && [ -n "$rollback_release_line" ] \ + && [ "$pointer_restore_line" -lt "$pointer_verify_line" ] \ + && [ "$pointer_verify_line" -lt "$rollback_release_line" ] || \ + fail "失败回滚未按 active 恢复→realpath/commit 复核→最后解除维护门排序" +printf '%s\n' "$failure_handler_body" | rg -q 'rollback_rc=' || \ + fail "失败回滚证据未记录总 rollback_rc" ! printf '%s\n' "$failure_handler_body" | rg -q 'exit 72' || \ fail "运行态 cleanup 失败仍直接 exit 72,跳过最终证据落盘" cleanup_rc_line="$(printf '%s\n' "$failure_handler_body" | rg -n 'rc=72' | cut -d: -f1)" @@ -365,6 +376,126 @@ bash "$TMP_DIR/stop-stack-harness.sh" || fail "stop_stack 未严格传播服务 '! rg -q "^unexpected " "$calls"' } >"$TMP_DIR/start-stack-harness.sh" bash "$TMP_DIR/start-stack-harness.sh" + +# 执行失败回滚状态机:即使新 active 尚未完成标记,也必须恢复并复核旧指针后才开放入口。 +{ + cat <<'SH' +#!/usr/bin/env bash +set -uo pipefail +case_name="${1:?}" +state="${RC_TEST_TMP:?}/rollback-state-$case_name" +calls="$state/calls.log" +evidence="$state/evidence" +release="$state/release" +mkdir -p "$evidence" "$release" +commit=0000000000000000000000000000000000000000 +candidate_prefix=gda-candidate +stable_prefix=gda-staging +switch_started=1 +pointer_changed=0 +previous_was_active=1 +previous_valid=1 +previous=/root/game-staging/releases/1111111111111111111111111111111111111111 +database_restore_required=1 +database_restore_rc=not-run +runtime_cleanup_rc=not-run +bootstrap_token_validated=0 +maintenance_gate_active=1 +run_id=test-run + +log_step() { printf 'log:%s\n' "$*" >>"$calls"; } +capture_journals() { :; } +stop_stack() { printf 'stop:%s\n' "$1" >>"$calls"; } +cleanup_dogfood_provision_runtime() { return 0; } +restore_database_snapshot() { printf 'database\n' >>"$calls"; return 0; } +start_stack() { printf 'start-old-stack\n' >>"$calls"; return 0; } +verify_stack() { printf 'verify-old-stack\n' >>"$calls"; return 0; } +restore_active_pointer() { + printf 'restore-active\n' >>"$calls" + [ "$case_name" != pointer-fail ] || return 88 +} +verify_active_pointer() { printf 'verify-active\n' >>"$calls"; return 0; } +release_maintenance_gate() { printf 'release-maintenance\n' >>"$calls"; maintenance_gate_active=0; } +SH + printf '%s\n' "$failure_handler_body" + printf '%s\n' 'false' 'failure_handler' +} >"$TMP_DIR/rollback-state-harness.sh" +chmod +x "$TMP_DIR/rollback-state-harness.sh" + +set +e +bash "$TMP_DIR/rollback-state-harness.sh" success +rollback_success_rc=$? +set -e +[ "$rollback_success_rc" -eq 1 ] || fail "完整回滚没有保留原始激活失败码" +success_calls="$TMP_DIR/rollback-state-success/calls.log" +database_line="$(rg -n '^database$' "$success_calls" | cut -d: -f1 || true)" +service_line="$(rg -n '^verify-old-stack$' "$success_calls" | cut -d: -f1 || true)" +restore_pointer_line="$(rg -n '^restore-active$' "$success_calls" | cut -d: -f1 || true)" +verify_pointer_line="$(rg -n '^verify-active$' "$success_calls" | cut -d: -f1 || true)" +release_line="$(rg -n '^release-maintenance$' "$success_calls" | cut -d: -f1 || true)" +[ -n "$database_line" ] && [ -n "$service_line" ] && [ -n "$restore_pointer_line" ] \ + && [ -n "$verify_pointer_line" ] && [ -n "$release_line" ] \ + && [ "$database_line" -lt "$service_line" ] \ + && [ "$service_line" -lt "$restore_pointer_line" ] \ + && [ "$restore_pointer_line" -lt "$verify_pointer_line" ] \ + && [ "$verify_pointer_line" -lt "$release_line" ] || \ + fail "完整回滚未按数据库→旧栈→active 恢复复核→维护门释放执行" +rg -q '^rollback_rc=0$' "$TMP_DIR/rollback-state-success/evidence/rollback-result.txt" || \ + fail "完整回滚证据未记录 rollback_rc=0" + +set +e +bash "$TMP_DIR/rollback-state-harness.sh" pointer-fail +rollback_pointer_fail_rc=$? +set -e +[ "$rollback_pointer_fail_rc" -eq 73 ] || fail "active 指针恢复失败未返回统一回滚失败码 73" +pointer_fail_calls="$TMP_DIR/rollback-state-pointer-fail/calls.log" +! rg -q '^release-maintenance$' "$pointer_fail_calls" || fail "active 指针失败后仍开放正式入口" +rg -q '^rollback_pointer_rc=88$' "$TMP_DIR/rollback-state-pointer-fail/evidence/rollback-result.txt" || \ + fail "active 指针失败证据未记录原始非零返回码" +rg -q '^rollback_rc=88$' "$TMP_DIR/rollback-state-pointer-fail/evidence/rollback-result.txt" || \ + fail "active 指针失败未计入总 rollback_rc" + +# 执行真实 active 指针函数,验证目标 realpath 与 RC 目录名、Git HEAD 三者必须一致。 +pointer_functions="$TMP_DIR/active-pointer-functions.sh" +{ + printf '%s\n' 'set -euo pipefail' + sed -n '/^restore_active_pointer()/,/^}/p' "$TMP_DIR/remote-script.sh" + sed -n '/^verify_active_pointer()/,/^}/p' "$TMP_DIR/remote-script.sh" +} >"$pointer_functions" +pointer_root="$TMP_DIR/pointer-releases" +pointer_seed="$TMP_DIR/pointer-seed" +mkdir -p "$pointer_root" "$pointer_seed" +git -C "$pointer_seed" init -q +git -C "$pointer_seed" config user.name staging-rc-test +git -C "$pointer_seed" config user.email staging-rc-test@example.invalid +printf 'v1\n' >"$pointer_seed/artifact.txt" +git -C "$pointer_seed" add artifact.txt +git -C "$pointer_seed" commit -q -m v1 +pointer_commit="$(git -C "$pointer_seed" rev-parse HEAD)" +pointer_release="$pointer_root/$pointer_commit" +mv "$pointer_seed" "$pointer_release" +{ + printf 'root=%q\n' "$pointer_root" + printf 'active_pointer=%q\n' "$pointer_root/active" + printf '%s\n' 'run_id=pointer-test' 'log_step() { :; }' \ + 'mv() { if [ "${1:-}" = -Tf ]; then command mv -f "$2" "$3"; else command mv "$@"; fi; }' + printf 'source %q\n' "$pointer_functions" + printf 'restore_active_pointer %q\n' "$pointer_release" + printf 'verify_active_pointer %q\n' "$pointer_release" +} >"$TMP_DIR/active-pointer-pass.sh" +bash "$TMP_DIR/active-pointer-pass.sh" || fail "active 指针 realpath/commit 正例失败" +[ "$(readlink -f "$pointer_root/active")" = "$(realpath "$pointer_release")" ] || \ + fail "active 指针未恢复到旧 RC 的 canonical 路径" + +printf 'v2\n' >>"$pointer_release/artifact.txt" +git -C "$pointer_release" add artifact.txt +git -C "$pointer_release" commit -q -m v2 +if bash -c "root=$(printf '%q' "$pointer_root"); active_pointer=$(printf '%q' "$pointer_root/active"); \ + run_id=pointer-test; log_step() { :; }; source $(printf '%q' "$pointer_functions"); \ + verify_active_pointer $(printf '%q' "$pointer_release")"; then + fail "active RC 的 Git HEAD 漂移后仍通过 commit 复核" +fi + if rg -q '/root/games-development-ai' "$TMP_DIR/remote-script.sh"; then fail "激活脚本引用了远端脏旧仓" fi diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImpl.java b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImpl.java index 0e703c83..6c089560 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImpl.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/main/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImpl.java @@ -10,6 +10,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Primary; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.HandlerMapping; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -54,12 +55,13 @@ public class FeedApiImpl implements FeedApi { if (request == null) { return; } - String requestUri = request.getRequestURI(); - String contextPath = request.getContextPath(); - if (contextPath != null && !contextPath.isEmpty() && requestUri.startsWith(contextPath)) { - requestUri = requestUri.substring(contextPath.length()); + // 使用 Spring 已规范化并选中的 handler pattern,矩阵参数不能再制造原始 URI 与映射路径差异。 + Object patternAttribute = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + if (patternAttribute == null) { + return; } - if (!requestUri.equals(FeedApi.PREFIX) && !requestUri.startsWith(FeedApi.PREFIX + "/")) { + String pattern = patternAttribute.toString(); + if (!pattern.equals(FeedApi.PREFIX) && !pattern.startsWith(FeedApi.PREFIX + "/")) { return; } log.warn("[feedRpcGuard] 已拒绝外部 HTTP 写入口 operation={} code={}", diff --git a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImplDogfoodTest.java b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImplDogfoodTest.java index 2e584100..54a9c2b6 100644 --- a/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImplDogfoodTest.java +++ b/game-cloud/game-module-feed/game-module-feed-server/src/test/java/com/wanxiang/huijing/game/module/feed/api/FeedApiImplDogfoodTest.java @@ -8,14 +8,22 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.HandlerMapping; + +import jakarta.servlet.ServletException; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; /** feed 排序写 RPC 的狗粮可信调用边界测试。 */ class FeedApiImplDogfoodTest { @@ -64,6 +72,34 @@ class FeedApiImplDogfoodTest { verify(feedService).upsertRank(req); } + @Test + void upsertRank_rejectsMatrixParameterRpcThroughSpringMapping() { + FeedApiImpl api = createApi(); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(api).build(); + + // 覆盖 Spring 已解析路径与原始 URI 不一致的场景,防止矩阵参数绕过写 RPC 守卫。 + ServletException exception = assertThrows(ServletException.class, () -> mockMvc.perform(post( + "/rpc-api/feed;x=1/upsert-rank") + .contentType(APPLICATION_JSON) + .content("{}"))); + + assertInstanceOf(ServiceException.class, exception.getCause()); + verifyNoInteractions(feedService); + } + + @Test + void offlineRank_rejectsMatrixParameterRpcThroughSpringMapping() { + FeedApiImpl api = createApi(); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(api).build(); + + ServletException exception = assertThrows(ServletException.class, () -> mockMvc.perform(post( + "/rpc-api/feed;x=1/offline-rank") + .param("gameId", "1024"))); + + assertInstanceOf(ServiceException.class, exception.getCause()); + verifyNoInteractions(feedService); + } + private FeedApiImpl createApi() { FeedApiImpl api = new FeedApiImpl(); ReflectionTestUtils.setField(api, "feedService", feedService); @@ -72,6 +108,7 @@ class FeedApiImplDogfoodTest { private static void bindRequest(String requestUri) { MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, requestUri); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); } } diff --git a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java index fbd993aa..47323984 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java +++ b/game-cloud/game-module-project/game-module-project-server/src/main/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImpl.java @@ -11,6 +11,7 @@ import lombok.extern.slf4j.Slf4j; import org.springframework.context.annotation.Primary; import org.springframework.validation.annotation.Validated; import org.springframework.web.bind.annotation.RestController; +import org.springframework.web.servlet.HandlerMapping; import static com.wanxiang.huijing.framework.common.pojo.CommonResult.success; import static com.wanxiang.huijing.framework.common.exception.util.ServiceExceptionUtil.exception; @@ -56,13 +57,14 @@ public class ProjectVersionApiImpl implements ProjectVersionApi { if (request == null) { return; } - String requestUri = request.getRequestURI(); - String contextPath = request.getContextPath(); - if (contextPath != null && !contextPath.isEmpty() && requestUri.startsWith(contextPath)) { - requestUri = requestUri.substring(contextPath.length()); + // 只信任 Spring 已解析的 handler pattern;原始 URI 可携带矩阵参数,不能用于安全边界判断。 + Object patternAttribute = request.getAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE); + if (patternAttribute == null) { + return; } - if (!requestUri.equals(ProjectVersionApi.PREFIX) - && !requestUri.startsWith(ProjectVersionApi.PREFIX + "/")) { + String pattern = patternAttribute.toString(); + if (!pattern.equals(ProjectVersionApi.PREFIX) + && !pattern.startsWith(ProjectVersionApi.PREFIX + "/")) { return; } log.warn("[projectRpcGuard] 已拒绝外部 HTTP 写入口 operation={} code={}", diff --git a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java index 5ef9c80d..8a6c1495 100644 --- a/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java +++ b/game-cloud/game-module-project/game-module-project-server/src/test/java/com/wanxiang/huijing/game/module/project/api/ProjectVersionApiImplDogfoodTest.java @@ -9,14 +9,22 @@ import org.junit.jupiter.api.BeforeEach; import org.junit.jupiter.api.Test; import org.springframework.mock.web.MockHttpServletRequest; import org.springframework.test.util.ReflectionTestUtils; +import org.springframework.test.web.servlet.MockMvc; +import org.springframework.test.web.servlet.setup.MockMvcBuilders; import org.springframework.web.context.request.RequestContextHolder; import org.springframework.web.context.request.ServletRequestAttributes; +import org.springframework.web.servlet.HandlerMapping; + +import jakarta.servlet.ServletException; import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertInstanceOf; import static org.junit.jupiter.api.Assertions.assertThrows; import static org.mockito.Mockito.mock; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.verifyNoInteractions; +import static org.springframework.http.MediaType.APPLICATION_JSON; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; /** project 版本写 RPC 的狗粮可信调用边界测试。 */ class ProjectVersionApiImplDogfoodTest { @@ -67,6 +75,36 @@ class ProjectVersionApiImplDogfoodTest { verify(gameVersionService).bindRuntimeArtifact(req); } + @Test + void createForPackage_rejectsMatrixParameterRpcThroughSpringMapping() { + ProjectVersionApiImpl api = createApi(); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(api).build(); + + // 必须经过 Spring 的真实 handler 映射,证明矩阵参数规范化后仍命中内部 RPC 且被业务守卫拒绝。 + ServletException exception = assertThrows(ServletException.class, () -> mockMvc.perform(post( + "/rpc-api/project;x=1/version/create-for-package") + .contentType(APPLICATION_JSON) + .content("{\"gameId\":1024}"))); + + assertInstanceOf(ServiceException.class, exception.getCause()); + verifyNoInteractions(gameVersionService); + } + + @Test + void bindRuntimeArtifact_rejectsMatrixParameterRpcThroughSpringMapping() { + ProjectVersionApiImpl api = createApi(); + MockMvc mockMvc = MockMvcBuilders.standaloneSetup(api).build(); + + ServletException exception = assertThrows(ServletException.class, () -> mockMvc.perform(post( + "/rpc-api/project;x=1/version/bind-runtime-artifact") + .contentType(APPLICATION_JSON) + .content("{\"versionId\":2048,\"checksum\":\"" + "a".repeat(64) + + "\",\"bundleSize\":4096}"))); + + assertInstanceOf(ServiceException.class, exception.getCause()); + verifyNoInteractions(gameVersionService); + } + private ProjectVersionApiImpl createApi() { ProjectVersionApiImpl api = new ProjectVersionApiImpl(); ReflectionTestUtils.setField(api, "gameVersionService", gameVersionService); @@ -75,12 +113,14 @@ class ProjectVersionApiImplDogfoodTest { private static void bindRequest(String requestUri) { MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, requestUri); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); } /** 模拟普通玩家携带自己的访问令牌直接命中内部 RPC HTTP 路径。 */ private static void bindOrdinaryPlayerRequest(String requestUri) { MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri); + request.setAttribute(HandlerMapping.BEST_MATCHING_PATTERN_ATTRIBUTE, requestUri); request.addHeader("Authorization", "Bearer ordinary-player-token"); RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request)); }