fix(release): bind dogfood gates to runtime state
This commit is contained in:
parent
495bd30a0c
commit
bcb7fec906
@ -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 供补偿后重试或人工吊销"
|
||||
|
||||
@ -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 <<EOF
|
||||
set -euo pipefail
|
||||
releases_root=$quoted_root
|
||||
active_pointer="\$releases_root/active"
|
||||
|
||||
# active 必须是指向固定 releases 根下 40 位 commit 目录的符号链接。
|
||||
[ -L "\$active_pointer" ]
|
||||
canonical_root="\$(readlink -f -- "\$releases_root")"
|
||||
[ "\$canonical_root" = "\$releases_root" ]
|
||||
release="\$(readlink -f -- "\$active_pointer")"
|
||||
[ -d "\$release" ]
|
||||
commit="\${release##*/}"
|
||||
printf '%s' "\$commit" | grep -Eq '^[0-9a-f]{40}\$'
|
||||
[ "\$release" = "\$releases_root/\$commit" ]
|
||||
|
||||
# RC_STATUS 只能是一条 active 终态,并且 commit、evidence 都必须回指当前 canonical release。
|
||||
status_file="\$release/RC_STATUS"
|
||||
[ -f "\$status_file" ] && [ ! -L "\$status_file" ]
|
||||
[ "\$(awk 'NF { count++ } END { print count + 0 }' "\$status_file")" -eq 1 ]
|
||||
IFS= read -r status_line <"\$status_file"
|
||||
set -- \$status_line
|
||||
[ "\$#" -eq 4 ] && [ "\$1" = active ]
|
||||
status_commit="\${2#commit=}"
|
||||
completed_at="\${3#at=}"
|
||||
evidence="\${4#evidence=}"
|
||||
[ "\$2" = "commit=\$status_commit" ] && [ "\$status_commit" = "\$commit" ]
|
||||
[ "\$3" = "at=\$completed_at" ]
|
||||
printf '%s' "\$completed_at" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\$'
|
||||
[ "\$4" = "evidence=\$evidence" ]
|
||||
|
||||
evidence_name="\${evidence##*/}"
|
||||
run_id="\${evidence_name#activation-}"
|
||||
[ "\$evidence_name" = "activation-\$run_id" ]
|
||||
printf '%s' "\$run_id" | grep -Eq '^[0-9]{8}T[0-9]{6}Z\$'
|
||||
[ "\${evidence%/*}" = "\$release/evidence" ]
|
||||
[ -d "\$evidence" ] && [ ! -L "\$evidence" ]
|
||||
[ "\$(readlink -f -- "\$evidence")" = "\$evidence" ]
|
||||
|
||||
# activation.log 最后两个非空记录必须是该 release 的 PASS 与同一 evidence 回执。
|
||||
activation_log="\$evidence/activation.log"
|
||||
[ -f "\$activation_log" ] && [ ! -L "\$activation_log" ]
|
||||
terminal_lines="\$(awk 'NF { previous=last; last=\$0 } END { print previous; print last }' "\$activation_log")"
|
||||
pass_line="\$(printf '%s\n' "\$terminal_lines" | sed -n '1p')"
|
||||
evidence_line="\$(printf '%s\n' "\$terminal_lines" | sed -n '2p')"
|
||||
printf '%s\n' "\$pass_line" | grep -Eq '^\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\] '
|
||||
printf '%s\n' "\$evidence_line" | grep -Eq '^\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\] '
|
||||
[ "\${pass_line#*] }" = "RC_ACTIVATION_PASS commit=\$commit active=\$release" ]
|
||||
[ "\${evidence_line#*] }" = "ACTIVATION_EVIDENCE=\$evidence" ]
|
||||
|
||||
printf 'DOGFOOD_RUNTIME_STATE_V1\t%s\t%s\t%s\t%s\t%s\n' \
|
||||
"\$commit" "\$run_id" "\$completed_at" "\$release" "\$evidence"
|
||||
EOF
|
||||
)"
|
||||
"$ssh_bin" \
|
||||
-o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 -o StrictHostKeyChecking=yes \
|
||||
"$SSH_TARGET" "$remote_command" >"$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,
|
||||
|
||||
@ -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>",
|
||||
|
||||
@ -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" <<EOF
|
||||
[2026-07-23T01:02:03Z] RC_ACTIVATION_PASS commit=$active_commit active=$active_release
|
||||
[2026-07-23T01:02:03Z] ACTIVATION_EVIDENCE=$active_evidence
|
||||
EOF
|
||||
chmod 600 "$active_release/RC_STATUS" "$active_evidence/activation.log"
|
||||
|
||||
reset_state_probe() {
|
||||
rm -f "$DOGFOOD_TEST_STATE_CALL_COUNT"
|
||||
unset DOGFOOD_TEST_DRIFT_TARGET
|
||||
}
|
||||
|
||||
assert_refused_without_output() {
|
||||
local name="$1"
|
||||
shift
|
||||
reset_state_probe
|
||||
if "$SCRIPT" "$@" --output "$TMP_DIR/$name.json" >/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" <<EOF
|
||||
[2026-07-23T01:02:03Z] ACTIVATION FAIL rc=1
|
||||
[2026-07-23T01:02:03Z] ACTIVATION_EVIDENCE=$active_evidence
|
||||
EOF
|
||||
chmod 600 "$active_evidence/activation.log"
|
||||
assert_refused_without_output failed-activation-terminal \
|
||||
--activation-run-id "$active_run_id" --rc-commit "$active_commit"
|
||||
cat >"$active_evidence/activation.log" <<EOF
|
||||
[2026-07-23T01:02:03Z] RC_ACTIVATION_PASS commit=$active_commit active=$active_release
|
||||
[2026-07-23T01:02:03Z] ACTIVATION_EVIDENCE=$active_evidence
|
||||
EOF
|
||||
chmod 600 "$active_evidence/activation.log"
|
||||
|
||||
# 数据库采集前后必须观测同一 active release,防止切版窗口产生错绑报告。
|
||||
drift_commit=2222222222222222222222222222222222222222
|
||||
drift_run_id=20260723T020304Z
|
||||
drift_release="$DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT/$drift_commit"
|
||||
drift_evidence="$drift_release/evidence/activation-$drift_run_id"
|
||||
mkdir -p "$drift_evidence"
|
||||
printf 'active commit=%s at=2026-07-23T02:03:04Z evidence=%s\n' \
|
||||
"$drift_commit" "$drift_evidence" >"$drift_release/RC_STATUS"
|
||||
cat >"$drift_evidence/activation.log" <<EOF
|
||||
[2026-07-23T02:03:04Z] RC_ACTIVATION_PASS commit=$drift_commit active=$drift_release
|
||||
[2026-07-23T02:03:04Z] ACTIVATION_EVIDENCE=$drift_evidence
|
||||
EOF
|
||||
chmod 600 "$drift_release/RC_STATUS" "$drift_evidence/activation.log"
|
||||
reset_state_probe
|
||||
export DOGFOOD_TEST_DRIFT_TARGET="$drift_release"
|
||||
if "$SCRIPT" --activation-run-id "$active_run_id" --rc-commit "$active_commit" \
|
||||
--output "$TMP_DIR/pointer-drift.json" >/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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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={}",
|
||||
|
||||
@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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={}",
|
||||
|
||||
@ -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));
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user