fix(release): pin dogfood trust boundaries
This commit is contained in:
parent
0551b3552f
commit
5ad0d26daa
@ -8,8 +8,6 @@ RC_HOST="${RC_HOST:-root@100.64.0.7}"
|
||||
RC_ROOT="${RC_ROOT:-/root/game-staging/releases}"
|
||||
GENERATION_ATTESTATION_ROOT="${GENERATION_ATTESTATION_ROOT:-/root/game-staging/attestations}"
|
||||
GENERATION_GATE_RUN_ID="${GENERATION_GATE_RUN_ID:-}"
|
||||
GENERATION_ATTESTATION_EXPECTED_ISSUER="${GENERATION_ATTESTATION_EXPECTED_ISSUER:-}"
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="${GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE:-}"
|
||||
BOOTSTRAP_ADMIN_TOKEN_FILE="${BOOTSTRAP_ADMIN_TOKEN_FILE:-}"
|
||||
ACTIVATION_TIMEOUT_SEC="${ACTIVATION_TIMEOUT_SEC:-180}"
|
||||
ACTIVATION_MODE="activate"
|
||||
@ -67,14 +65,6 @@ if [[ ! "$GENERATION_GATE_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then
|
||||
printf 'GENERATION_GATE_RUN_ID 必填,且只能包含安全的 run-id 字符。\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$GENERATION_ATTESTATION_EXPECTED_ISSUER" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]; then
|
||||
printf 'GENERATION_ATTESTATION_EXPECTED_ISSUER 必填,且只能包含安全的签发者标识。\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [[ ! "$GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]]; then
|
||||
printf 'GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE 必填,且必须是发布机上的安全绝对路径。\n' >&2
|
||||
exit 2
|
||||
fi
|
||||
if [ "$ACTIVATION_MODE" = activate ] && [[ ! "$BOOTSTRAP_ADMIN_TOKEN_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]]; then
|
||||
printf 'BOOTSTRAP_ADMIN_TOKEN_FILE 必填,且必须是 mini-desktop 上的安全绝对路径。\n' >&2
|
||||
exit 2
|
||||
@ -90,9 +80,7 @@ fi
|
||||
"$SSH_BIN" -o ProxyCommand=none "$RC_HOST" bash -s -- \
|
||||
"$RC_COMMIT" "$RC_ROOT" "$ACTIVATION_TIMEOUT_SEC" "$STORAGE_AUDIT_REF" \
|
||||
"$GENERATION_GATE_EVIDENCE_REF" "$GENERATION_ATTESTATION_ROOT" "$ACTIVATION_MODE" \
|
||||
"$BOOTSTRAP_ADMIN_TOKEN_FILE" "$GENERATION_GATE_RUN_ID" \
|
||||
"$GENERATION_ATTESTATION_EXPECTED_ISSUER" \
|
||||
"$GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE" <<'REMOTE_SCRIPT'
|
||||
"$BOOTSTRAP_ADMIN_TOKEN_FILE" "$GENERATION_GATE_RUN_ID" <<'REMOTE_SCRIPT'
|
||||
set -Eeuo pipefail
|
||||
|
||||
commit="$1"
|
||||
@ -104,8 +92,6 @@ attestation_root="$6"
|
||||
activation_mode="$7"
|
||||
bootstrap_admin_token_file="$8"
|
||||
expected_generation_gate_run_id="$9"
|
||||
expected_attestation_issuer="${10}"
|
||||
trusted_attestation_public_key_file="${11}"
|
||||
release="$root/$commit"
|
||||
active_pointer="$root/active"
|
||||
stable_prefix="gda-staging"
|
||||
@ -128,9 +114,15 @@ account_provision_run_id="${run_id}-dogfood"
|
||||
account_provision_cleanup_manifest="$evidence/account-provision/provision-cleanup.tsv"
|
||||
runtime_cleanup_rc=not-run
|
||||
maintenance_chain="GDA_STAGING_MAINT"
|
||||
maintenance_release_guard_chain="GDA_STAGING_RELEASE_GUARD"
|
||||
maintenance_ports="48080,4173,4174,8300,9501"
|
||||
maintenance_gate_active=0
|
||||
IPTABLES_BIN="${IPTABLES_BIN:-iptables}"
|
||||
# R1 信任根是发布策略常量,调用者和环境变量均不能替换。
|
||||
expected_attestation_issuer="r1-mini-infra"
|
||||
trusted_attestation_public_key_sha256="e3cc17cd86805b053f61450b5c2c6e4e168bb01dbf1e83840124290d1196a304"
|
||||
trusted_attestation_public_key_file="/root/game-staging/trust/r1-attestation-ed25519.pub"
|
||||
release_attestation_public_key_file="$release/deploy/trust/r1-attestation-ed25519.pub"
|
||||
|
||||
umask 077
|
||||
if [ "$activation_mode" = activate ]; then
|
||||
@ -171,11 +163,29 @@ unit_name() {
|
||||
}
|
||||
|
||||
stop_stack() {
|
||||
local prefix="$1" service unit
|
||||
local prefix="$1" backend_port="$2" studio_port="$3" admin_port="$4" agent_port="$5" worker_port="$6"
|
||||
local service unit port
|
||||
for service in backend studio admin cheap-agent cheap-worker; do
|
||||
unit="$(unit_name "$prefix" "$service")"
|
||||
systemctl stop "$unit" >/dev/null 2>&1 || true
|
||||
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
|
||||
if systemctl is-active --quiet "$unit"; then
|
||||
systemctl stop "$unit" >/dev/null 2>&1 || {
|
||||
log_step "停止服务失败 unit=$unit"
|
||||
return 1
|
||||
}
|
||||
fi
|
||||
done
|
||||
for service in backend studio admin cheap-agent cheap-worker; do
|
||||
unit="$(unit_name "$prefix" "$service")"
|
||||
if systemctl is-active --quiet "$unit"; then
|
||||
log_step "停栈复核失败,unit 仍 active unit=$unit"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
for port in "$backend_port" "$studio_port" "$admin_port" "$agent_port" "$worker_port"; do
|
||||
if port_listening "$port"; then
|
||||
log_step "停栈复核失败,端口仍监听 port=$port"
|
||||
return 1
|
||||
fi
|
||||
done
|
||||
}
|
||||
|
||||
@ -185,6 +195,12 @@ maintenance_rule_present() {
|
||||
&& "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_chain" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
maintenance_release_guard_present() {
|
||||
"$IPTABLES_BIN" -w 5 -C "$maintenance_release_guard_chain" -p tcp -m multiport \
|
||||
--dports "$maintenance_ports" -j REJECT >/dev/null 2>&1 \
|
||||
&& "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_release_guard_chain" >/dev/null 2>&1
|
||||
}
|
||||
|
||||
install_maintenance_gate() {
|
||||
command -v "$IPTABLES_BIN" >/dev/null 2>&1 || {
|
||||
log_step "缺 iptables,无法封锁正式入口"
|
||||
@ -203,7 +219,7 @@ install_maintenance_gate() {
|
||||
if ! "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_chain" >/dev/null 2>&1; then
|
||||
"$IPTABLES_BIN" -w 5 -I INPUT 1 ! -i lo -j "$maintenance_chain" || return 1
|
||||
fi
|
||||
# INPUT 跳转一旦存在,本次事务就必须负责解除;即使后续复核失败,回滚也不能遗留维护门。
|
||||
# INPUT 跳转一旦存在就纳入事务状态;旧栈完整恢复后解除,恢复失败则保留封锁。
|
||||
maintenance_gate_active=1
|
||||
maintenance_rule_present || {
|
||||
log_step "正式入口维护门安装后复核失败"
|
||||
@ -216,19 +232,38 @@ install_maintenance_gate() {
|
||||
|
||||
release_maintenance_gate() {
|
||||
[ "$maintenance_gate_active" -eq 1 ] || return 0
|
||||
maintenance_rule_present || {
|
||||
log_step "维护门状态漂移,拒绝按成功路径开放入口"
|
||||
return 1
|
||||
}
|
||||
|
||||
# 先安装独立拒绝护栏。此后即使链清理失败,正式端口仍保持封锁。
|
||||
if ! maintenance_release_guard_present; then
|
||||
if ! "$IPTABLES_BIN" -w 5 -nL "$maintenance_release_guard_chain" >/dev/null 2>&1; then
|
||||
"$IPTABLES_BIN" -w 5 -N "$maintenance_release_guard_chain" || return 1
|
||||
fi
|
||||
if ! "$IPTABLES_BIN" -w 5 -C "$maintenance_release_guard_chain" -p tcp -m multiport \
|
||||
--dports "$maintenance_ports" -j REJECT >/dev/null 2>&1; then
|
||||
"$IPTABLES_BIN" -w 5 -A "$maintenance_release_guard_chain" -p tcp -m multiport \
|
||||
--dports "$maintenance_ports" -j REJECT || return 1
|
||||
fi
|
||||
if ! "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_release_guard_chain" >/dev/null 2>&1; then
|
||||
"$IPTABLES_BIN" -w 5 -I INPUT 1 ! -i lo -j "$maintenance_release_guard_chain" || return 1
|
||||
fi
|
||||
fi
|
||||
maintenance_release_guard_present || return 1
|
||||
|
||||
while "$IPTABLES_BIN" -w 5 -C INPUT ! -i lo -j "$maintenance_chain" >/dev/null 2>&1; do
|
||||
"$IPTABLES_BIN" -w 5 -D INPUT ! -i lo -j "$maintenance_chain" || return 1
|
||||
done
|
||||
"$IPTABLES_BIN" -w 5 -F "$maintenance_chain" || return 1
|
||||
"$IPTABLES_BIN" -w 5 -X "$maintenance_chain" || return 1
|
||||
maintenance_gate_active=0
|
||||
printf 'maintenance_gate=OPENED chain=%s ports=%s\n' "$maintenance_chain" "$maintenance_ports" \
|
||||
if "$IPTABLES_BIN" -w 5 -nL "$maintenance_chain" >/dev/null 2>&1; then
|
||||
"$IPTABLES_BIN" -w 5 -F "$maintenance_chain" || return 1
|
||||
"$IPTABLES_BIN" -w 5 -X "$maintenance_chain" || return 1
|
||||
fi
|
||||
maintenance_release_guard_present || return 1
|
||||
printf 'maintenance_gate=OPENING chain=%s ports=%s\n' "$maintenance_chain" "$maintenance_ports" \
|
||||
>"$evidence/maintenance-gate.status"
|
||||
log_step "正式入口维护门 OPENED"
|
||||
|
||||
# 最后一条可能失败的命令才真正开放入口;成功后不得再触发数据库补偿。
|
||||
"$IPTABLES_BIN" -w 5 -D INPUT ! -i lo -j "$maintenance_release_guard_chain" || return 1
|
||||
maintenance_gate_active=0
|
||||
return 0
|
||||
}
|
||||
|
||||
capture_journals() {
|
||||
@ -439,7 +474,8 @@ valid_prepared_release() {
|
||||
verify_generation_gate_evidence() {
|
||||
local ref="$1" expected_commit="$2" hash_and_path expected_hash relative_path actual_hash
|
||||
local source_path signature_path canonical_root canonical_source canonical_signature component walk mode
|
||||
local public_key_mode public_key_mode_decimal canonical_public_key
|
||||
local public_key_mode canonical_public_key public_key_hash
|
||||
local trusted_parent trusted_parent_mode trusted_parent_mode_decimal
|
||||
hash_and_path="${ref#sha256:}"
|
||||
expected_hash="${hash_and_path%%:*}"
|
||||
relative_path="${hash_and_path#*:}"
|
||||
@ -510,11 +546,41 @@ verify_generation_gate_evidence() {
|
||||
}
|
||||
public_key_mode="$(stat -c '%a' "$trusted_attestation_public_key_file" 2>/dev/null \
|
||||
|| stat -f '%Lp' "$trusted_attestation_public_key_file")"
|
||||
public_key_mode_decimal=$((8#$public_key_mode))
|
||||
if (( (public_key_mode_decimal & 022) != 0 )); then
|
||||
log_step "R1 信任公钥不得允许 group/other 写入 actual=$public_key_mode"
|
||||
if [ "$public_key_mode" != 644 ]; then
|
||||
log_step "R1 信任公钥权限必须固定为 0644 actual=$public_key_mode"
|
||||
return 1
|
||||
fi
|
||||
[ "$(stat -c '%u' "$trusted_attestation_public_key_file" 2>/dev/null \
|
||||
|| stat -f '%u' "$trusted_attestation_public_key_file")" = 0 ] || {
|
||||
log_step "R1 信任公钥必须由 root 持有"
|
||||
return 1
|
||||
}
|
||||
trusted_parent="$(dirname "$trusted_attestation_public_key_file")"
|
||||
[ -d "$trusted_parent" ] && [ ! -L "$trusted_parent" ] || return 1
|
||||
[ "$(realpath "$trusted_parent")" = "$trusted_parent" ] || return 1
|
||||
[ "$(stat -c '%u' "$trusted_parent" 2>/dev/null || stat -f '%u' "$trusted_parent")" = 0 ] || {
|
||||
log_step "R1 信任公钥父目录必须由 root 持有"
|
||||
return 1
|
||||
}
|
||||
trusted_parent_mode="$(stat -c '%a' "$trusted_parent" 2>/dev/null || stat -f '%Lp' "$trusted_parent")"
|
||||
trusted_parent_mode_decimal=$((8#$trusted_parent_mode))
|
||||
if (( (trusted_parent_mode_decimal & 022) != 0 )); then
|
||||
log_step "R1 信任公钥父目录不得允许 group/other 写入 actual=$trusted_parent_mode"
|
||||
return 1
|
||||
fi
|
||||
[ -f "$release_attestation_public_key_file" ] && [ ! -L "$release_attestation_public_key_file" ] || {
|
||||
log_step "commit-pinned RC 缺仓内 R1 信任公钥"
|
||||
return 1
|
||||
}
|
||||
public_key_hash="$(sha256sum "$trusted_attestation_public_key_file" | awk '{print $1}')"
|
||||
[ "$public_key_hash" = "$trusted_attestation_public_key_sha256" ] || {
|
||||
log_step "R1 信任公钥 SHA-256 与固定信任根不一致"
|
||||
return 1
|
||||
}
|
||||
cmp -s "$trusted_attestation_public_key_file" "$release_attestation_public_key_file" || {
|
||||
log_step "远端 R1 信任公钥与 commit-pinned RC 仓内公钥不一致"
|
||||
return 1
|
||||
}
|
||||
openssl pkeyutl -verify -pubin -inkey "$trusted_attestation_public_key_file" -rawin \
|
||||
-in "$source_path" -sigfile "$signature_path" >/dev/null 2>&1 || {
|
||||
log_step "生成门 detached signature 验证失败"
|
||||
@ -649,7 +715,7 @@ JAVA
|
||||
restore_database_snapshot() {
|
||||
local dump="$evidence/pre-migration-ruoyi-vue-pro.sql.gz"
|
||||
[ -s "$dump" ] || return 1
|
||||
stop_stack "$stable_prefix"
|
||||
stop_stack "$stable_prefix" 48080 4173 4174 8300 9501 || return 1
|
||||
docker exec game-staging-mysql sh -lc \
|
||||
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -e "DROP DATABASE IF EXISTS `ruoyi-vue-pro`"' || return 1
|
||||
gzip -dc "$dump" | docker exec -i game-staging-mysql sh -lc \
|
||||
@ -677,7 +743,7 @@ failure_handler() {
|
||||
log_step "ACTIVATION FAIL rc=${rc},开始保留证据并恢复旧栈"
|
||||
capture_journals "$candidate_prefix" candidate-failed
|
||||
capture_journals "$stable_prefix" stable-failed
|
||||
stop_stack "$candidate_prefix"
|
||||
stop_stack "$candidate_prefix" 18080 14173 14174 18300 19501
|
||||
cleanup_dogfood_provision_runtime
|
||||
runtime_cleanup_rc=$?
|
||||
printf 'runtime_cleanup_rc=%s\n' "$runtime_cleanup_rc" >"$evidence/account-provision-runtime-cleanup.status"
|
||||
@ -693,7 +759,7 @@ failure_handler() {
|
||||
fi
|
||||
fi
|
||||
if [ "$switch_started" -eq 1 ]; then
|
||||
stop_stack "$stable_prefix"
|
||||
stop_stack "$stable_prefix" 48080 4173 4174 8300 9501
|
||||
if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -eq 1 ]; then
|
||||
log_step "恢复旧版本服务 target=$previous"
|
||||
rollback_rc=0
|
||||
@ -717,6 +783,9 @@ failure_handler() {
|
||||
if [ "$pointer_changed" -eq 1 ]; then
|
||||
restore_active_pointer "$previous"
|
||||
fi
|
||||
if [ "$switch_started" -eq 0 ] && [ "$maintenance_gate_active" -eq 1 ]; then
|
||||
release_maintenance_gate || rc=74
|
||||
fi
|
||||
if [ "$bootstrap_token_validated" -eq 1 ]; then
|
||||
log_step "激活失败,保留 0600 bootstrap admin token 供补偿后重试或人工吊销"
|
||||
fi
|
||||
@ -807,15 +876,13 @@ done
|
||||
start_candidate_probe "$release" candidate "$candidate_prefix" 14173 14174 18300 19501
|
||||
capture_journals "$candidate_prefix" candidate-ready
|
||||
|
||||
log_step "候选非数据面探针全绿,停止旧栈后执行独立 Flyway 迁移"
|
||||
switch_started=1
|
||||
log_step "候选非数据面探针全绿,先封锁入口,再严格停止旧栈"
|
||||
capture_journals "$stable_prefix" pre-switch-old
|
||||
stop_stack "$stable_prefix"
|
||||
|
||||
# 从停写快照前到账号事务、审核轮换和 smoke 全部完成,正式端口只允许本机 loopback 访问。
|
||||
install_maintenance_gate
|
||||
switch_started=1
|
||||
stop_stack "$stable_prefix" 48080 4173 4174 8300 9501
|
||||
|
||||
# 旧栈停写后再生成补偿快照,避免候选探针期间的新写入在失败恢复时丢失。
|
||||
# 维护门已封锁外部写入,五服务与五端口也已严格停净,才允许生成补偿快照。
|
||||
log_step "迁移前数据库快照"
|
||||
docker inspect game-staging-mysql >/dev/null
|
||||
docker exec game-staging-mysql sh -lc \
|
||||
@ -970,7 +1037,7 @@ pointer_changed=1
|
||||
printf 'active commit=%s at=%s evidence=%s\n' "$commit" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$evidence" \
|
||||
>"$release/RC_STATUS"
|
||||
|
||||
stop_stack "$candidate_prefix"
|
||||
stop_stack "$candidate_prefix" 18080 14173 14174 18300 19501
|
||||
rm -f -- "$account_provision_cleanup_manifest"
|
||||
chmod -R go-rwx "$evidence" "$release/RC_STATUS"
|
||||
delete_bootstrap_token
|
||||
|
||||
277
deploy/collect-dogfood-account-report.sh
Executable file
277
deploy/collect-dogfood-account-report.sh
Executable file
@ -0,0 +1,277 @@
|
||||
#!/usr/bin/env bash
|
||||
# 在 mini-infra 信任边界内采集 staging 账号与额度池快照,并生成 Ed25519 签名证据。
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
|
||||
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 REPORT_SCHEMA='dogfood-account-database-report/2'
|
||||
readonly QUERY_ID='dogfood-account-readiness-v2'
|
||||
readonly QUERY_SCHEMA='dogfood-account-readiness-row/2'
|
||||
readonly NEWAPI_PG_CONTAINER='infra-postgres'
|
||||
readonly NEWAPI_PG_DATABASE='new-api'
|
||||
readonly NEWAPI_PG_USER='root'
|
||||
|
||||
die() {
|
||||
printf 'ACCOUNT_REPORT_REFUSED %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
usage() {
|
||||
printf '用法:%s --activation-run-id <run-id> --rc-commit <40hex> --output <report.json>\n' "$0" >&2
|
||||
exit 2
|
||||
}
|
||||
|
||||
activation_run_id=''
|
||||
rc_commit=''
|
||||
output=''
|
||||
while [ "$#" -gt 0 ]; do
|
||||
case "$1" in
|
||||
--activation-run-id) [ "$#" -ge 2 ] || usage; activation_run_id="$2"; shift 2 ;;
|
||||
--rc-commit) [ "$#" -ge 2 ] || usage; rc_commit="$2"; shift 2 ;;
|
||||
--output) [ "$#" -ge 2 ] || usage; output="$2"; shift 2 ;;
|
||||
*) usage ;;
|
||||
esac
|
||||
done
|
||||
|
||||
[[ "$activation_run_id" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]] || die 'activation run-id 非法'
|
||||
[[ "$rc_commit" =~ ^[0-9a-f]{40}$ ]] || die 'rcCommit 必须是完整 40 位小写哈希'
|
||||
[ -n "$output" ] || usage
|
||||
[ ! -L "$output" ] && [ ! -e "$output" ] || die '输出报告已存在或是符号链接,拒绝覆盖'
|
||||
[ ! -L "$output.sig" ] && [ ! -e "$output.sig" ] || die '输出签名已存在或是符号链接,拒绝覆盖'
|
||||
output_dir="$(cd "$(dirname "$output")" && pwd -P)" || die '输出目录不存在'
|
||||
output="$output_dir/$(basename "$output")"
|
||||
|
||||
test_mode="${DOGFOOD_COLLECTOR_TEST_MODE:-0}"
|
||||
if [ "$test_mode" = 1 ]; then
|
||||
# 测试钩子只替换进程和私钥,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:?}"
|
||||
else
|
||||
[ "$(id -u)" = 0 ] || die '采集器必须以 mini-infra root signer 身份运行'
|
||||
[ "$(hostname -s)" = "$EXPECTED_SIGNER_HOST" ] || die '采集器只能在固定 mini-infra signer 主机运行'
|
||||
ssh_bin='/usr/bin/ssh'
|
||||
signer_key="$SIGNER_PRIVATE_KEY"
|
||||
openssl_bin='/usr/bin/openssl'
|
||||
docker_bin='/usr/bin/docker'
|
||||
fi
|
||||
[ -x "$ssh_bin" ] || die '固定 SSH 客户端不可执行'
|
||||
[ -x "$openssl_bin" ] || die 'OpenSSL 客户端不可执行'
|
||||
[ -x "$docker_bin" ] || die '固定 Docker 客户端不可执行'
|
||||
[ -f "$signer_key" ] && [ ! -L "$signer_key" ] || die '固定 signer 私钥缺失或不是普通文件'
|
||||
[ "$(stat -c '%a' "$signer_key" 2>/dev/null || stat -f '%Lp' "$signer_key")" = 600 ] || \
|
||||
die 'signer 私钥权限必须为 0600'
|
||||
if [ "$test_mode" != 1 ]; then
|
||||
[ "$(realpath "$signer_key")" = "$SIGNER_PRIVATE_KEY" ] || die 'signer 私钥 canonical 路径漂移'
|
||||
[ "$(stat -c '%u' "$signer_key")" = 0 ] || die 'signer 私钥必须属于 root'
|
||||
fi
|
||||
|
||||
# 查询只读取 staging MySQL 的账号和池表;不选择 newapi_token_key、手机号、密码或 OAuth token。
|
||||
readonly FIXED_QUERY="$(cat <<'SQL'
|
||||
SET SESSION TRANSACTION READ ONLY;
|
||||
START TRANSACTION READ ONLY;
|
||||
SELECT 'ACCOUNT', p.id, p.username,
|
||||
IF(p.creator_flag=1,'creator','player'),
|
||||
IF(p.status=0,'ACTIVE','DISABLED'),
|
||||
q.id, q.status, q.grant_quota, q.newapi_user_id, q.newapi_token_id
|
||||
FROM game_player p
|
||||
JOIN newapi_quota_pool q
|
||||
ON q.claimed_by_game_player_id=p.id AND q.deleted=b'0'
|
||||
WHERE p.username IN (
|
||||
'dogfoodc01','dogfoodc02','dogfoodc03','dogfoodc04','dogfoodc05',
|
||||
'dogfoodc06','dogfoodc07','dogfoodc08','dogfoodc09','dogfoodc10',
|
||||
'dogfoodp01','dogfoodp02','dogfoodp03','dogfoodp04','dogfoodp05',
|
||||
'dogfoodp06','dogfoodp07','dogfoodp08','dogfoodp09','dogfoodp10',
|
||||
'dogfoodp11','dogfoodp12','dogfoodp13','dogfoodp14','dogfoodp15',
|
||||
'dogfoodp16','dogfoodp17','dogfoodp18','dogfoodp19','dogfoodp20',
|
||||
'dogfoodp21','dogfoodp22','dogfoodp23','dogfoodp24','dogfoodp25',
|
||||
'dogfoodp26','dogfoodp27','dogfoodp28','dogfoodp29','dogfoodp30'
|
||||
) AND p.deleted=b'0'
|
||||
UNION ALL
|
||||
SELECT 'RESERVE', q.id, q.newapi_user_id, q.newapi_token_id,
|
||||
q.status, q.grant_quota, '', '', '', ''
|
||||
FROM newapi_quota_pool q
|
||||
WHERE q.status='FREE' AND q.deleted=b'0'
|
||||
ORDER BY 1, 3;
|
||||
COMMIT;
|
||||
SQL
|
||||
)"
|
||||
|
||||
# new-api PostgreSQL 是 token 可用性权威源;固定查询不读取 token key、access token 或密码。
|
||||
readonly FIXED_NEWAPI_QUERY="$(cat <<'SQL'
|
||||
BEGIN TRANSACTION READ ONLY;
|
||||
SELECT 'TOKEN', u.id, u.username, t.id,
|
||||
COALESCE(t.status, 0),
|
||||
CASE WHEN t.unlimited_quota IS FALSE THEN 0 WHEN t.unlimited_quota IS TRUE THEN 1 ELSE -1 END,
|
||||
COALESCE(t.expired_time, 0),
|
||||
COALESCE(t.remain_quota, 0)
|
||||
FROM users u
|
||||
JOIN tokens t ON t.user_id=u.id
|
||||
WHERE u.username ~ '^neice_[0-9]{3}$'
|
||||
ORDER BY u.id, t.id;
|
||||
COMMIT;
|
||||
SQL
|
||||
)"
|
||||
|
||||
tmp_rows="$output_dir/.account-report-rows.$$"
|
||||
tmp_newapi_rows="$output_dir/.account-report-newapi-rows.$$"
|
||||
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
|
||||
|
||||
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" \
|
||||
-o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 -o StrictHostKeyChecking=yes \
|
||||
"$SSH_TARGET" "$remote_mysql_command" >"$tmp_rows" || die '固定 staging MySQL 只读查询失败'
|
||||
chmod 600 "$tmp_rows"
|
||||
printf '%s\n' "$FIXED_NEWAPI_QUERY" | "$docker_bin" exec -i "$NEWAPI_PG_CONTAINER" sh -lc \
|
||||
'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"
|
||||
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'
|
||||
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:]
|
||||
expected_roles = {
|
||||
**{f"dogfoodc{index:02d}": "creator" for index in range(1, 11)},
|
||||
**{f"dogfoodp{index:02d}": "player" for index in range(1, 31)},
|
||||
}
|
||||
accounts = []
|
||||
reserves = []
|
||||
for line_number, raw in enumerate(rows_path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
fields = raw.split("\t")
|
||||
if len(fields) != 10:
|
||||
raise SystemExit(f"MySQL 固定查询第 {line_number} 行字段数不是 10")
|
||||
if fields[0] == "ACCOUNT":
|
||||
_, account_id, username, role, account_status, pool_id, quota_status, grant, newapi_user, newapi_token = fields
|
||||
accounts.append({
|
||||
"id": int(account_id), "username": username, "role": role,
|
||||
"accountStatus": account_status, "quotaPoolId": int(pool_id),
|
||||
"quotaStatus": quota_status, "grantQuota": int(grant),
|
||||
"newapiUserId": int(newapi_user), "newapiTokenId": int(newapi_token),
|
||||
})
|
||||
elif fields[0] == "RESERVE":
|
||||
_, pool_id, newapi_user, newapi_token, quota_status, grant, *_ = fields
|
||||
reserves.append({
|
||||
"poolId": int(pool_id), "newapiUserId": int(newapi_user),
|
||||
"newapiTokenId": int(newapi_token), "quotaStatus": quota_status,
|
||||
"grantQuota": int(grant),
|
||||
})
|
||||
else:
|
||||
raise SystemExit(f"MySQL 固定查询第 {line_number} 行类型非法")
|
||||
|
||||
by_username = {item["username"]: item for item in accounts}
|
||||
if len(accounts) != 40 or set(by_username) != set(expected_roles):
|
||||
raise SystemExit("固定查询必须精确返回 40 个批准账号")
|
||||
if any(
|
||||
item["role"] != expected_roles[item["username"]]
|
||||
or item["accountStatus"] != "ACTIVE"
|
||||
or item["quotaStatus"] != "CLAIMED"
|
||||
or min(
|
||||
item["id"], item["quotaPoolId"], item["grantQuota"],
|
||||
item["newapiUserId"], item["newapiTokenId"],
|
||||
) <= 0
|
||||
for item in accounts
|
||||
):
|
||||
raise SystemExit("40 个批准账号的角色、状态或 CLAIMED 额度非法")
|
||||
if len(reserves) != 12 or any(
|
||||
item["quotaStatus"] != "FREE"
|
||||
or min(
|
||||
item["poolId"], item["grantQuota"], item["newapiUserId"], item["newapiTokenId"],
|
||||
) <= 0
|
||||
for item in reserves
|
||||
):
|
||||
raise SystemExit("固定查询必须精确返回 12 条可用 FREE 预留额度")
|
||||
|
||||
pool_ids = [item["quotaPoolId"] for item in accounts] + [item["poolId"] for item in reserves]
|
||||
account_ids = [item["id"] for item in accounts]
|
||||
newapi_user_ids = [item["newapiUserId"] for item in accounts + reserves]
|
||||
newapi_token_ids = [item["newapiTokenId"] for item in accounts + reserves]
|
||||
if len(account_ids) != len(set(account_ids)) or len(pool_ids) != len(set(pool_ids)) \
|
||||
or len(newapi_user_ids) != len(set(newapi_user_ids)) \
|
||||
or len(newapi_token_ids) != len(set(newapi_token_ids)):
|
||||
raise SystemExit("账号与预留额度存在重复身份")
|
||||
|
||||
# 以 staging 池表中的 user/token 双 ID 为锚,要求 PostgreSQL 权威记录一对一命中。
|
||||
token_rows = []
|
||||
for line_number, raw in enumerate(newapi_rows_path.read_text(encoding="utf-8").splitlines(), 1):
|
||||
fields = raw.split("|")
|
||||
if len(fields) != 8 or fields[0] != "TOKEN":
|
||||
raise SystemExit(f"new-api 固定查询第 {line_number} 行 schema 非法")
|
||||
_, user_id, username, token_id, status, unlimited, expired, remain = fields
|
||||
token_rows.append({
|
||||
"newapiUserId": int(user_id),
|
||||
"newapiUsername": username,
|
||||
"newapiTokenId": int(token_id),
|
||||
"status": int(status),
|
||||
"unlimited": int(unlimited),
|
||||
"expired": int(expired),
|
||||
"remain": int(remain),
|
||||
})
|
||||
|
||||
expected_items = accounts + reserves
|
||||
expected_user_ids = {item["newapiUserId"] for item in expected_items}
|
||||
expected_token_ids = {item["newapiTokenId"] for item in expected_items}
|
||||
for item in expected_items:
|
||||
user_matches = [row for row in token_rows if row["newapiUserId"] == item["newapiUserId"]]
|
||||
token_matches = [row for row in token_rows if row["newapiTokenId"] == item["newapiTokenId"]]
|
||||
if len(user_matches) != 1 or len(token_matches) != 1 or user_matches[0] is not token_matches[0]:
|
||||
raise SystemExit("staging 池表与 new-api user/token IDs 未唯一匹配")
|
||||
token = user_matches[0]
|
||||
if not re.fullmatch(r"neice_[0-9]{3}", token["newapiUsername"]):
|
||||
raise SystemExit("new-api 用户名不符合 canonical neice_NNN")
|
||||
if token["status"] != 1 or token["unlimited"] != 0 \
|
||||
or token["expired"] != -1 or token["remain"] <= 0:
|
||||
raise SystemExit("new-api token 当前不可用")
|
||||
item.update({
|
||||
"newapiUsername": token["newapiUsername"],
|
||||
"tokenStatus": "ENABLED",
|
||||
"tokenRemainQuota": token["remain"],
|
||||
"tokenUnlimitedQuota": False,
|
||||
"tokenExpiredTime": -1,
|
||||
})
|
||||
|
||||
relevant_rows = [
|
||||
row for row in token_rows
|
||||
if row["newapiUserId"] in expected_user_ids or row["newapiTokenId"] in expected_token_ids
|
||||
]
|
||||
if len(relevant_rows) != len(expected_items) \
|
||||
or len({row["newapiUsername"] for row in relevant_rows}) != len(expected_items):
|
||||
raise SystemExit("new-api 权威查询存在重复 user/token 行或用户名")
|
||||
|
||||
report = {
|
||||
"schema": schema,
|
||||
"issuer": issuer,
|
||||
"activationRunId": activation_run_id,
|
||||
"rcCommit": rc_commit,
|
||||
"capturedAt": captured_at,
|
||||
"queryId": query_id,
|
||||
"querySchema": query_schema,
|
||||
"accounts": sorted(accounts, key=lambda item: item["username"]),
|
||||
"reserves": sorted(reserves, key=lambda item: item["poolId"]),
|
||||
}
|
||||
Path(report_path).write_text(
|
||||
json.dumps(report, ensure_ascii=False, indent=2, sort_keys=True) + "\n",
|
||||
encoding="utf-8",
|
||||
)
|
||||
PY
|
||||
chmod 600 "$tmp_report"
|
||||
"$openssl_bin" pkeyutl -sign -inkey "$signer_key" -rawin \
|
||||
-in "$tmp_report" -out "$tmp_signature" >/dev/null 2>&1 || die '账号报告签名失败'
|
||||
chmod 600 "$tmp_signature"
|
||||
|
||||
# 先放签名、最后原子发布报告;消费者看见报告时,配套签名已经存在。
|
||||
mv "$tmp_signature" "$output.sig"
|
||||
mv "$tmp_report" "$output"
|
||||
printf 'ACCOUNT_REPORT_CREATED report=%s signature=%s issuer=%s\n' "$output" "$output.sig" "$SIGNER_ISSUER"
|
||||
@ -12,7 +12,10 @@
|
||||
初始化只接受这两个名称。`startedAt` 由工具读取 UTC 当前时间写入,CLI 不提供回填参数。重复初始化会校验既有 `manifest.json` 后返回,不覆盖任何已填写证据。Wave1 初始化前会重跑 Wave0 收口门,并把 Wave0 的 `close-result.json` 哈希、最后一份冻结清单哈希和 `closedAt` 固化进 Wave1 manifest;任一绑定变化、P0、确认发布假阳或超过 4 小时待审积压都会拒绝 Wave1。
|
||||
|
||||
```bash
|
||||
deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave0
|
||||
deploy/dogfood-ops/init-wave.sh \
|
||||
--root /受控证据根 \
|
||||
--wave wave0 \
|
||||
--rc-commit <commit-pinned-RC的40位哈希>
|
||||
```
|
||||
|
||||
生成目录中的 JSON 带 `_template: true`,表示它还不是证据。填写完成后删除该字段。附件统一放在波次目录内的 `attachments/`;JSON/TSV 只写相对路径。绝对路径、`..` 路径、符号链接和不存在的附件都会被拒绝。
|
||||
@ -23,7 +26,10 @@ deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave0
|
||||
|
||||
```bash
|
||||
# 1. 初始化并填写同一波次的全部结构化证据。
|
||||
deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave0
|
||||
deploy/dogfood-ops/init-wave.sh \
|
||||
--root /受控证据根 \
|
||||
--wave wave0 \
|
||||
--rc-commit <commit-pinned-RC的40位哈希>
|
||||
|
||||
# 2. 先跑开波门;模板、缺字段、阈值不达标都返回非零。
|
||||
deploy/dogfood-ops/check-readiness.sh --wave-dir /受控证据根/wave0
|
||||
@ -38,7 +44,10 @@ deploy/dogfood-ops/freeze-evidence.sh \
|
||||
deploy/dogfood-ops/check-close.sh --wave-dir /受控证据根/wave0
|
||||
|
||||
# 5. 只有 Wave0 收口回执仍可复核时,才允许初始化 Wave1。
|
||||
deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave1
|
||||
deploy/dogfood-ops/init-wave.sh \
|
||||
--root /受控证据根 \
|
||||
--wave wave1 \
|
||||
--rc-commit <Wave1使用的commit-pinned-RC的40位哈希>
|
||||
```
|
||||
|
||||
`GATE_PASS` 只表示本地证据在当前规则下可对账,不替代人工判断,不代表 staging 已部署、告警已送达或波次已完成。真实结论必须回到原始截图、网络记录、日志和数据库 ID 核验。
|
||||
@ -47,12 +56,12 @@ deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave1
|
||||
|
||||
| 文件 | 必填内容 |
|
||||
|---|---|
|
||||
| `manifest.json` | 固定波次、人数、持续时间和工具生成的 `startedAt`;Wave1 另绑定 Wave0 收口回执、最终冻结哈希和 `closedAt` |
|
||||
| `manifest.json` | 固定波次、人数、持续时间、commit-pinned `rcCommit` 和工具生成的 `startedAt`;Wave1 另绑定 Wave0 收口回执、最终冻结哈希和 `closedAt` |
|
||||
| `rc.json` | 完整 40 位 commit、tracked 干净标志、`rcArtifactRef` 与实际 `rcArtifactSha256`,只证明部署候选 |
|
||||
| `config-identity.json` | 同一 RC commit、配置包路径、实际 SHA-256、采集时间 |
|
||||
| `snapshot.json` | MySQL 快照路径/哈希,以及非 staging/production 目标的隔离恢复 PASS、时间和报告 |
|
||||
| `storage-audit.json` | 对象存储审计报告、实际哈希、采集时间 |
|
||||
| `accounts.json` | batchId、精确创作者/玩家计数、FREE 额度;wave0 至少 4,wave1 至少 12 |
|
||||
| `accounts.json` | `/2`:绑定激活 run-id、签名数据库报告、报告哈希/采集时间,以及本波明确账号 ID+用户名 |
|
||||
| `role-permissions.json` | 创作者本账号允许、跨账号拒绝、审核员只审、审核员改配置拒绝、只读写拒绝;每项带截图 |
|
||||
| `main-chain.json` | 同一 project/version/game 的 `gameArtifactSha256`,分别绑定 APPROVED 审核记录、实际 runtime package 和主链阶段真实使用的数据库证据;完整覆盖 create→generate→preview→submit-review→approve→feed→play→telemetry |
|
||||
| `release-negatives.json` | DRAFT、REVIEWING、REJECTED、直调绕过、批准后产物漂移、下架后 feed/详情/深链八类拒绝证据 |
|
||||
@ -71,6 +80,78 @@ deploy/dogfood-ops/init-wave.sh --root /受控证据根 --wave wave1
|
||||
|
||||
所有时间使用带时区 ISO 8601,例如 `2026-07-22T08:00:00Z`。哈希必须是小写 64 位 SHA-256,并与附件内容一致。
|
||||
|
||||
### 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 匹配都会在签名前失败。
|
||||
|
||||
```bash
|
||||
# 在可信工作站从指定 RC commit 导出脚本,不使用当前未提交工作树。
|
||||
set -euo pipefail
|
||||
umask 077
|
||||
: "${RC_COMMIT:?请先设置 commit-pinned RC 的 40 位哈希}"
|
||||
: "${ACTIVATION_RUN_ID:?请先设置激活 run-id}"
|
||||
[[ "$RC_COMMIT" =~ ^[0-9a-f]{40}$ ]]
|
||||
[[ "$ACTIVATION_RUN_ID" =~ ^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$ ]]
|
||||
PINNED_SCRIPT="$(mktemp)"
|
||||
git show "${RC_COMMIT}:deploy/collect-dogfood-account-report.sh" >"$PINNED_SCRIPT"
|
||||
chmod 700 "$PINNED_SCRIPT"
|
||||
LOCAL_SHA="$(shasum -a 256 "$PINNED_SCRIPT" | awk '{print $1}')"
|
||||
|
||||
# mini-infra 不要求存在项目仓库;先传入临时名,哈希一致后再原子安装到固定 signer 目录。
|
||||
REMOTE_INCOMING="/root/dogfood-r1-signer/collect-dogfood-account-report.sh.incoming-$RC_COMMIT"
|
||||
scp -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
"$PINNED_SCRIPT" "root@100.64.0.8:$REMOTE_INCOMING"
|
||||
REMOTE_SHA="$(ssh -o BatchMode=yes -o StrictHostKeyChecking=yes root@100.64.0.8 \
|
||||
"sha256sum '$REMOTE_INCOMING' | cut -d ' ' -f 1")"
|
||||
[ "$LOCAL_SHA" = "$REMOTE_SHA" ]
|
||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=yes root@100.64.0.8 \
|
||||
"install -o root -g root -m 700 '$REMOTE_INCOMING' /root/dogfood-r1-signer/collect-dogfood-account-report.sh.new && \
|
||||
mv -f /root/dogfood-r1-signer/collect-dogfood-account-report.sh.new /root/dogfood-r1-signer/collect-dogfood-account-report.sh && \
|
||||
rm -f '$REMOTE_INCOMING'"
|
||||
rm -f "$PINNED_SCRIPT"
|
||||
|
||||
# 在 mini-infra 运行固定安装件;报告和签名均留在 signer 主机的 0700 目录。
|
||||
ssh -o BatchMode=yes -o StrictHostKeyChecking=yes root@100.64.0.8 \
|
||||
"install -d -o root -g root -m 700 /root/dogfood-account-reports && \
|
||||
/root/dogfood-r1-signer/collect-dogfood-account-report.sh \
|
||||
--activation-run-id '$ACTIVATION_RUN_ID' \
|
||||
--rc-commit '$RC_COMMIT' \
|
||||
--output /root/dogfood-account-reports/wave0-account-report.json"
|
||||
```
|
||||
|
||||
采集器使用固定私钥 `/root/dogfood-r1-signer/r1-ed25519-private.pem`,生成报告及同名 `.sig`,两者权限均为 `0600`。报告包含账号身份、池绑定、`newapiUsername`、`tokenStatus`、`tokenRemainQuota`、`tokenUnlimitedQuota` 和 `tokenExpiredTime`,不输出 token key、手机号、密码或 OAuth token。通过严格主机密钥校验把两个文件取回可信工作站,再以 `0600` 安装进波次附件:
|
||||
|
||||
```bash
|
||||
TRANSFER_DIR="$(mktemp -d)"
|
||||
chmod 700 "$TRANSFER_DIR"
|
||||
scp -o BatchMode=yes -o StrictHostKeyChecking=yes \
|
||||
root@100.64.0.8:/root/dogfood-account-reports/wave0-account-report.json \
|
||||
root@100.64.0.8:/root/dogfood-account-reports/wave0-account-report.json.sig \
|
||||
"$TRANSFER_DIR/"
|
||||
install -m 600 "$TRANSFER_DIR/wave0-account-report.json" /受控证据根/wave0/attachments/
|
||||
install -m 600 "$TRANSFER_DIR/wave0-account-report.json.sig" /受控证据根/wave0/attachments/
|
||||
rm -rf "$TRANSFER_DIR"
|
||||
```
|
||||
|
||||
随后填写:
|
||||
|
||||
```json
|
||||
{
|
||||
"schema": "dogfood-accounts/2",
|
||||
"activationRunId": "activation-20260723T010203Z",
|
||||
"databaseReportRef": "attachments/wave0-account-report.json",
|
||||
"databaseReportSignatureRef": "attachments/wave0-account-report.json.sig",
|
||||
"databaseReportSha256": "<报告文件SHA-256>",
|
||||
"databaseReportCapturedAt": "<报告内capturedAt原值>",
|
||||
"selectedAccounts": [
|
||||
{"id": 1001, "username": "dogfoodc01"},
|
||||
{"id": 1101, "username": "dogfoodp01"}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
`selectedAccounts` 必须列出本波全部成员:Wave0 精确 3 名创作者和 5 名玩家,Wave1 精确 10 名创作者和 30 名玩家。证据门只使用仓内 `deploy/trust/r1-attestation-ed25519.pub` 验签,不读取 `accounts.json`、环境变量或命令行中的公钥路径。缺签、签名篡改、错误 issuer、固定查询身份漂移、RC 与 manifest 不一致、new-api user/token 身份重复、token 禁用/耗尽/unlimited/非永久有效都会失败关闭。
|
||||
|
||||
## 4. 首屏 P75 口径
|
||||
|
||||
采样环境固定为 mini-desktop、Chrome 146、`390x844@1`、Tailscale staging。每次禁用缓存后独立导航,从 navigation commit 计到首个可交互游戏帧;每条记录使用唯一 `navigationId` 和唯一原始文件,并标记 `cache=cold`、`source=/gstack@mini-desktop`。`collector` 必须包含 `tool=/gstack`、`host=mini-desktop` 和非空 `sessionId`,且与原始文件内身份一致;`collectedAt` 必须带时区、不早于波次开始且不在未来。热缓存另行报告,禁止写入 `performance.json`。
|
||||
|
||||
@ -9,6 +9,8 @@ import hashlib
|
||||
import json
|
||||
import math
|
||||
import re
|
||||
import stat
|
||||
import subprocess
|
||||
import sys
|
||||
from datetime import date, datetime, timedelta, timezone
|
||||
from pathlib import Path
|
||||
@ -27,6 +29,12 @@ RC_SCHEMA = "dogfood-rc/2"
|
||||
MAIN_CHAIN_SCHEMA = "dogfood-main-chain/2"
|
||||
PERFORMANCE_SCHEMA = "dogfood-first-frame-performance/2"
|
||||
FREEZE_SCHEMA = "dogfood-evidence-freeze/2"
|
||||
ACCOUNT_REPORT_SCHEMA = "dogfood-account-database-report/2"
|
||||
ACCOUNT_REPORT_ISSUER = "r1-mini-infra"
|
||||
ACCOUNT_REPORT_QUERY_ID = "dogfood-account-readiness-v2"
|
||||
ACCOUNT_REPORT_QUERY_SCHEMA = "dogfood-account-readiness-row/2"
|
||||
ACCOUNT_REPORT_PUBLIC_KEY = Path(__file__).resolve().parents[1] / "trust" / "r1-attestation-ed25519.pub"
|
||||
OPENSSL_BIN = Path("/opt/homebrew/bin/openssl") if sys.platform == "darwin" else Path("/usr/bin/openssl")
|
||||
|
||||
TSV_TEMPLATES = {
|
||||
"oncall.tsv": "start_at\tend_at\tprimary\tbackup\tcontact\n",
|
||||
@ -44,6 +52,7 @@ JSON_TEMPLATES: dict[str, dict[str, Any]] = {
|
||||
"schema": "dogfood-accounts/2",
|
||||
"activationRunId": None,
|
||||
"databaseReportRef": None,
|
||||
"databaseReportSignatureRef": None,
|
||||
"databaseReportSha256": None,
|
||||
"databaseReportCapturedAt": None,
|
||||
"selectedAccounts": [],
|
||||
@ -238,6 +247,43 @@ class EvidenceGate:
|
||||
return None
|
||||
return path
|
||||
|
||||
def verify_detached_signature(self, report_path: Path, signature_ref: Any) -> bool:
|
||||
"""只用仓内固定 R1 公钥验证账号报告,不接受调用方提供信任根。"""
|
||||
signature_path = self.checked_ref(signature_ref, "accounts.database-report.signature")
|
||||
if signature_path is None:
|
||||
self.fail("accounts.database-report.signature", "账号数据库报告缺少 detached signature")
|
||||
return False
|
||||
for path, label in ((report_path, "报告"), (signature_path, "签名")):
|
||||
try:
|
||||
mode = stat.S_IMODE(path.stat().st_mode)
|
||||
except OSError as exc:
|
||||
self.fail("accounts.database-report.signature", f"账号{label}权限不可读:{exc}")
|
||||
return False
|
||||
if mode != 0o600:
|
||||
self.fail("accounts.database-report.signature", f"账号{label}权限必须为 0600")
|
||||
return False
|
||||
if not ACCOUNT_REPORT_PUBLIC_KEY.is_file() or ACCOUNT_REPORT_PUBLIC_KEY.is_symlink():
|
||||
self.fail("accounts.database-report.signature", "仓内固定 R1 公钥缺失或不是普通文件")
|
||||
return False
|
||||
try:
|
||||
result = subprocess.run(
|
||||
[
|
||||
str(OPENSSL_BIN), "pkeyutl", "-verify", "-pubin", "-inkey",
|
||||
str(ACCOUNT_REPORT_PUBLIC_KEY), "-rawin", "-in", str(report_path),
|
||||
"-sigfile", str(signature_path),
|
||||
],
|
||||
check=False,
|
||||
capture_output=True,
|
||||
timeout=10,
|
||||
)
|
||||
except (OSError, subprocess.TimeoutExpired) as exc:
|
||||
self.fail("accounts.database-report.signature", f"账号报告验签工具执行失败:{exc}")
|
||||
return False
|
||||
if result.returncode != 0:
|
||||
self.fail("accounts.database-report.signature", "账号数据库报告 detached signature 验证失败")
|
||||
return False
|
||||
return True
|
||||
|
||||
def read_tsv(self, name: str, fields: list[str]) -> list[dict[str, str]] | None:
|
||||
"""用标准库 TSV 解析器校验表头,禁止文本猜字段。"""
|
||||
path = self.wave_dir / name
|
||||
@ -272,10 +318,14 @@ class EvidenceGate:
|
||||
if started_at > self.now:
|
||||
self.fail("manifest.started-at", "manifest.startedAt 不得晚于当前时间")
|
||||
self.started_at = started_at
|
||||
expected_keys = {"schema", "wave", "creatorCount", "playerCount", "durationHours", "startedAt"}
|
||||
expected_keys = {
|
||||
"schema", "wave", "creatorCount", "playerCount", "durationHours", "startedAt", "rcCommit",
|
||||
}
|
||||
if wave == "wave1":
|
||||
expected_keys.update({"wave0CloseResultSha256", "wave0FinalFreezeSha256", "wave0ClosedAt"})
|
||||
fixed_values_match = all(manifest.get(key) == value for key, value in WAVE_SCALES[wave].items())
|
||||
if not isinstance(manifest.get("rcCommit"), str) or not COMMIT_PATTERN.fullmatch(manifest["rcCommit"]):
|
||||
self.fail("manifest.rc-commit", "manifest.rcCommit 必须是完整 40 位小写哈希")
|
||||
if set(manifest) != expected_keys or not fixed_values_match or self.wave_dir.name != wave:
|
||||
self.fail("manifest.identity", "波次目录名、身份或固定规模不匹配")
|
||||
return
|
||||
@ -309,6 +359,8 @@ class EvidenceGate:
|
||||
if rc.get("repoClean") is not True:
|
||||
self.fail("rc.clean", "RC 必须来自 tracked 工作区干净构建")
|
||||
self.check_hash_ref(rc.get("rcArtifactRef"), rc.get("rcArtifactSha256"), "rc.artifact")
|
||||
if self.manifest is not None and rc.get("commit") != self.manifest.get("rcCommit"):
|
||||
self.fail("rc.manifest-commit", "rc.json commit 与 wave manifest 的 rcCommit 不一致")
|
||||
if config is not None:
|
||||
if self.checked_evidence_time(config.get("capturedAt"), "config.capturedAt") is None:
|
||||
self.fail("config.captured-at", "配置身份缺少带时区采集时间")
|
||||
@ -360,6 +412,12 @@ class EvidenceGate:
|
||||
accounts.get("databaseReportSha256"),
|
||||
"accounts.database-report",
|
||||
)
|
||||
expected_account_keys = {
|
||||
"schema", "activationRunId", "databaseReportRef", "databaseReportSignatureRef",
|
||||
"databaseReportSha256", "databaseReportCapturedAt", "selectedAccounts",
|
||||
}
|
||||
if set(accounts) != expected_account_keys:
|
||||
self.fail("accounts.fields", "accounts.json 字段必须精确匹配 dogfood-accounts/2")
|
||||
def checked_report_time(value: Any, context: str) -> datetime | None:
|
||||
"""激活快照可早于波次开始,但不得陈旧超过两小时或晚于当前可信时钟。"""
|
||||
parsed = parse_timestamp(value)
|
||||
@ -378,19 +436,30 @@ class EvidenceGate:
|
||||
|
||||
declared_captured_at = checked_report_time(
|
||||
accounts.get("databaseReportCapturedAt"), "accounts.databaseReportCapturedAt")
|
||||
if report_path is None:
|
||||
if report_path is None or not self.verify_detached_signature(
|
||||
report_path, accounts.get("databaseReportSignatureRef")):
|
||||
return
|
||||
try:
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
self.fail("accounts.database-report.invalid-json", f"账号数据库报告不是合法 JSON:{exc}")
|
||||
return
|
||||
if not isinstance(report, dict) or report.get("schema") != "dogfood-account-database-report/1":
|
||||
expected_report_keys = {
|
||||
"schema", "issuer", "activationRunId", "rcCommit", "capturedAt",
|
||||
"queryId", "querySchema", "accounts", "reserves",
|
||||
}
|
||||
if not isinstance(report, dict) or report.get("schema") != ACCOUNT_REPORT_SCHEMA:
|
||||
self.fail("accounts.database-report.schema", "账号数据库报告 schema 非法")
|
||||
return
|
||||
if report.get("source") != "staging-mysql-readonly" \
|
||||
or not isinstance(report.get("queryId"), str) or not report["queryId"]:
|
||||
self.fail("accounts.database-report.source", "账号报告必须声明 staging MySQL 只读来源和查询 ID")
|
||||
if set(report) != expected_report_keys:
|
||||
self.fail("accounts.database-report.fields", "账号数据库报告字段必须精确匹配固定 schema")
|
||||
if report.get("issuer") != ACCOUNT_REPORT_ISSUER:
|
||||
self.fail("accounts.database-report.issuer", "账号报告签发者必须为固定 r1-mini-infra")
|
||||
if report.get("queryId") != ACCOUNT_REPORT_QUERY_ID \
|
||||
or report.get("querySchema") != ACCOUNT_REPORT_QUERY_SCHEMA:
|
||||
self.fail("accounts.database-report.query", "账号报告必须绑定固定只读查询及行 schema")
|
||||
if report.get("rcCommit") != self.manifest.get("rcCommit"):
|
||||
self.fail("accounts.database-report.rc-commit", "账号报告 rcCommit 与 wave manifest 不一致")
|
||||
if report.get("activationRunId") != activation_run_id:
|
||||
self.fail("accounts.activation-run-id", "accounts.json 与数据库报告的 activation run-id 不一致")
|
||||
report_captured_at = checked_report_time(
|
||||
@ -411,6 +480,9 @@ class EvidenceGate:
|
||||
usernames: set[str] = set()
|
||||
account_ids: set[int] = set()
|
||||
pool_ids: set[int] = set()
|
||||
newapi_user_ids: set[int] = set()
|
||||
newapi_token_ids: set[int] = set()
|
||||
newapi_usernames: set[str] = set()
|
||||
for item in report_accounts:
|
||||
if not isinstance(item, dict):
|
||||
self.fail("accounts.database-report.accounts", "数据库报告存在非对象账号")
|
||||
@ -418,6 +490,12 @@ class EvidenceGate:
|
||||
account_id = item.get("id")
|
||||
username = item.get("username")
|
||||
pool_id = item.get("quotaPoolId")
|
||||
if set(item) != {
|
||||
"id", "username", "role", "accountStatus", "quotaPoolId", "quotaStatus",
|
||||
"grantQuota", "newapiUserId", "newapiTokenId", "newapiUsername",
|
||||
"tokenStatus", "tokenRemainQuota", "tokenUnlimitedQuota", "tokenExpiredTime"}:
|
||||
self.fail("accounts.database-report.accounts", "数据库报告账号字段不符合固定行 schema")
|
||||
continue
|
||||
if not isinstance(account_id, int) or isinstance(account_id, bool) or account_id <= 0 \
|
||||
or not isinstance(username, str) or username not in expected_roles \
|
||||
or item.get("role") != expected_roles.get(username) \
|
||||
@ -430,12 +508,36 @@ class EvidenceGate:
|
||||
self.fail("accounts.database-report.account-quota", f"账号 {username} 缺少唯一额度池 ID")
|
||||
else:
|
||||
pool_ids.add(pool_id)
|
||||
newapi_user_id = item.get("newapiUserId")
|
||||
newapi_token_id = item.get("newapiTokenId")
|
||||
newapi_username = item.get("newapiUsername")
|
||||
authority_identity_valid = (
|
||||
isinstance(newapi_user_id, int) and not isinstance(newapi_user_id, bool)
|
||||
and newapi_user_id > 0 and newapi_user_id not in newapi_user_ids
|
||||
and isinstance(newapi_token_id, int) and not isinstance(newapi_token_id, bool)
|
||||
and newapi_token_id > 0 and newapi_token_id not in newapi_token_ids
|
||||
and isinstance(newapi_username, str)
|
||||
and re.fullmatch(r"neice_[0-9]{3}", newapi_username)
|
||||
and newapi_username not in newapi_usernames
|
||||
)
|
||||
if item.get("accountStatus") != "ACTIVE" \
|
||||
or item.get("quotaStatus") != "CLAIMED" \
|
||||
or item.get("tokenStatus") != "ENABLED" \
|
||||
or not isinstance(item.get("grantQuota"), int) or item["grantQuota"] <= 0 \
|
||||
or not isinstance(item.get("tokenRemainQuota"), int) or item["tokenRemainQuota"] <= 0:
|
||||
or not isinstance(item.get("newapiUserId"), int) or item["newapiUserId"] <= 0 \
|
||||
or not isinstance(item.get("newapiTokenId"), int) or item["newapiTokenId"] <= 0 \
|
||||
or not isinstance(item.get("newapiUsername"), str) \
|
||||
or not re.fullmatch(r"neice_[0-9]{3}", item["newapiUsername"]) \
|
||||
or item.get("tokenStatus") != "ENABLED" \
|
||||
or not isinstance(item.get("tokenRemainQuota"), int) \
|
||||
or isinstance(item.get("tokenRemainQuota"), bool) or item["tokenRemainQuota"] <= 0 \
|
||||
or item.get("tokenUnlimitedQuota") is not False \
|
||||
or item.get("tokenExpiredTime") != -1 \
|
||||
or not authority_identity_valid:
|
||||
self.fail("accounts.database-report.account-quota", f"账号 {username} 的账号或额度状态不可用")
|
||||
else:
|
||||
newapi_user_ids.add(newapi_user_id)
|
||||
newapi_token_ids.add(newapi_token_id)
|
||||
newapi_usernames.add(newapi_username)
|
||||
by_identity[(account_id, username)] = item
|
||||
if usernames != set(expected_roles) or len(report_accounts) != len(expected_roles):
|
||||
self.fail("accounts.database-report.accounts", "数据库报告必须精确包含 dogfoodc01-10 与 dogfoodp01-30")
|
||||
@ -444,7 +546,6 @@ class EvidenceGate:
|
||||
reserve_pool_ids: set[int] = set()
|
||||
reserve_user_ids: set[int] = set()
|
||||
reserve_token_ids: set[int] = set()
|
||||
reserve_usernames: set[str] = set()
|
||||
if not isinstance(reserves, list) or len(reserves) != 12:
|
||||
self.fail("accounts.database-report.reserve-quota", "数据库报告必须精确包含 12 条 FREE 预留额度")
|
||||
else:
|
||||
@ -455,21 +556,32 @@ class EvidenceGate:
|
||||
pool_id = item.get("poolId")
|
||||
user_id = item.get("newapiUserId")
|
||||
token_id = item.get("newapiTokenId")
|
||||
username = item.get("newapiUsername")
|
||||
if set(item) != {
|
||||
"poolId", "newapiUserId", "newapiTokenId", "newapiUsername", "quotaStatus",
|
||||
"grantQuota", "tokenStatus", "tokenRemainQuota", "tokenUnlimitedQuota",
|
||||
"tokenExpiredTime"}:
|
||||
self.fail("accounts.database-report.reserve-quota", "FREE 预留字段不符合固定行 schema")
|
||||
continue
|
||||
identifiers_valid = (
|
||||
isinstance(pool_id, int) and not isinstance(pool_id, bool) and pool_id > 0
|
||||
and isinstance(user_id, int) and not isinstance(user_id, bool) and user_id > 0
|
||||
and isinstance(token_id, int) and not isinstance(token_id, bool) and token_id > 0
|
||||
and isinstance(username, str) and re.fullmatch(r"neice_[0-9]{3}", username)
|
||||
and pool_id not in pool_ids and pool_id not in reserve_pool_ids
|
||||
and user_id not in reserve_user_ids and token_id not in reserve_token_ids
|
||||
and username not in reserve_usernames
|
||||
and user_id not in newapi_user_ids and token_id not in newapi_token_ids
|
||||
and item.get("newapiUsername") not in newapi_usernames
|
||||
)
|
||||
quota_valid = (
|
||||
item.get("quotaStatus") == "FREE"
|
||||
and item.get("tokenStatus") == "ENABLED"
|
||||
and isinstance(item.get("grantQuota"), int) and item["grantQuota"] > 0
|
||||
and isinstance(item.get("tokenRemainQuota"), int) and item["tokenRemainQuota"] > 0
|
||||
and isinstance(item.get("newapiUsername"), str)
|
||||
and re.fullmatch(r"neice_[0-9]{3}", item["newapiUsername"])
|
||||
and item.get("tokenStatus") == "ENABLED"
|
||||
and isinstance(item.get("tokenRemainQuota"), int)
|
||||
and not isinstance(item.get("tokenRemainQuota"), bool)
|
||||
and item["tokenRemainQuota"] > 0
|
||||
and item.get("tokenUnlimitedQuota") is False
|
||||
and item.get("tokenExpiredTime") == -1
|
||||
)
|
||||
if not identifiers_valid or not quota_valid:
|
||||
self.fail("accounts.database-report.reserve-quota", "FREE 预留额度的身份、状态或余额非法")
|
||||
@ -477,7 +589,9 @@ class EvidenceGate:
|
||||
reserve_pool_ids.add(pool_id)
|
||||
reserve_user_ids.add(user_id)
|
||||
reserve_token_ids.add(token_id)
|
||||
reserve_usernames.add(username)
|
||||
newapi_user_ids.add(user_id)
|
||||
newapi_token_ids.add(token_id)
|
||||
newapi_usernames.add(item["newapiUsername"])
|
||||
|
||||
selected = accounts.get("selectedAccounts")
|
||||
if not isinstance(selected, list):
|
||||
@ -1367,12 +1481,15 @@ def ensure_wave_scaffold(wave_dir: Path) -> list[str]:
|
||||
return created
|
||||
|
||||
|
||||
def initialize_wave(root: Path, wave: str, now: datetime | None = None) -> int:
|
||||
def initialize_wave(root: Path, wave: str, now: datetime | None = None, rc_commit: str | None = None) -> int:
|
||||
"""初始化固定波次目录;已存在时只校验身份,不覆盖证据。"""
|
||||
if wave not in WAVE_SCALES:
|
||||
print("INIT_REFUSED:仅允许 wave0 或 wave1。", file=sys.stderr)
|
||||
return 2
|
||||
current_time = normalized_utc(now)
|
||||
if not isinstance(rc_commit, str) or not COMMIT_PATTERN.fullmatch(rc_commit):
|
||||
print("INIT_REFUSED:--rc-commit 必须是完整 40 位小写哈希。", file=sys.stderr)
|
||||
return 2
|
||||
root = root.resolve()
|
||||
wave_dir = root / wave
|
||||
manifest_path = wave_dir / "manifest.json"
|
||||
@ -1386,7 +1503,9 @@ def initialize_wave(root: Path, wave: str, now: datetime | None = None) -> int:
|
||||
except (OSError, json.JSONDecodeError) as exc:
|
||||
print(f"INIT_REFUSED:既有 manifest 不可读:{exc}", file=sys.stderr)
|
||||
return 2
|
||||
expected_keys = {"schema", "wave", "creatorCount", "playerCount", "durationHours", "startedAt"}
|
||||
expected_keys = {
|
||||
"schema", "wave", "creatorCount", "playerCount", "durationHours", "startedAt", "rcCommit",
|
||||
}
|
||||
if wave == "wave1":
|
||||
expected_keys.update({"wave0CloseResultSha256", "wave0FinalFreezeSha256", "wave0ClosedAt"})
|
||||
started_at = parse_timestamp(current_manifest.get("startedAt")) if isinstance(current_manifest, dict) else None
|
||||
@ -1396,6 +1515,7 @@ def initialize_wave(root: Path, wave: str, now: datetime | None = None) -> int:
|
||||
or set(current_manifest) != expected_keys
|
||||
or current_manifest.get("schema") != MANIFEST_SCHEMA
|
||||
or current_manifest.get("wave") != wave
|
||||
or current_manifest.get("rcCommit") != rc_commit
|
||||
or started_at is None
|
||||
or started_at > current_time
|
||||
or not fixed_values_match
|
||||
@ -1420,6 +1540,7 @@ def initialize_wave(root: Path, wave: str, now: datetime | None = None) -> int:
|
||||
"wave": wave,
|
||||
**WAVE_SCALES[wave],
|
||||
"startedAt": format_timestamp(current_time),
|
||||
"rcCommit": rc_commit,
|
||||
}
|
||||
if wave == "wave1":
|
||||
binding, errors = inspect_wave0_close(root, current_time)
|
||||
@ -1443,6 +1564,7 @@ def build_parser() -> argparse.ArgumentParser:
|
||||
init_parser = subparsers.add_parser("init", help="初始化 wave0 或 wave1 证据目录")
|
||||
init_parser.add_argument("--root", required=True, type=Path, help="证据根目录")
|
||||
init_parser.add_argument("--wave", required=True, choices=sorted(WAVE_SCALES), help="固定波次")
|
||||
init_parser.add_argument("--rc-commit", required=True, help="波次绑定的完整 40 位 RC commit")
|
||||
check_parser = subparsers.add_parser("check", help="执行开波或收口证据门")
|
||||
check_parser.add_argument("--wave-dir", required=True, type=Path, help="单个波次证据目录")
|
||||
check_parser.add_argument("--phase", required=True, choices=("readiness", "close"), help="闸门阶段")
|
||||
@ -1463,7 +1585,7 @@ def main() -> int:
|
||||
"""解析命令并返回稳定退出码。"""
|
||||
args = build_parser().parse_args()
|
||||
if args.command == "init":
|
||||
return initialize_wave(args.root, args.wave)
|
||||
return initialize_wave(args.root, args.wave, rc_commit=args.rc_commit)
|
||||
if args.command == "check":
|
||||
return EvidenceGate(args.wave_dir, args.phase).run()
|
||||
if args.command == "freeze":
|
||||
|
||||
@ -6,6 +6,7 @@ from __future__ import annotations
|
||||
import hashlib
|
||||
import importlib.util
|
||||
import json
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
import unittest
|
||||
@ -27,6 +28,36 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
"""通过命令行入口验证运维人员实际使用的行为。"""
|
||||
|
||||
FIXTURE_STARTED_AT = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc)
|
||||
FIXTURE_RC_COMMIT = "1" * 40
|
||||
|
||||
@classmethod
|
||||
def setUpClass(cls) -> None:
|
||||
"""生成测试专用签名根,并复制 CLI 到相同的固定相对信任路径。"""
|
||||
cls.trust_temp_dir = tempfile.TemporaryDirectory()
|
||||
deploy_dir = Path(cls.trust_temp_dir.name) / "deploy"
|
||||
(deploy_dir / "dogfood-ops").mkdir(parents=True)
|
||||
(deploy_dir / "trust").mkdir(parents=True)
|
||||
cls.test_tool = deploy_dir / "dogfood-ops" / "dogfood_ops.py"
|
||||
cls.test_private_key = deploy_dir / "trust" / "r1-private.pem"
|
||||
cls.test_public_key = deploy_dir / "trust" / "r1-attestation-ed25519.pub"
|
||||
shutil.copy2(TOOL, cls.test_tool)
|
||||
subprocess.run(
|
||||
["openssl", "genpkey", "-algorithm", "ED25519", "-out", str(cls.test_private_key)],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
subprocess.run(
|
||||
["openssl", "pkey", "-in", str(cls.test_private_key), "-pubout", "-out", str(cls.test_public_key)],
|
||||
check=True, capture_output=True,
|
||||
)
|
||||
cls.original_public_key = getattr(DOGFOOD_OPS, "ACCOUNT_REPORT_PUBLIC_KEY", None)
|
||||
DOGFOOD_OPS.ACCOUNT_REPORT_PUBLIC_KEY = cls.test_public_key
|
||||
|
||||
@classmethod
|
||||
def tearDownClass(cls) -> None:
|
||||
"""恢复模块常量并删除测试签名材料。"""
|
||||
if cls.original_public_key is not None:
|
||||
DOGFOOD_OPS.ACCOUNT_REPORT_PUBLIC_KEY = cls.original_public_key
|
||||
cls.trust_temp_dir.cleanup()
|
||||
|
||||
def setUp(self) -> None:
|
||||
"""每个用例使用独立临时证据根,避免测试相互污染。"""
|
||||
@ -40,7 +71,7 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def run_tool(self, *args: str) -> subprocess.CompletedProcess[str]:
|
||||
"""调用真实 CLI,并保留标准输出供失败断言。"""
|
||||
return subprocess.run(
|
||||
["python3", str(TOOL), *args],
|
||||
["python3", str(self.test_tool), *args],
|
||||
check=False,
|
||||
text=True,
|
||||
capture_output=True,
|
||||
@ -49,7 +80,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def init_wave(self, wave: str) -> Path:
|
||||
"""初始化指定波次并返回证据目录。"""
|
||||
# 公共通过夹具固定在已过去的可信时间,避免用未来证据伪造刚初始化波次的 readiness。
|
||||
result = DOGFOOD_OPS.initialize_wave(self.root, wave, now=self.FIXTURE_STARTED_AT)
|
||||
result = DOGFOOD_OPS.initialize_wave(
|
||||
self.root, wave, now=self.FIXTURE_STARTED_AT, rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
self.assertEqual(result, 0)
|
||||
return self.root / wave
|
||||
|
||||
@ -64,10 +96,24 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
attachment.write_text(content, encoding="utf-8")
|
||||
return attachment.relative_to(wave_dir).as_posix(), hashlib.sha256(attachment.read_bytes()).hexdigest()
|
||||
|
||||
def sign_report(self, report_path: Path, signature_path: Path) -> None:
|
||||
"""使用测试专用私钥生成真实 detached signature。"""
|
||||
subprocess.run(
|
||||
[
|
||||
"openssl", "pkeyutl", "-sign", "-inkey", str(self.test_private_key),
|
||||
"-rawin", "-in", str(report_path), "-out", str(signature_path),
|
||||
],
|
||||
check=True,
|
||||
capture_output=True,
|
||||
)
|
||||
report_path.chmod(0o600)
|
||||
signature_path.chmod(0o600)
|
||||
|
||||
def close_valid_wave0(self) -> tuple[Path, datetime]:
|
||||
"""按两个实际 UTC 运行日构造已通过收口门的 Wave0。"""
|
||||
started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at), 0)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave_dir = self.root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
self.assertEqual(
|
||||
@ -164,7 +210,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
with self.subTest(evidence=name):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "evidence"
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(root, "wave0", now=started_at), 0)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave_dir = root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
mutate(wave_dir, invalid_time)
|
||||
@ -178,7 +225,10 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
|
||||
def test_wave1_init_requires_closed_wave0(self) -> None:
|
||||
"""没有 Wave0 收口回执时不得初始化 Wave1。"""
|
||||
result = self.run_tool("init", "--root", str(self.root), "--wave", "wave1")
|
||||
result = self.run_tool(
|
||||
"init", "--root", str(self.root), "--wave", "wave1",
|
||||
"--rc-commit", self.FIXTURE_RC_COMMIT,
|
||||
)
|
||||
|
||||
self.assertNotEqual(result.returncode, 0)
|
||||
self.assertIn("WAVE0_CLOSE_REQUIRED", result.stderr)
|
||||
@ -186,11 +236,13 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def test_manifest_and_freeze_use_tool_generated_trusted_times(self) -> None:
|
||||
"""开始与冻结时间必须由工具生成,并拒绝未来或回填日期。"""
|
||||
started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc)
|
||||
result = DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at)
|
||||
result = DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
self.assertEqual(result, 0)
|
||||
wave_dir = self.root / "wave0"
|
||||
manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
self.assertEqual(manifest["startedAt"], "2026-07-20T09:00:00Z")
|
||||
self.assertEqual(manifest["rcCommit"], self.FIXTURE_RC_COMMIT)
|
||||
|
||||
backfill = DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-19", now=started_at)
|
||||
future = DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-21", now=started_at)
|
||||
@ -205,7 +257,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def test_close_requires_elapsed_runtime_and_every_actual_utc_day(self) -> None:
|
||||
"""收口必须达到真实时长,并覆盖从 startedAt 到 close 的每个 UTC 运行日。"""
|
||||
started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc)
|
||||
DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at)
|
||||
DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
wave_dir = self.root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-20", now=started_at.replace(hour=23))
|
||||
@ -224,7 +277,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def test_same_minute_cannot_forge_three_daily_freezes(self) -> None:
|
||||
"""同一时刻只能冻结当天,不能用三个日期伪造 Wave1 三天运行。"""
|
||||
now = datetime(2026, 7, 23, 5, 30, tzinfo=timezone.utc)
|
||||
DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=now)
|
||||
DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave0", now=now, rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
wave_dir = self.root / "wave0"
|
||||
|
||||
results = [
|
||||
@ -237,7 +291,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def test_close_rejects_forged_freeze_created_at(self) -> None:
|
||||
"""手工把 createdAt 改成未来或与文件日期不一致时不得收口。"""
|
||||
started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc)
|
||||
DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at)
|
||||
DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
wave_dir = self.root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
freeze_now = datetime(2026, 7, 20, 23, 0, tzinfo=timezone.utc)
|
||||
@ -309,6 +364,7 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
report["accounts"].pop()
|
||||
self.write_json(report_path, report)
|
||||
self.sign_report(report_path, wave_dir / accounts["databaseReportSignatureRef"])
|
||||
accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest()
|
||||
self.write_json(wave_dir / "accounts.json", accounts)
|
||||
|
||||
@ -323,12 +379,19 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
("activation", "accounts.activation-run-id"),
|
||||
("captured-at", "accounts.database-report.captured-at"),
|
||||
("claimed-quota", "accounts.database-report.account-quota"),
|
||||
("token-disabled", "accounts.database-report.account-quota"),
|
||||
("token-balance", "accounts.database-report.account-quota"),
|
||||
("token-unlimited", "accounts.database-report.account-quota"),
|
||||
("token-expiry", "accounts.database-report.account-quota"),
|
||||
("newapi-user-duplicate", "accounts.database-report.account-quota"),
|
||||
("newapi-token-duplicate", "accounts.database-report.account-quota"),
|
||||
("reserve-status", "accounts.database-report.reserve-quota"),
|
||||
):
|
||||
with self.subTest(mutation=mutation):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "evidence"
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(root, "wave0"), 0)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave_dir = root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
accounts_path = wave_dir / "accounts.json"
|
||||
@ -346,10 +409,23 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
report["capturedAt"] = stale_time
|
||||
accounts["databaseReportCapturedAt"] = stale_time
|
||||
elif mutation == "claimed-quota":
|
||||
report["accounts"][0]["grantQuota"] = 0
|
||||
elif mutation == "token-disabled":
|
||||
report["accounts"][0]["tokenStatus"] = "DISABLED"
|
||||
elif mutation == "token-balance":
|
||||
report["accounts"][0]["tokenRemainQuota"] = 0
|
||||
elif mutation == "token-unlimited":
|
||||
report["accounts"][0]["tokenUnlimitedQuota"] = True
|
||||
elif mutation == "token-expiry":
|
||||
report["accounts"][0]["tokenExpiredTime"] = 1800000000
|
||||
elif mutation == "newapi-user-duplicate":
|
||||
report["accounts"][1]["newapiUserId"] = report["accounts"][0]["newapiUserId"]
|
||||
elif mutation == "newapi-token-duplicate":
|
||||
report["accounts"][1]["newapiTokenId"] = report["accounts"][0]["newapiTokenId"]
|
||||
else:
|
||||
report["reserves"][0]["quotaStatus"] = "CLAIMED"
|
||||
self.write_json(report_path, report)
|
||||
self.sign_report(report_path, wave_dir / accounts["databaseReportSignatureRef"])
|
||||
accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest()
|
||||
self.write_json(accounts_path, accounts)
|
||||
|
||||
@ -371,6 +447,62 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
self.assertNotEqual(gate.run(), 0)
|
||||
self.assertTrue(any(code == "accounts.selected.identity" for code, _ in gate.errors), gate.errors)
|
||||
|
||||
def test_accounts_rejects_missing_or_tampered_detached_signature(self) -> None:
|
||||
"""缺签或报告签名后被篡改时必须失败关闭。"""
|
||||
for mutation in ("missing", "tampered"):
|
||||
with self.subTest(mutation=mutation):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "evidence"
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave_dir = root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
accounts = json.loads((wave_dir / "accounts.json").read_text(encoding="utf-8"))
|
||||
signature_path = wave_dir / accounts["databaseReportSignatureRef"]
|
||||
if mutation == "missing":
|
||||
signature_path.unlink()
|
||||
else:
|
||||
report_path = wave_dir / accounts["databaseReportRef"]
|
||||
report_path.write_text(report_path.read_text(encoding="utf-8") + " ", encoding="utf-8")
|
||||
accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest()
|
||||
self.write_json(wave_dir / "accounts.json", accounts)
|
||||
|
||||
gate = DOGFOOD_OPS.EvidenceGate(wave_dir, "readiness")
|
||||
self.assertNotEqual(gate.run(), 0)
|
||||
self.assertTrue(
|
||||
any(code == "accounts.database-report.signature" for code, _ in gate.errors),
|
||||
gate.errors,
|
||||
)
|
||||
|
||||
def test_accounts_rejects_signed_issuer_rc_or_query_drift(self) -> None:
|
||||
"""即使签名有效,固定签发者、manifest RC 和固定查询身份也不能漂移。"""
|
||||
for field, value, expected_code in (
|
||||
("issuer", "operator-self-signed", "accounts.database-report.issuer"),
|
||||
("rcCommit", "2" * 40, "accounts.database-report.rc-commit"),
|
||||
("queryId", "handwritten-query", "accounts.database-report.query"),
|
||||
("querySchema", "handwritten-row/1", "accounts.database-report.query"),
|
||||
):
|
||||
with self.subTest(field=field):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "evidence"
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave_dir = root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
accounts_path = wave_dir / "accounts.json"
|
||||
accounts = json.loads(accounts_path.read_text(encoding="utf-8"))
|
||||
report_path = wave_dir / accounts["databaseReportRef"]
|
||||
report = json.loads(report_path.read_text(encoding="utf-8"))
|
||||
report[field] = value
|
||||
self.write_json(report_path, report)
|
||||
self.sign_report(report_path, wave_dir / accounts["databaseReportSignatureRef"])
|
||||
accounts["databaseReportSha256"] = hashlib.sha256(report_path.read_bytes()).hexdigest()
|
||||
self.write_json(accounts_path, accounts)
|
||||
|
||||
gate = DOGFOOD_OPS.EvidenceGate(wave_dir, "readiness")
|
||||
self.assertNotEqual(gate.run(), 0)
|
||||
self.assertTrue(any(code == expected_code for code, _ in gate.errors), gate.errors)
|
||||
|
||||
def populate_valid_evidence(self, wave_dir: Path) -> None:
|
||||
"""构造不依赖 staging 的完整本地通过 fixture。"""
|
||||
manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
@ -440,8 +572,13 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
"quotaPoolId": 2000 + index,
|
||||
"quotaStatus": "CLAIMED",
|
||||
"grantQuota": 6849315,
|
||||
"newapiUserId": 4000 + index,
|
||||
"newapiTokenId": 5000 + index,
|
||||
"newapiUsername": f"neice_{index:03d}",
|
||||
"tokenStatus": "ENABLED",
|
||||
"tokenRemainQuota": 6849315,
|
||||
"tokenUnlimitedQuota": False,
|
||||
"tokenExpiredTime": -1,
|
||||
})
|
||||
for index in range(1, 31):
|
||||
all_accounts.append({
|
||||
@ -452,29 +589,40 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
"quotaPoolId": 2100 + index,
|
||||
"quotaStatus": "CLAIMED",
|
||||
"grantQuota": 6849315,
|
||||
"newapiUserId": 4100 + index,
|
||||
"newapiTokenId": 5100 + index,
|
||||
"newapiUsername": f"neice_{10 + index:03d}",
|
||||
"tokenStatus": "ENABLED",
|
||||
"tokenRemainQuota": 6849315,
|
||||
"tokenUnlimitedQuota": False,
|
||||
"tokenExpiredTime": -1,
|
||||
})
|
||||
reserves = [{
|
||||
"poolId": 3000 + index,
|
||||
"newapiUserId": 4000 + index,
|
||||
"newapiUsername": f"neice_{index:03d}",
|
||||
"newapiTokenId": 5000 + index,
|
||||
"newapiUserId": 4200 + index,
|
||||
"newapiTokenId": 5200 + index,
|
||||
"newapiUsername": f"neice_{40 + index:03d}",
|
||||
"quotaStatus": "FREE",
|
||||
"grantQuota": 6849315,
|
||||
"tokenStatus": "ENABLED",
|
||||
"tokenRemainQuota": 6849315,
|
||||
"tokenUnlimitedQuota": False,
|
||||
"tokenExpiredTime": -1,
|
||||
} for index in range(1, 13)]
|
||||
activation_run_id = "activation-20260723T010203Z"
|
||||
self.write_json(wave_dir / account_report_ref, {
|
||||
"schema": "dogfood-account-database-report/1",
|
||||
"schema": "dogfood-account-database-report/2",
|
||||
"issuer": "r1-mini-infra",
|
||||
"activationRunId": activation_run_id,
|
||||
"rcCommit": commit,
|
||||
"capturedAt": captured_at,
|
||||
"source": "staging-mysql-readonly",
|
||||
"queryId": f"account-quota-audit-{wave}",
|
||||
"queryId": "dogfood-account-readiness-v2",
|
||||
"querySchema": "dogfood-account-readiness-row/2",
|
||||
"accounts": all_accounts,
|
||||
"reserves": reserves,
|
||||
})
|
||||
account_signature_ref = account_report_ref + ".sig"
|
||||
self.sign_report(wave_dir / account_report_ref, wave_dir / account_signature_ref)
|
||||
account_report_hash = hashlib.sha256((wave_dir / account_report_ref).read_bytes()).hexdigest()
|
||||
selected = (
|
||||
all_accounts[:3] + all_accounts[10:15]
|
||||
@ -485,6 +633,7 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
"schema": "dogfood-accounts/2",
|
||||
"activationRunId": activation_run_id,
|
||||
"databaseReportRef": account_report_ref,
|
||||
"databaseReportSignatureRef": account_signature_ref,
|
||||
"databaseReportSha256": account_report_hash,
|
||||
"databaseReportCapturedAt": captured_at,
|
||||
"selectedAccounts": [
|
||||
@ -627,7 +776,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
"""wave1 必须固定为累计 10 名创作者和 30 名玩家。"""
|
||||
wave0_dir, closed_at = self.close_valid_wave0()
|
||||
self.assertTrue((wave0_dir / "close-result.json").is_file())
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=closed_at + timedelta(minutes=1)), 0)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave1", now=closed_at + timedelta(minutes=1), rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave_dir = self.root / "wave1"
|
||||
|
||||
manifest = json.loads((wave_dir / "manifest.json").read_text(encoding="utf-8"))
|
||||
@ -640,7 +790,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
"""Wave1 开波门必须持续复核 Wave0 回执和最终冻结哈希。"""
|
||||
wave0_dir, closed_at = self.close_valid_wave0()
|
||||
wave1_started_at = closed_at + timedelta(minutes=1)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=wave1_started_at), 0)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave1", now=wave1_started_at, rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave1_dir = self.root / "wave1"
|
||||
self.populate_valid_evidence(wave1_dir)
|
||||
(wave0_dir / "close-result.json").write_text(
|
||||
@ -662,7 +813,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
encoding="utf-8",
|
||||
)
|
||||
|
||||
result = DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=closed_at + timedelta(minutes=1))
|
||||
result = DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave1", now=closed_at + timedelta(minutes=1), rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
|
||||
self.assertNotEqual(result, 0)
|
||||
_, errors = DOGFOOD_OPS.inspect_wave0_close(self.root, closed_at + timedelta(minutes=1))
|
||||
@ -676,7 +828,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
next(item for item in payload["conditions"] if item["condition"] == "confirmed-false-positive")["triggered"] = True
|
||||
self.write_json(path, payload)
|
||||
|
||||
result = DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=closed_at + timedelta(minutes=1))
|
||||
result = DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave1", now=closed_at + timedelta(minutes=1), rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
|
||||
self.assertNotEqual(result, 0)
|
||||
_, errors = DOGFOOD_OPS.inspect_wave0_close(self.root, closed_at + timedelta(minutes=1))
|
||||
@ -690,7 +843,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
next(item for item in payload["conditions"] if item["condition"] == "review-backlog-over-4h")["triggered"] = True
|
||||
self.write_json(path, payload)
|
||||
|
||||
result = DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=closed_at + timedelta(minutes=1))
|
||||
result = DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave1", now=closed_at + timedelta(minutes=1), rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
|
||||
self.assertNotEqual(result, 0)
|
||||
_, errors = DOGFOOD_OPS.inspect_wave0_close(self.root, closed_at + timedelta(minutes=1))
|
||||
@ -703,7 +857,10 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
feedback.write_text(feedback.read_text(encoding="utf-8") + "F-1\t2026-07-22T00:00:00Z\tu1\tplayer\tbug\tP1\t卡住\tOPEN\tops\tattachments/f.txt\n", encoding="utf-8")
|
||||
before = hashlib.sha256(feedback.read_bytes()).hexdigest()
|
||||
|
||||
result = self.run_tool("init", "--root", str(self.root), "--wave", "wave0")
|
||||
result = self.run_tool(
|
||||
"init", "--root", str(self.root), "--wave", "wave0",
|
||||
"--rc-commit", self.FIXTURE_RC_COMMIT,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertEqual(hashlib.sha256(feedback.read_bytes()).hexdigest(), before)
|
||||
@ -717,7 +874,10 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
(wave_dir / "rc.json").unlink()
|
||||
(wave_dir / "attachments").rmdir()
|
||||
|
||||
result = self.run_tool("init", "--root", str(self.root), "--wave", "wave0")
|
||||
result = self.run_tool(
|
||||
"init", "--root", str(self.root), "--wave", "wave0",
|
||||
"--rc-commit", self.FIXTURE_RC_COMMIT,
|
||||
)
|
||||
|
||||
self.assertEqual(result.returncode, 0, result.stderr)
|
||||
self.assertIn("INIT_REPAIRED", result.stdout)
|
||||
@ -890,7 +1050,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
with self.subTest(collected_at=collected_at):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "evidence"
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(root, "wave0"), 0)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave_dir = root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
path = wave_dir / "performance.json"
|
||||
@ -978,7 +1139,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
with self.subTest(ref_key=ref_key):
|
||||
with tempfile.TemporaryDirectory() as temp_dir:
|
||||
root = Path(temp_dir) / "evidence"
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(root, "wave0"), 0)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
root, "wave0", rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave_dir = root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
chain = json.loads((wave_dir / "main-chain.json").read_text(encoding="utf-8"))
|
||||
@ -1011,7 +1173,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def test_close_requires_freeze_and_detects_post_freeze_tampering(self) -> None:
|
||||
"""收口门必须验证最近一次 SHA-256 冻结,冻结后改动立即失效。"""
|
||||
started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc)
|
||||
DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at)
|
||||
DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
wave_dir = self.root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
closed_at = started_at + timedelta(hours=24, minutes=1)
|
||||
@ -1035,7 +1198,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def test_close_rejects_malformed_freeze_without_traceback(self) -> None:
|
||||
"""畸形冻结清单应形成可追踪失败,而不是让闸门异常退出。"""
|
||||
started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc)
|
||||
DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at)
|
||||
DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
wave_dir = self.root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
freeze_dir = wave_dir / "freezes"
|
||||
@ -1050,7 +1214,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def test_close_rejects_symlink_added_after_freeze_without_traceback(self) -> None:
|
||||
"""冻结后加入符号链接时应 fail-closed,并保持稳定退出码。"""
|
||||
started_at = datetime(2026, 7, 20, 9, 0, tzinfo=timezone.utc)
|
||||
DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=started_at)
|
||||
DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave0", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
wave_dir = self.root / "wave0"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
self.assertEqual(DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-20", now=started_at.replace(hour=23)), 0)
|
||||
@ -1066,7 +1231,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
"""Wave1 必须真实运行 72 小时并覆盖四个实际 UTC 日期,且哈希链不可篡改。"""
|
||||
_, wave0_closed_at = self.close_valid_wave0()
|
||||
started_at = wave0_closed_at + timedelta(minutes=1)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(self.root, "wave1", now=started_at), 0)
|
||||
self.assertEqual(DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave1", now=started_at, rc_commit=self.FIXTURE_RC_COMMIT), 0)
|
||||
wave_dir = self.root / "wave1"
|
||||
self.populate_valid_evidence(wave_dir)
|
||||
close_time = started_at + timedelta(hours=72, minutes=1)
|
||||
@ -1120,7 +1286,8 @@ class DogfoodOpsTest(unittest.TestCase):
|
||||
def test_freeze_is_idempotent_but_refuses_same_day_rewrite(self) -> None:
|
||||
"""同一证据可重复冻结,内容变化后不得覆盖同日清单。"""
|
||||
now = datetime(2026, 7, 22, 8, 0, tzinfo=timezone.utc)
|
||||
DOGFOOD_OPS.initialize_wave(self.root, "wave0", now=now)
|
||||
DOGFOOD_OPS.initialize_wave(
|
||||
self.root, "wave0", now=now, rc_commit=self.FIXTURE_RC_COMMIT)
|
||||
wave_dir = self.root / "wave0"
|
||||
self.assertEqual(DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-22", now=now), 0)
|
||||
self.assertEqual(DOGFOOD_OPS.freeze_evidence(wave_dir, "2026-07-22", now=now + timedelta(minutes=1)), 0)
|
||||
|
||||
@ -99,11 +99,13 @@ security_java_tests=(
|
||||
GameVersionServiceImplTest
|
||||
PassportServiceImplTest
|
||||
ProjectServiceImplTest
|
||||
ProjectVersionApiImplDogfoodTest
|
||||
PublishOrchestrationServiceImplTest
|
||||
RuntimePackageServiceImplTest
|
||||
RuntimePackageApiImplDogfoodTest
|
||||
AdminRuntimeControllerDogfoodTest
|
||||
AppFeedControllerDogfoodTest
|
||||
FeedApiImplDogfoodTest
|
||||
AdControllerDogfoodTest
|
||||
TradeControllerDogfoodTest
|
||||
AdminNewapiQuotaControllerTest
|
||||
|
||||
169
deploy/tests/test-collect-dogfood-account-report.sh
Executable file
169
deploy/tests/test-collect-dogfood-account-report.sh
Executable file
@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env bash
|
||||
# 账号报告采集器回归:使用假 SSH/MySQL 输出,不连接真实内网主机。
|
||||
set -euo pipefail
|
||||
|
||||
ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)"
|
||||
SCRIPT="$ROOT_DIR/deploy/collect-dogfood-account-report.sh"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
trap 'rm -rf "$TMP_DIR"' EXIT
|
||||
|
||||
fail() {
|
||||
printf 'TEST_FAIL %s\n' "$*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
openssl genpkey -algorithm ED25519 -out "$TMP_DIR/private.pem" >/dev/null 2>&1
|
||||
openssl pkey -in "$TMP_DIR/private.pem" -pubout -out "$TMP_DIR/public.pem" >/dev/null 2>&1
|
||||
|
||||
{
|
||||
for index in $(seq 1 10); do
|
||||
printf 'ACCOUNT\t%d\tdogfoodc%02d\tcreator\tACTIVE\t%d\tCLAIMED\t6849315\t%d\t%d\n' \
|
||||
"$((1000 + index))" "$index" "$((2000 + index))" "$((4000 + index))" "$((5000 + index))"
|
||||
done
|
||||
for index in $(seq 1 30); do
|
||||
printf 'ACCOUNT\t%d\tdogfoodp%02d\tplayer\tACTIVE\t%d\tCLAIMED\t6849315\t%d\t%d\n' \
|
||||
"$((1100 + index))" "$index" "$((2100 + index))" "$((4100 + index))" "$((5100 + index))"
|
||||
done
|
||||
for index in $(seq 1 12); do
|
||||
printf 'RESERVE\t%d\t%d\t%d\tFREE\t6849315\t\t\t\t\n' \
|
||||
"$((3000 + index))" "$((4200 + index))" "$((5200 + index))"
|
||||
done
|
||||
} >"$TMP_DIR/mysql.tsv"
|
||||
|
||||
{
|
||||
for index in $(seq 1 10); do
|
||||
printf 'TOKEN|%d|neice_%03d|%d|1|0|-1|6849315\n' \
|
||||
"$((4000 + index))" "$index" "$((5000 + index))"
|
||||
done
|
||||
for index in $(seq 1 30); do
|
||||
printf 'TOKEN|%d|neice_%03d|%d|1|0|-1|6849315\n' \
|
||||
"$((4100 + index))" "$((10 + index))" "$((5100 + index))"
|
||||
done
|
||||
for index in $(seq 1 12); do
|
||||
printf 'TOKEN|%d|neice_%03d|%d|1|0|-1|6849315\n' \
|
||||
"$((4200 + index))" "$((40 + index))" "$((5200 + index))"
|
||||
done
|
||||
} >"$TMP_DIR/newapi.tsv"
|
||||
|
||||
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"
|
||||
SH
|
||||
chmod +x "$TMP_DIR/fake-ssh"
|
||||
|
||||
cat >"$TMP_DIR/fake-docker" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
printf '%s\n' "$*" >"$DOGFOOD_TEST_DOCKER_ARGS"
|
||||
cat >"$DOGFOOD_TEST_PG_SQL_CAPTURE"
|
||||
cat "$DOGFOOD_TEST_NEWAPI_ROWS"
|
||||
SH
|
||||
chmod +x "$TMP_DIR/fake-docker"
|
||||
|
||||
export DOGFOOD_COLLECTOR_TEST_MODE=1
|
||||
export DOGFOOD_COLLECTOR_TEST_SSH_BIN="$TMP_DIR/fake-ssh"
|
||||
export DOGFOOD_COLLECTOR_TEST_SIGNER_KEY="$TMP_DIR/private.pem"
|
||||
export DOGFOOD_COLLECTOR_TEST_OPENSSL_BIN="$(command -v openssl)"
|
||||
export DOGFOOD_COLLECTOR_TEST_DOCKER_BIN="$TMP_DIR/fake-docker"
|
||||
export DOGFOOD_TEST_SSH_ARGS="$TMP_DIR/ssh-args"
|
||||
export DOGFOOD_TEST_SQL_CAPTURE="$TMP_DIR/mysql.sql"
|
||||
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"
|
||||
|
||||
report="$TMP_DIR/account-report.json"
|
||||
"$SCRIPT" \
|
||||
--activation-run-id activation-20260723T010203Z \
|
||||
--rc-commit 1111111111111111111111111111111111111111 \
|
||||
--output "$report"
|
||||
|
||||
[ -f "$report" ] && [ -f "$report.sig" ] || fail '没有生成报告及 detached signature'
|
||||
[ "$(stat -f '%Lp' "$report" 2>/dev/null || stat -c '%a' "$report")" = 600 ] || fail '报告权限不是 0600'
|
||||
[ "$(stat -f '%Lp' "$report.sig" 2>/dev/null || stat -c '%a' "$report.sig")" = 600 ] || fail '签名权限不是 0600'
|
||||
openssl pkeyutl -verify -pubin -inkey "$TMP_DIR/public.pem" -rawin \
|
||||
-in "$report" -sigfile "$report.sig" >/dev/null 2>&1 || fail 'detached signature 无法验证'
|
||||
grep -q 'root@100.64.0.7' "$TMP_DIR/ssh-args" || fail 'SSH 目标不是固定 mini-desktop'
|
||||
grep -q '^START TRANSACTION READ ONLY;' "$TMP_DIR/mysql.sql" || fail 'MySQL 查询未进入服务端只读事务'
|
||||
grep -q 'FROM game_player p' "$TMP_DIR/mysql.sql" || fail '固定账号查询缺失'
|
||||
grep -q 'FROM newapi_quota_pool q' "$TMP_DIR/mysql.sql" || fail '固定额度池查询缺失'
|
||||
if grep -Eqi 'newapi_token_key|password|access_token|refresh_token' "$TMP_DIR/mysql.sql"; then
|
||||
fail '固定查询选择了敏感列'
|
||||
fi
|
||||
grep -q 'infra-postgres' "$TMP_DIR/docker-args" || fail 'new-api PostgreSQL 容器不是固定值'
|
||||
grep -q 'new-api' "$TMP_DIR/docker-args" || fail 'new-api PostgreSQL 数据库不是固定值'
|
||||
grep -q '^BEGIN TRANSACTION READ ONLY;' "$TMP_DIR/newapi.sql" || fail 'new-api 查询未进入只读事务'
|
||||
grep -q 'FROM users u' "$TMP_DIR/newapi.sql" || fail 'new-api users 固定查询缺失'
|
||||
grep -q 'JOIN tokens t' "$TMP_DIR/newapi.sql" || fail 'new-api tokens 固定查询缺失'
|
||||
if grep -Eqi 'SELECT[^;]*(key|access_token|password)' "$TMP_DIR/newapi.sql"; then
|
||||
fail 'new-api 固定查询选择了敏感凭据'
|
||||
fi
|
||||
grep -q "SIGNER_PRIVATE_KEY='/root/dogfood-r1-signer/r1-ed25519-private.pem'" "$SCRIPT" || \
|
||||
fail '生产 signer 私钥路径不是固定值'
|
||||
grep -q 'realpath.*signer_key' "$SCRIPT" || fail '生产 signer 私钥未校验 canonical 路径'
|
||||
grep -q "stat -c '%u'.*signer_key" "$SCRIPT" || fail '生产 signer 私钥未校验 root 所有权'
|
||||
|
||||
python3 - "$report" <<'PY'
|
||||
import json
|
||||
import sys
|
||||
|
||||
report = json.load(open(sys.argv[1], encoding="utf-8"))
|
||||
assert report["schema"] == "dogfood-account-database-report/2"
|
||||
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 len(report["accounts"]) == 40
|
||||
assert len(report["reserves"]) == 12
|
||||
for item in report["accounts"] + report["reserves"]:
|
||||
assert item["newapiUsername"].startswith("neice_")
|
||||
assert item["tokenStatus"] == "ENABLED"
|
||||
assert item["tokenRemainQuota"] > 0
|
||||
assert item["tokenUnlimitedQuota"] is False
|
||||
assert item["tokenExpiredTime"] == -1
|
||||
serialized = json.dumps(report)
|
||||
for secret_name in ("tokenKey", "password", "accessToken", "refreshToken"):
|
||||
assert secret_name not in serialized
|
||||
PY
|
||||
|
||||
# 精确规模是签发前置条件,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 \
|
||||
--output "$TMP_DIR/incomplete.json" >/dev/null 2>&1; then
|
||||
fail '不完整 40+12 数据仍被签发'
|
||||
fi
|
||||
[ ! -e "$TMP_DIR/incomplete.json" ] && [ ! -e "$TMP_DIR/incomplete.json.sig" ] || fail '失败后遗留半成品'
|
||||
|
||||
# new-api 权威状态异常、ID 关联漂移或一名用户匹配多行时都不得签发。
|
||||
for mutation in disabled zero id-drift multi-row; do
|
||||
case "$mutation" in
|
||||
disabled)
|
||||
awk -F'|' 'BEGIN{OFS="|"} NR==1{$5=0} {print}' "$TMP_DIR/newapi.tsv" >"$TMP_DIR/newapi-$mutation.tsv"
|
||||
;;
|
||||
zero)
|
||||
awk -F'|' 'BEGIN{OFS="|"} NR==1{$8=0} {print}' "$TMP_DIR/newapi.tsv" >"$TMP_DIR/newapi-$mutation.tsv"
|
||||
;;
|
||||
id-drift)
|
||||
awk -F'|' 'BEGIN{OFS="|"} NR==1{$4=999999} {print}' "$TMP_DIR/newapi.tsv" >"$TMP_DIR/newapi-$mutation.tsv"
|
||||
;;
|
||||
multi-row)
|
||||
cp "$TMP_DIR/newapi.tsv" "$TMP_DIR/newapi-$mutation.tsv"
|
||||
head -n 1 "$TMP_DIR/newapi.tsv" >>"$TMP_DIR/newapi-$mutation.tsv"
|
||||
;;
|
||||
esac
|
||||
export DOGFOOD_TEST_NEWAPI_ROWS="$TMP_DIR/newapi-$mutation.tsv"
|
||||
if "$SCRIPT" --activation-run-id activation-20260723T010203Z \
|
||||
--rc-commit 1111111111111111111111111111111111111111 \
|
||||
--output "$TMP_DIR/$mutation.json" >/dev/null 2>&1; then
|
||||
fail "new-api 异常仍被签发 mutation=$mutation"
|
||||
fi
|
||||
[ ! -e "$TMP_DIR/$mutation.json" ] && [ ! -e "$TMP_DIR/$mutation.json.sig" ] || \
|
||||
fail "new-api 异常后遗留半成品 mutation=$mutation"
|
||||
done
|
||||
|
||||
printf 'TEST_PASS 账号报告固定 SSH/MySQL、40+12、签名、权限与脱敏校验通过\n'
|
||||
@ -10,8 +10,11 @@ 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"
|
||||
GENERATION_GATE_RUN_ID="r1-test-run-20260723"
|
||||
GENERATION_ATTESTATION_EXPECTED_ISSUER="r1-independent-signer"
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="/root/game-staging/trust/r1-attestation-ed25519.pub"
|
||||
GENERATION_ATTESTATION_EXPECTED_ISSUER="attacker-controlled-issuer"
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="/tmp/attacker-controlled-public-key.pem"
|
||||
FIXED_GENERATION_ATTESTATION_ISSUER="r1-mini-infra"
|
||||
FIXED_GENERATION_ATTESTATION_PUBLIC_KEY_SHA256="e3cc17cd86805b053f61450b5c2c6e4e168bb01dbf1e83840124290d1196a304"
|
||||
REPOSITORY_GENERATION_ATTESTATION_PUBLIC_KEY="$ROOT_DIR/deploy/trust/r1-attestation-ed25519.pub"
|
||||
TMP_DIR="$(mktemp -d)"
|
||||
SERVER_PID=""
|
||||
|
||||
@ -120,6 +123,12 @@ rg -q 'openssl pkeyutl -verify' "$TMP_DIR/remote-script.sh" || fail "R1 attestat
|
||||
rg -q 'expected_generation_gate_run_id' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 未绑定独立 run-id"
|
||||
rg -q 'expected_attestation_issuer' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 未绑定 issuer"
|
||||
rg -q 'trusted_attestation_public_key_file' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 未绑定信任公钥"
|
||||
rg -Fq 'expected_attestation_issuer="r1-mini-infra"' "$TMP_DIR/remote-script.sh" || fail "R1 issuer 未固定"
|
||||
rg -Fq 'trusted_attestation_public_key_file="/root/game-staging/trust/r1-attestation-ed25519.pub"' \
|
||||
"$TMP_DIR/remote-script.sh" || fail "R1 公钥路径未固定"
|
||||
rg -Fq "trusted_attestation_public_key_sha256=\"$FIXED_GENERATION_ATTESTATION_PUBLIC_KEY_SHA256\"" \
|
||||
"$TMP_DIR/remote-script.sh" || fail "R1 公钥摘要未固定"
|
||||
! rg -q 'attacker-controlled-(issuer|public-key)' "$TMP_DIR/ssh-args.txt" || fail "R1 信任根仍由调用参数传入"
|
||||
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"
|
||||
@ -148,10 +157,12 @@ gate_line="$(rg -n 'verify_generation_gate_evidence' "$TMP_DIR/remote-script.sh"
|
||||
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)"
|
||||
maintenance_install_line="$(rg -n '^install_maintenance_gate$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)"
|
||||
stop_old_line="$(rg -n '^stop_stack .*48080 4173 4174 8300 9501$' "$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 "候选非数据面探针没有在停止旧栈前完成"
|
||||
[ "$maintenance_install_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 "未先完成隔离恢复验证就执行正式迁移"
|
||||
@ -161,7 +172,6 @@ provision_pass_line="$(rg -n 'PROVISION_PASS accounts=40 creators=10 role_mismat
|
||||
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)"
|
||||
maintenance_install_line="$(rg -n '^install_maintenance_gate$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)"
|
||||
stable_start_line="$(rg -n '^start_stack .*48080 4173 4174 8300 9501 true$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)"
|
||||
maintenance_release_line="$(rg -n '^release_maintenance_gate$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)"
|
||||
[ "$maintenance_install_line" -lt "$stable_start_line" ] || fail "正式栈启动前未封锁外部入口"
|
||||
@ -207,7 +217,7 @@ verify_stack_body="$(sed -n '/^verify_stack()/,/^}/p' "$TMP_DIR/remote-script.sh
|
||||
[ "$(printf '%s\n' "$verify_stack_body" | rg -c 'systemctl is-active --quiet')" -eq 5 ] || \
|
||||
fail "回滚复核没有逐项检查五个 systemd 服务"
|
||||
|
||||
# 用有状态假 iptables 执行维护门真实函数,验证安装、复核和解除的完整生命周期。
|
||||
# 用有状态假 iptables 执行维护门真实函数,验证链清理失败时独立护栏仍封锁入口。
|
||||
cat >"$TMP_DIR/fake-iptables" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
set -euo pipefail
|
||||
@ -224,8 +234,21 @@ case "$*" in
|
||||
'-w 5 -C INPUT ! -i lo -j GDA_STAGING_MAINT') [ -f "$state/input-jump" ] ;;
|
||||
'-w 5 -I INPUT 1 ! -i lo -j GDA_STAGING_MAINT') touch "$state/input-jump" ;;
|
||||
'-w 5 -D INPUT ! -i lo -j GDA_STAGING_MAINT') rm -f "$state/input-jump" ;;
|
||||
'-w 5 -F GDA_STAGING_MAINT') rm -f "$state/reject-rule" ;;
|
||||
'-w 5 -X GDA_STAGING_MAINT') rm -f "$state/chain" ;;
|
||||
'-w 5 -nL GDA_STAGING_RELEASE_GUARD') [ -f "$state/release-guard-chain" ] ;;
|
||||
'-w 5 -N GDA_STAGING_RELEASE_GUARD') touch "$state/release-guard-chain" ;;
|
||||
'-w 5 -C GDA_STAGING_RELEASE_GUARD -p tcp -m multiport --dports 48080,4173,4174,8300,9501 -j REJECT')
|
||||
[ -f "$state/release-guard-reject" ] ;;
|
||||
'-w 5 -A GDA_STAGING_RELEASE_GUARD -p tcp -m multiport --dports 48080,4173,4174,8300,9501 -j REJECT')
|
||||
touch "$state/release-guard-reject" ;;
|
||||
'-w 5 -C INPUT ! -i lo -j GDA_STAGING_RELEASE_GUARD') [ -f "$state/release-guard-jump" ] ;;
|
||||
'-w 5 -I INPUT 1 ! -i lo -j GDA_STAGING_RELEASE_GUARD') touch "$state/release-guard-jump" ;;
|
||||
'-w 5 -D INPUT ! -i lo -j GDA_STAGING_RELEASE_GUARD') rm -f "$state/release-guard-jump" ;;
|
||||
'-w 5 -F GDA_STAGING_MAINT')
|
||||
[ ! -f "$state/fail-F" ] || exit 91
|
||||
rm -f "$state/reject-rule" ;;
|
||||
'-w 5 -X GDA_STAGING_MAINT')
|
||||
[ ! -f "$state/fail-X" ] || exit 92
|
||||
rm -f "$state/chain" ;;
|
||||
*) printf '未知 fake iptables 调用:%s\n' "$*" >&2; exit 96 ;;
|
||||
esac
|
||||
SH
|
||||
@ -235,9 +258,11 @@ chmod +x "$TMP_DIR/fake-iptables"
|
||||
printf 'IPTABLES_BIN=%q\n' "$TMP_DIR/fake-iptables"
|
||||
printf 'evidence=%q\n' "$TMP_DIR/maintenance-evidence"
|
||||
printf '%s\n' 'maintenance_chain=GDA_STAGING_MAINT' \
|
||||
'maintenance_release_guard_chain=GDA_STAGING_RELEASE_GUARD' \
|
||||
'maintenance_ports=48080,4173,4174,8300,9501' 'maintenance_gate_active=0'
|
||||
printf '%s\n' 'log_step() { :; }'
|
||||
sed -n '/^maintenance_rule_present()/,/^}/p' "$TMP_DIR/remote-script.sh"
|
||||
sed -n '/^maintenance_release_guard_present()/,/^}/p' "$TMP_DIR/remote-script.sh"
|
||||
sed -n '/^install_maintenance_gate()/,/^}/p' "$TMP_DIR/remote-script.sh"
|
||||
sed -n '/^release_maintenance_gate()/,/^}/p' "$TMP_DIR/remote-script.sh"
|
||||
printf '%s\n' 'mkdir -p "$evidence"' 'install_maintenance_gate' \
|
||||
@ -247,9 +272,81 @@ chmod +x "$TMP_DIR/fake-iptables"
|
||||
bash "$TMP_DIR/maintenance-harness.sh"
|
||||
[ ! -e "$TMP_DIR/iptables-state/chain" ] \
|
||||
&& [ ! -e "$TMP_DIR/iptables-state/reject-rule" ] \
|
||||
&& [ ! -e "$TMP_DIR/iptables-state/input-jump" ] || fail "维护门解除后残留 iptables 状态"
|
||||
rg -q '^maintenance_gate=OPENED ' "$TMP_DIR/maintenance-evidence/maintenance-gate.status" || \
|
||||
fail "维护门未留下 OPENED 终态证据"
|
||||
&& [ ! -e "$TMP_DIR/iptables-state/input-jump" ] \
|
||||
&& [ ! -e "$TMP_DIR/iptables-state/release-guard-jump" ] \
|
||||
&& [ -e "$TMP_DIR/iptables-state/release-guard-chain" ] \
|
||||
&& [ -e "$TMP_DIR/iptables-state/release-guard-reject" ] || fail "维护门解除后的 guard 状态非法"
|
||||
rg -q '^maintenance_gate=OPENING ' "$TMP_DIR/maintenance-evidence/maintenance-gate.status" || \
|
||||
fail "维护门未留下最终开放提交前证据"
|
||||
tail -1 "$TMP_DIR/iptables-state/calls.log" | rg -Fq \
|
||||
-- '-D INPUT ! -i lo -j GDA_STAGING_RELEASE_GUARD' || \
|
||||
fail "最终开放不是最后一条 iptables 命令"
|
||||
|
||||
for failed_operation in F X; do
|
||||
rm -rf "$TMP_DIR/iptables-state" "$TMP_DIR/maintenance-evidence"
|
||||
{
|
||||
sed -n '1,/^mkdir -p "\$evidence"/p' "$TMP_DIR/maintenance-harness.sh"
|
||||
printf '%s\n' 'install_maintenance_gate'
|
||||
printf 'touch %q\n' "$TMP_DIR/iptables-state/fail-$failed_operation"
|
||||
printf '%s\n' 'set +e' 'release_maintenance_gate' 'release_rc=$?' 'set -e' \
|
||||
'[ "$release_rc" -ne 0 ]' '[ "$maintenance_gate_active" -eq 1 ]' \
|
||||
'maintenance_release_guard_present'
|
||||
} >"$TMP_DIR/maintenance-fail-$failed_operation.sh"
|
||||
bash "$TMP_DIR/maintenance-fail-$failed_operation.sh" || fail "-$failed_operation 失败后维护门未保持封锁"
|
||||
done
|
||||
|
||||
# 执行真实 stop_stack:服务停止失败、unit 残留或端口残留均必须阻止快照继续。
|
||||
stop_stack_body="$(sed -n '/^stop_stack()/,/^}/p' "$TMP_DIR/remote-script.sh")"
|
||||
{
|
||||
printf '%s\n' 'set -euo pipefail' 'state="${RC_TEST_TMP:?}/stop-stack-state"' 'mkdir -p "$state"'
|
||||
printf '%s\n' 'log_step() { :; }' 'unit_name() { printf "%s-%s" "$1" "$2"; }'
|
||||
cat <<'SH'
|
||||
systemctl() {
|
||||
local action="$1"
|
||||
shift
|
||||
if [ "${1:-}" = "--quiet" ]; then shift; fi
|
||||
local unit="${1:-}"
|
||||
case "$action" in
|
||||
is-active) [ -f "$state/unit-$unit" ] ;;
|
||||
stop)
|
||||
[ "$unit" != "${STOP_FAIL_UNIT:-}" ] || return 93
|
||||
rm -f "$state/unit-$unit"
|
||||
;;
|
||||
*) return 94 ;;
|
||||
esac
|
||||
}
|
||||
port_listening() {
|
||||
[ -f "$state/port-$1" ]
|
||||
}
|
||||
SH
|
||||
printf '%s\n' "$stop_stack_body"
|
||||
cat <<'SH'
|
||||
for service in backend studio admin cheap-agent cheap-worker; do
|
||||
touch "$state/unit-gda-staging-$service"
|
||||
done
|
||||
stop_stack gda-staging 48080 4173 4174 8300 9501
|
||||
for service in backend studio admin cheap-agent cheap-worker; do
|
||||
[ ! -e "$state/unit-gda-staging-$service" ]
|
||||
done
|
||||
|
||||
touch "$state/unit-gda-staging-backend"
|
||||
set +e
|
||||
STOP_FAIL_UNIT=gda-staging-backend stop_stack gda-staging 48080 4173 4174 8300 9501
|
||||
stop_rc=$?
|
||||
set -e
|
||||
[ "$stop_rc" -ne 0 ]
|
||||
[ -e "$state/unit-gda-staging-backend" ]
|
||||
|
||||
rm -f "$state"/unit-*
|
||||
touch "$state/port-48080"
|
||||
set +e
|
||||
stop_stack gda-staging 48080 4173 4174 8300 9501
|
||||
port_rc=$?
|
||||
set -e
|
||||
[ "$port_rc" -ne 0 ]
|
||||
SH
|
||||
} >"$TMP_DIR/stop-stack-harness.sh"
|
||||
bash "$TMP_DIR/stop-stack-harness.sh" || fail "stop_stack 未严格传播服务或端口残留"
|
||||
|
||||
# 在关闭 errexit 的回滚环境中执行真实 start_stack,首个启动失败必须立即返回且不得继续探活。
|
||||
{
|
||||
@ -326,6 +423,8 @@ rg -q 'AdminNewapiQuotaControllerTest' "$PREPARE" || fail "prepare 未运行配
|
||||
rg -q 'NewapiQuotaClaimConsumerTest' "$PREPARE" || fail "prepare 未运行配额 MQ 消费测试"
|
||||
rg -q 'NewapiQuotaServiceImplTest' "$PREPARE" || fail "prepare 未运行配额服务测试"
|
||||
rg -q 'GameVersionServiceImplTest' "$PREPARE" || fail "prepare 未运行版本产物绑定测试"
|
||||
rg -q 'ProjectVersionApiImplDogfoodTest' "$PREPARE" || fail "prepare 未运行 project RPC 写边界测试"
|
||||
rg -q 'FeedApiImplDogfoodTest' "$PREPARE" || fail "prepare 未运行 feed RPC 写边界测试"
|
||||
rg -q 'DifyCallbackServiceImplTest' "$PREPARE" || fail "prepare 未运行生成回调产物绑定测试"
|
||||
rg -q 'PublishOrchestrationServiceImplTest' "$PREPARE" || fail "prepare 未运行发布编排测试"
|
||||
rg -q 'test-dogfood-reviewer-security\.py' "$PREPARE" || fail "prepare 未运行审核账号安全测试"
|
||||
@ -363,21 +462,68 @@ tool_test_line="$(rg -n '运行发布工具回归测试' "$PREPARE" | cut -d: -f
|
||||
rg -q 'cheap-worker/\.venv/bin.*PATH' "$PREPARE" || fail "发布工具回归未显式使用 commit 内 Python"
|
||||
rg -q '原子切换 active' "$PREPARE" || fail "prepare 完成提示仍未使用 active 指针"
|
||||
|
||||
# 独立签发者用仓外私钥签名;发布机只持有信任公钥,签名、身份、run-id 和有效期任一漂移均拒绝。
|
||||
# 独立签发者用仓外私钥签名;测试 SSH 桩只把固定生产路径映射到临时目录。
|
||||
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 "$@"
|
||||
shift
|
||||
[ "${1:-}" = -s ] || exit 97
|
||||
shift
|
||||
[ "${1:-}" = -- ] || exit 97
|
||||
shift
|
||||
remote_script="$(mktemp)"
|
||||
trap 'rm -f "$remote_script"' EXIT
|
||||
sed "s|/root/game-staging/trust/r1-attestation-ed25519.pub|$RC_TEST_TRUSTED_PUBLIC_KEY|g" \
|
||||
>"$remote_script"
|
||||
PATH="$RC_TEST_FAKE_BIN:$PATH" bash "$remote_script" "$@"
|
||||
SH
|
||||
chmod +x "$TMP_DIR/exec-ssh"
|
||||
|
||||
mkdir -p "$TMP_DIR/r1-fake-bin"
|
||||
cat >"$TMP_DIR/r1-fake-bin/stat" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
if [ "${1:-}" = -c ] && [ "${2:-}" = %u ]; then
|
||||
printf '%s\n' "${RC_TEST_FORCE_BAD_TRUST_OWNER:-0}"
|
||||
exit 0
|
||||
fi
|
||||
exec /usr/bin/stat "$@"
|
||||
SH
|
||||
cat >"$TMP_DIR/r1-fake-bin/openssl" <<'SH'
|
||||
#!/usr/bin/env bash
|
||||
if [ "${1:-}" = pkeyutl ] && printf '%s\n' "$*" | rg -q -- ' -verify( |$)'; then
|
||||
signature=""
|
||||
while [ "$#" -gt 0 ]; do
|
||||
if [ "$1" = -sigfile ]; then signature="$2"; break; fi
|
||||
shift
|
||||
done
|
||||
[ -f "$signature" ] && [ "$(wc -c <"$signature" | tr -d ' ')" -eq 64 ]
|
||||
exit
|
||||
fi
|
||||
exec /usr/bin/openssl "$@"
|
||||
SH
|
||||
chmod +x "$TMP_DIR/r1-fake-bin/stat" "$TMP_DIR/r1-fake-bin/openssl"
|
||||
export RC_TEST_FAKE_BIN="$TMP_DIR/r1-fake-bin"
|
||||
|
||||
attestation_root="$(realpath "$TMP_DIR")/attestations"
|
||||
mkdir -p "$attestation_root/r1"
|
||||
openssl genpkey -algorithm ED25519 -out "$TMP_DIR/r1-private.pem" >/dev/null 2>&1
|
||||
openssl pkey -in "$TMP_DIR/r1-private.pem" -pubout -out "$TMP_DIR/r1-public.pem" >/dev/null 2>&1
|
||||
chmod 600 "$TMP_DIR/r1-private.pem" "$TMP_DIR/r1-public.pem"
|
||||
trusted_public_key="$(realpath "$TMP_DIR/r1-public.pem")"
|
||||
chmod 600 "$TMP_DIR/r1-private.pem"
|
||||
RC_ROOT="$(realpath "$TMP_DIR")/r1-releases"
|
||||
trusted_public_key="$(realpath "$TMP_DIR")/r1-trust/r1-attestation-ed25519.pub"
|
||||
mkdir -p "$(dirname "$trusted_public_key")"
|
||||
cp "$REPOSITORY_GENERATION_ATTESTATION_PUBLIC_KEY" "$trusted_public_key"
|
||||
chmod 755 "$(dirname "$trusted_public_key")"
|
||||
chmod 644 "$trusted_public_key"
|
||||
export RC_ROOT RC_TEST_TRUSTED_PUBLIC_KEY="$trusted_public_key"
|
||||
for fixture_commit in 0000000000000000000000000000000000000000 1111111111111111111111111111111111111111; do
|
||||
mkdir -p "$RC_ROOT/$fixture_commit/deploy/trust"
|
||||
cp "$REPOSITORY_GENERATION_ATTESTATION_PUBLIC_KEY" \
|
||||
"$RC_ROOT/$fixture_commit/deploy/trust/r1-attestation-ed25519.pub"
|
||||
done
|
||||
[ "$(sha256sum "$trusted_public_key" | awk '{print $1}')" = "$FIXED_GENERATION_ATTESTATION_PUBLIC_KEY_SHA256" ] \
|
||||
|| fail "仓内 R1 公钥摘要与固定值不一致"
|
||||
read -r issued_at expires_at expired_issued_at expired_expires_at < <(python3 - <<'PY'
|
||||
from datetime import datetime, timedelta, timezone
|
||||
now = datetime.now(timezone.utc).replace(microsecond=0)
|
||||
@ -393,7 +539,7 @@ cat >"$attestation_file" <<EOF
|
||||
schema=generation-r1-gate/2
|
||||
rc_commit=0000000000000000000000000000000000000000
|
||||
run_id=$GENERATION_GATE_RUN_ID
|
||||
issuer=$GENERATION_ATTESTATION_EXPECTED_ISSUER
|
||||
issuer=$FIXED_GENERATION_ATTESTATION_ISSUER
|
||||
issued_at=$issued_at
|
||||
expires_at=$expires_at
|
||||
r1_status=PASS
|
||||
@ -417,21 +563,61 @@ chmod 600 "$attestation_file.sig"
|
||||
attestation_ref="sha256:$attestation_hash:r1/$attestation_hash.attestation"
|
||||
RC_COMMIT=0000000000000000000000000000000000000000 \
|
||||
GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >"$TMP_DIR/attestation-pass.log"
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >"$TMP_DIR/attestation-pass.log" 2>&1 || {
|
||||
sed -n '1,120p' "$TMP_DIR/attestation-pass.log" >&2
|
||||
fail "固定信任根的独立 attestation 正例失败"
|
||||
}
|
||||
rg -q 'GENERATION_ATTESTATION_PASS' "$TMP_DIR/attestation-pass.log" || fail "独立 attestation 未通过"
|
||||
|
||||
# 调用者提供的 issuer 与公钥路径必须被忽略,不能替换固定信任根。
|
||||
RC_COMMIT=0000000000000000000000000000000000000000 \
|
||||
GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \
|
||||
GENERATION_ATTESTATION_EXPECTED_ISSUER=other-issuer \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$TMP_DIR/attacker.pem" \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null
|
||||
|
||||
cp "$trusted_public_key" "$TMP_DIR/trusted-public-key.backup"
|
||||
printf 'tampered\n' >"$trusted_public_key"
|
||||
if RC_COMMIT=0000000000000000000000000000000000000000 \
|
||||
GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$TMP_DIR/missing-public.pem" \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then
|
||||
fail "缺信任公钥仍允许 attestation"
|
||||
fail "固定公钥内容篡改后仍允许 attestation"
|
||||
fi
|
||||
mv "$TMP_DIR/trusted-public-key.backup" "$trusted_public_key"
|
||||
chmod 644 "$trusted_public_key"
|
||||
|
||||
release_public_key="$RC_ROOT/0000000000000000000000000000000000000000/deploy/trust/r1-attestation-ed25519.pub"
|
||||
printf '\n' >>"$release_public_key"
|
||||
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 "远端公钥与 RC 仓内公钥不一致时仍允许 attestation"
|
||||
fi
|
||||
cp "$REPOSITORY_GENERATION_ATTESTATION_PUBLIC_KEY" "$release_public_key"
|
||||
|
||||
chmod 600 "$trusted_public_key"
|
||||
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 644 "$trusted_public_key"
|
||||
chmod 777 "$(dirname "$trusted_public_key")"
|
||||
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 755 "$(dirname "$trusted_public_key")"
|
||||
if RC_TEST_FORCE_BAD_TRUST_OWNER=1000 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 "固定公钥 owner 不是 root 时仍允许 attestation"
|
||||
fi
|
||||
|
||||
mv "$attestation_file.sig" "$attestation_file.sig.missing"
|
||||
if RC_COMMIT=0000000000000000000000000000000000000000 \
|
||||
GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then
|
||||
fail "缺 detached signature 仍允许 attestation"
|
||||
fi
|
||||
@ -441,7 +627,6 @@ cp "$attestation_file.sig" "$TMP_DIR/valid-attestation.sig"
|
||||
printf 'x' >>"$attestation_file.sig"
|
||||
if RC_COMMIT=0000000000000000000000000000000000000000 \
|
||||
GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then
|
||||
fail "篡改 detached signature 仍允许 attestation"
|
||||
fi
|
||||
@ -449,22 +634,13 @@ mv "$TMP_DIR/valid-attestation.sig" "$attestation_file.sig"
|
||||
|
||||
if RC_COMMIT=0000000000000000000000000000000000000000 \
|
||||
GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \
|
||||
GENERATION_GATE_RUN_ID=other-run GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \
|
||||
GENERATION_GATE_RUN_ID=other-run \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then
|
||||
fail "run-id 漂移 attestation 未拒绝"
|
||||
fi
|
||||
if RC_COMMIT=0000000000000000000000000000000000000000 \
|
||||
GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \
|
||||
GENERATION_ATTESTATION_EXPECTED_ISSUER=other-issuer \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then
|
||||
fail "issuer 漂移 attestation 未拒绝"
|
||||
fi
|
||||
|
||||
chmod 644 "$attestation_file"
|
||||
if RC_COMMIT=0000000000000000000000000000000000000000 \
|
||||
GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then
|
||||
fail "权限过宽 attestation 未拒绝"
|
||||
fi
|
||||
@ -473,13 +649,11 @@ 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" \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" SSH_BIN="$TMP_DIR/exec-ssh" \
|
||||
bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then
|
||||
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" \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then
|
||||
fail "rc_commit 漂移 attestation 未拒绝"
|
||||
fi
|
||||
@ -497,7 +671,6 @@ chmod 600 "$expired_file.sig"
|
||||
if RC_COMMIT=0000000000000000000000000000000000000000 \
|
||||
GENERATION_GATE_EVIDENCE_REF="sha256:$expired_hash:r1/$expired_hash.attestation" \
|
||||
GENERATION_ATTESTATION_ROOT="$attestation_root" \
|
||||
GENERATION_ATTESTATION_TRUSTED_PUBLIC_KEY_FILE="$trusted_public_key" \
|
||||
SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then
|
||||
fail "已过期 attestation 未拒绝"
|
||||
fi
|
||||
|
||||
3
deploy/trust/r1-attestation-ed25519.pub
Normal file
3
deploy/trust/r1-attestation-ed25519.pub
Normal file
@ -0,0 +1,3 @@
|
||||
-----BEGIN PUBLIC KEY-----
|
||||
MCowBQYDK2VwAyEApuq2r6Rth2iP0MoBStpEFPKHWZV1rCYq14zlRFLtsBU=
|
||||
-----END PUBLIC KEY-----
|
||||
@ -35,6 +35,8 @@ public class ProjectVersionApiImpl implements ProjectVersionApi {
|
||||
|
||||
@Override
|
||||
public CommonResult<Long> createForPackage(ProjectVersionCreateForPackageReqDTO req) {
|
||||
// 写类 RPC 统一封锁外部 HTTP 入口,普通玩家令牌不能跨项目创建版本或改写当前版本指针。
|
||||
rejectExternalRpcWrite("project.rpc.create-for-package");
|
||||
// 落包建版本:委托 GameVersionService 创建/复用 game_version(status=2 预览就绪),按 genTaskId 幂等
|
||||
return success(gameVersionService.createForPackage(req));
|
||||
}
|
||||
|
||||
@ -97,13 +97,26 @@ public class GameVersionServiceImpl implements GameVersionService {
|
||||
throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED);
|
||||
}
|
||||
|
||||
// 锁定版本和项目,保证状态、归属、当前版本指针与摘要写入在同一事务快照内完成。
|
||||
GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId());
|
||||
if (version == null || version.getGameId() == null) {
|
||||
// 先无锁读取版本归属,只用于确定项目锁;所有业务事实必须在取得 project -> version 两把锁后重新校验。
|
||||
GameVersionDO observedVersion = gameVersionMapper.selectById(req.getVersionId());
|
||||
if (observedVersion == null || observedVersion.getGameId() == null) {
|
||||
log.warn("[bindRuntimeArtifact] 目标版本不存在或缺少项目归属 versionId={}", req.getVersionId());
|
||||
throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED);
|
||||
}
|
||||
|
||||
// 与审核发布统一 project -> version 锁序,避免两个事务互相持有对方所需行而死锁。
|
||||
ProjectDO project = projectMapper.selectByIdForUpdate(observedVersion.getGameId());
|
||||
GameVersionDO version = gameVersionMapper.selectByIdForUpdate(req.getVersionId());
|
||||
if (project == null || version == null || version.getGameId() == null
|
||||
|| !Objects.equals(observedVersion.getGameId(), version.getGameId())
|
||||
|| !Objects.equals(project.getId(), version.getGameId())
|
||||
|| !Objects.equals(project.getCurrentVersionId(), version.getId())) {
|
||||
log.warn("[bindRuntimeArtifact] 持锁后版本归属或项目指针漂移 versionId={}, observedGameId={}, lockedGameId={}, currentVersionId={}",
|
||||
req.getVersionId(), observedVersion.getGameId(), version == null ? null : version.getGameId(),
|
||||
project == null ? null : project.getCurrentVersionId());
|
||||
throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED);
|
||||
}
|
||||
|
||||
// 已发布版本只允许同一份产物幂等重放;任何改写都拒绝,避免审核后摘要被覆盖。
|
||||
boolean sameArtifact = Objects.equals(version.getChecksum(), req.getChecksum())
|
||||
&& Objects.equals(version.getBundleSize(), req.getBundleSize());
|
||||
@ -117,13 +130,6 @@ public class GameVersionServiceImpl implements GameVersionService {
|
||||
throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED);
|
||||
}
|
||||
|
||||
ProjectDO project = projectMapper.selectByIdForUpdate(version.getGameId());
|
||||
if (project == null || !Objects.equals(project.getCurrentVersionId(), version.getId())) {
|
||||
log.warn("[bindRuntimeArtifact] 版本与项目当前指针不一致 versionId={}, gameId={}, currentVersionId={}",
|
||||
version.getId(), version.getGameId(), project == null ? null : project.getCurrentVersionId());
|
||||
throw exception(PROJECT_VERSION_ARTIFACT_BIND_FAILED);
|
||||
}
|
||||
|
||||
Integer runtimeStatus;
|
||||
try {
|
||||
runtimeStatus = runtimePackageApi.getStatus(version.getId()).getCheckedData();
|
||||
|
||||
@ -2,6 +2,7 @@ package com.wanxiang.huijing.game.module.project.api;
|
||||
|
||||
import com.wanxiang.huijing.framework.common.exception.ServiceException;
|
||||
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionBindArtifactReqDTO;
|
||||
import com.wanxiang.huijing.game.module.project.dto.ProjectVersionCreateForPackageReqDTO;
|
||||
import com.wanxiang.huijing.game.module.project.service.version.GameVersionService;
|
||||
import org.junit.jupiter.api.AfterEach;
|
||||
import org.junit.jupiter.api.BeforeEach;
|
||||
@ -33,10 +34,22 @@ class ProjectVersionApiImplDogfoodTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindRuntimeArtifact_rejectsExternalRpcBeforeService() {
|
||||
void createForPackage_rejectsOrdinaryPlayerExternalRpcBeforeService() {
|
||||
ProjectVersionApiImpl api = createApi();
|
||||
ProjectVersionCreateForPackageReqDTO req = new ProjectVersionCreateForPackageReqDTO();
|
||||
req.setGameId(1024L);
|
||||
bindOrdinaryPlayerRequest("/rpc-api/project/version/create-for-package");
|
||||
|
||||
assertThrows(ServiceException.class, () -> api.createForPackage(req));
|
||||
|
||||
verifyNoInteractions(gameVersionService);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindRuntimeArtifact_rejectsOrdinaryPlayerExternalRpcBeforeService() {
|
||||
ProjectVersionApiImpl api = createApi();
|
||||
ProjectVersionBindArtifactReqDTO req = artifactReq();
|
||||
bindRequest("/rpc-api/project/version/bind-runtime-artifact");
|
||||
bindOrdinaryPlayerRequest("/rpc-api/project/version/bind-runtime-artifact");
|
||||
|
||||
assertThrows(ServiceException.class, () -> api.bindRuntimeArtifact(req));
|
||||
|
||||
@ -65,6 +78,13 @@ class ProjectVersionApiImplDogfoodTest {
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
}
|
||||
|
||||
/** 模拟普通玩家携带自己的访问令牌直接命中内部 RPC HTTP 路径。 */
|
||||
private static void bindOrdinaryPlayerRequest(String requestUri) {
|
||||
MockHttpServletRequest request = new MockHttpServletRequest("POST", requestUri);
|
||||
request.addHeader("Authorization", "Bearer ordinary-player-token");
|
||||
RequestContextHolder.setRequestAttributes(new ServletRequestAttributes(request));
|
||||
}
|
||||
|
||||
/** 构造 callback 已完成 runtime 落包后的合法绑定入参。 */
|
||||
private static ProjectVersionBindArtifactReqDTO artifactReq() {
|
||||
ProjectVersionBindArtifactReqDTO req = new ProjectVersionBindArtifactReqDTO();
|
||||
|
||||
@ -12,6 +12,7 @@ import com.wanxiang.huijing.game.module.runtime.enums.PackageStatusEnum;
|
||||
import org.junit.jupiter.api.Test;
|
||||
import org.mockito.ArgumentCaptor;
|
||||
import org.mockito.InjectMocks;
|
||||
import org.mockito.InOrder;
|
||||
import org.mockito.Mock;
|
||||
|
||||
import static com.wanxiang.huijing.game.module.project.enums.ErrorCodeConstants.PROJECT_VERSION_ARTIFACT_BIND_FAILED;
|
||||
@ -59,6 +60,40 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest {
|
||||
assertEquals(4096L, captor.getValue().getBundleSize());
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindRuntimeArtifact_usesSameProjectThenVersionLockOrderAsApproval() {
|
||||
ProjectVersionBindArtifactReqDTO req = artifactReq("a".repeat(64));
|
||||
prepareBindableVersion();
|
||||
when(gameVersionMapper.updateById(any(GameVersionDO.class))).thenReturn(1);
|
||||
|
||||
gameVersionService.bindRuntimeArtifact(req);
|
||||
|
||||
InOrder lockOrder = org.mockito.Mockito.inOrder(gameVersionMapper, projectMapper);
|
||||
lockOrder.verify(gameVersionMapper).selectById(2048L);
|
||||
lockOrder.verify(projectMapper).selectByIdForUpdate(1024L);
|
||||
lockOrder.verify(gameVersionMapper).selectByIdForUpdate(2048L);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindRuntimeArtifact_rejectsVersionProjectDriftAfterProjectLock() {
|
||||
GameVersionDO observed = version(2, "", 0L);
|
||||
GameVersionDO drifted = version(2, "", 0L);
|
||||
drifted.setGameId(2049L);
|
||||
when(gameVersionMapper.selectById(2048L)).thenReturn(observed);
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(1024L);
|
||||
project.setCurrentVersionId(2048L);
|
||||
when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project);
|
||||
when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(drifted);
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> gameVersionService.bindRuntimeArtifact(artifactReq("a".repeat(64))));
|
||||
|
||||
assertEquals(PROJECT_VERSION_ARTIFACT_BIND_FAILED.getCode(), error.getCode());
|
||||
verify(gameVersionMapper, never()).updateById(any(GameVersionDO.class));
|
||||
verifyNoInteractions(runtimePackageApi);
|
||||
}
|
||||
|
||||
@Test
|
||||
void bindRuntimeArtifact_rejectsInvalidChecksumWithoutWriting() {
|
||||
ProjectVersionBindArtifactReqDTO req = artifactReq("ABC");
|
||||
@ -73,7 +108,7 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void bindRuntimeArtifact_rejectsMissingVersion() {
|
||||
ProjectVersionBindArtifactReqDTO req = artifactReq("b".repeat(64));
|
||||
when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(null);
|
||||
when(gameVersionMapper.selectById(2048L)).thenReturn(null);
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
() -> gameVersionService.bindRuntimeArtifact(req));
|
||||
@ -86,6 +121,11 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void bindRuntimeArtifact_rejectsPublishedVersionWithDifferentArtifact() {
|
||||
GameVersionDO version = version(3, "c".repeat(64), 4096L);
|
||||
when(gameVersionMapper.selectById(2048L)).thenReturn(version);
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(1024L);
|
||||
project.setCurrentVersionId(2048L);
|
||||
when(projectMapper.selectByIdForUpdate(1024L)).thenReturn(project);
|
||||
when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version);
|
||||
|
||||
ServiceException error = assertThrows(ServiceException.class,
|
||||
@ -97,7 +137,9 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
@Test
|
||||
void bindRuntimeArtifact_rejectsVersionWhoseProjectDoesNotPointToIt() {
|
||||
when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version(2, "", 0L));
|
||||
GameVersionDO version = version(2, "", 0L);
|
||||
when(gameVersionMapper.selectById(2048L)).thenReturn(version);
|
||||
when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version);
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(1024L);
|
||||
project.setCurrentVersionId(9999L);
|
||||
@ -138,6 +180,7 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest {
|
||||
@Test
|
||||
void bindRuntimeArtifact_sameArtifactIsIdempotent() {
|
||||
GameVersionDO version = version(2, "a".repeat(64), 4096L);
|
||||
when(gameVersionMapper.selectById(2048L)).thenReturn(version);
|
||||
when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version);
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(1024L);
|
||||
@ -161,7 +204,9 @@ class GameVersionServiceImplTest extends BaseMockitoUnitTest {
|
||||
|
||||
/** 准备版本、项目、runtime 三方都指向同一待绑定版本的正常场景。 */
|
||||
private void prepareBindableVersion() {
|
||||
when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version(2, "", 0L));
|
||||
GameVersionDO version = version(2, "", 0L);
|
||||
when(gameVersionMapper.selectById(2048L)).thenReturn(version);
|
||||
when(gameVersionMapper.selectByIdForUpdate(2048L)).thenReturn(version);
|
||||
ProjectDO project = new ProjectDO();
|
||||
project.setId(1024L);
|
||||
project.setCurrentVersionId(2048L);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user