games-development-ai/deploy/collect-dogfood-account-report.sh
lili bcb7fec906
Some checks failed
contract-gates / contract-gates (push) Has been cancelled
docs-gate / docs-gate (push) Has been cancelled
fix(release): bind dogfood gates to runtime state
2026-07-23 02:49:08 -07:00

366 lines
16 KiB
Bash
Executable File

#!/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 STAGING_RELEASES_ROOT='/root/game-staging/releases'
readonly REPORT_SCHEMA='dogfood-account-database-report/2'
readonly QUERY_ID='dogfood-account-readiness-v2'
readonly QUERY_SCHEMA='dogfood-account-readiness-row/2'
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
# 测试钩子只替换进程、私钥和本地 fixture 根;生产 SSH 目标、issuer 与报告 schema 仍固定。
ssh_bin="${DOGFOOD_COLLECTOR_TEST_SSH_BIN:?}"
signer_key="${DOGFOOD_COLLECTOR_TEST_SIGNER_KEY:?}"
openssl_bin="${DOGFOOD_COLLECTOR_TEST_OPENSSL_BIN:?}"
docker_bin="${DOGFOOD_COLLECTOR_TEST_DOCKER_BIN:?}"
runtime_releases_root="${DOGFOOD_COLLECTOR_TEST_RELEASES_ROOT:?}"
else
[ "$(id -u)" = 0 ] || die '采集器必须以 mini-infra root signer 身份运行'
[ "$(hostname -s)" = "$EXPECTED_SIGNER_HOST" ] || die '采集器只能在固定 mini-infra signer 主机运行'
ssh_bin='/usr/bin/ssh'
signer_key="$SIGNER_PRIVATE_KEY"
openssl_bin='/usr/bin/openssl'
docker_bin='/usr/bin/docker'
runtime_releases_root="$STAGING_RELEASES_ROOT"
fi
[ -x "$ssh_bin" ] || die '固定 SSH 客户端不可执行'
[ -x "$openssl_bin" ] || die 'OpenSSL 客户端不可执行'
[ -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_runtime_state_before="$output_dir/.account-report-runtime-before.$$"
tmp_runtime_state_after="$output_dir/.account-report-runtime-after.$$"
tmp_report="$output_dir/.account-report.$$"
tmp_signature="$output_dir/.account-report-signature.$$"
trap 'rm -f "$tmp_rows" "$tmp_newapi_rows" "$tmp_runtime_state_before" "$tmp_runtime_state_after" "$tmp_report" "$tmp_signature"' EXIT
collect_runtime_state() {
local destination="$1" quoted_root remote_command
printf -v quoted_root '%q' "$runtime_releases_root"
remote_command="$(cat <<EOF
set -euo pipefail
releases_root=$quoted_root
active_pointer="\$releases_root/active"
# active 必须是指向固定 releases 根下 40 位 commit 目录的符号链接。
[ -L "\$active_pointer" ]
canonical_root="\$(readlink -f -- "\$releases_root")"
[ "\$canonical_root" = "\$releases_root" ]
release="\$(readlink -f -- "\$active_pointer")"
[ -d "\$release" ]
commit="\${release##*/}"
printf '%s' "\$commit" | grep -Eq '^[0-9a-f]{40}\$'
[ "\$release" = "\$releases_root/\$commit" ]
# RC_STATUS 只能是一条 active 终态,并且 commit、evidence 都必须回指当前 canonical release。
status_file="\$release/RC_STATUS"
[ -f "\$status_file" ] && [ ! -L "\$status_file" ]
[ "\$(awk 'NF { count++ } END { print count + 0 }' "\$status_file")" -eq 1 ]
IFS= read -r status_line <"\$status_file"
set -- \$status_line
[ "\$#" -eq 4 ] && [ "\$1" = active ]
status_commit="\${2#commit=}"
completed_at="\${3#at=}"
evidence="\${4#evidence=}"
[ "\$2" = "commit=\$status_commit" ] && [ "\$status_commit" = "\$commit" ]
[ "\$3" = "at=\$completed_at" ]
printf '%s' "\$completed_at" | grep -Eq '^[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\$'
[ "\$4" = "evidence=\$evidence" ]
evidence_name="\${evidence##*/}"
run_id="\${evidence_name#activation-}"
[ "\$evidence_name" = "activation-\$run_id" ]
printf '%s' "\$run_id" | grep -Eq '^[0-9]{8}T[0-9]{6}Z\$'
[ "\${evidence%/*}" = "\$release/evidence" ]
[ -d "\$evidence" ] && [ ! -L "\$evidence" ]
[ "\$(readlink -f -- "\$evidence")" = "\$evidence" ]
# activation.log 最后两个非空记录必须是该 release 的 PASS 与同一 evidence 回执。
activation_log="\$evidence/activation.log"
[ -f "\$activation_log" ] && [ ! -L "\$activation_log" ]
terminal_lines="\$(awk 'NF { previous=last; last=\$0 } END { print previous; print last }' "\$activation_log")"
pass_line="\$(printf '%s\n' "\$terminal_lines" | sed -n '1p')"
evidence_line="\$(printf '%s\n' "\$terminal_lines" | sed -n '2p')"
printf '%s\n' "\$pass_line" | grep -Eq '^\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\] '
printf '%s\n' "\$evidence_line" | grep -Eq '^\[[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}Z\] '
[ "\${pass_line#*] }" = "RC_ACTIVATION_PASS commit=\$commit active=\$release" ]
[ "\${evidence_line#*] }" = "ACTIVATION_EVIDENCE=\$evidence" ]
printf 'DOGFOOD_RUNTIME_STATE_V1\t%s\t%s\t%s\t%s\t%s\n' \
"\$commit" "\$run_id" "\$completed_at" "\$release" "\$evidence"
EOF
)"
"$ssh_bin" \
-o BatchMode=yes -o ConnectTimeout=10 -o ConnectionAttempts=1 -o StrictHostKeyChecking=yes \
"$SSH_TARGET" "$remote_command" >"$destination" || die '固定 staging active/RC_STATUS/activation evidence 校验失败'
chmod 600 "$destination"
}
collect_runtime_state "$tmp_runtime_state_before"
if [ "$(awk 'END { print NR + 0 }' "$tmp_runtime_state_before")" -ne 1 ]; then
die 'staging 运行态观测输出行数非法'
fi
IFS=$'\t' read -r state_schema observed_rc_commit observed_activation_run_id \
observed_completed_at observed_release observed_evidence <"$tmp_runtime_state_before"
[ "$state_schema" = DOGFOOD_RUNTIME_STATE_V1 ] || die 'staging 运行态观测 schema 非法'
[ "$observed_rc_commit" = "$rc_commit" ] || die 'expected rcCommit 与 staging active commit 漂移'
[ "$observed_activation_run_id" = "$activation_run_id" ] || die 'expected activation run-id 与 staging 事实漂移'
remote_mysql_command='set -euo pipefail; . /root/huijing-dev.env; : "${MYSQL_ROOT_PASSWORD:?}"; docker exec -i game-staging-mysql sh -lc '\''MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B ruoyi-vue-pro'\'''
printf '%s\n' "$FIXED_QUERY" | "$ssh_bin" \
-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"
collect_runtime_state "$tmp_runtime_state_after"
cmp -s "$tmp_runtime_state_before" "$tmp_runtime_state_after" || \
die '账号采集期间 staging active/activation evidence 发生漂移'
captured_at="$(date -u '+%Y-%m-%dT%H:%M:%SZ')"
python3 - "$tmp_rows" "$tmp_newapi_rows" "$tmp_report" "$observed_activation_run_id" \
"$observed_rc_commit" "$captured_at" "$REPORT_SCHEMA" "$SIGNER_ISSUER" "$QUERY_ID" "$QUERY_SCHEMA" \
"$observed_completed_at" "$observed_release" "$observed_evidence" <<'PY'
import json
import re
import sys
from pathlib import Path
rows_path, newapi_rows_path, report_path = map(Path, sys.argv[1:4])
(
activation_run_id, rc_commit, captured_at, schema, issuer, query_id, query_schema,
activation_completed_at, active_release, activation_evidence,
) = sys.argv[4:]
expected_roles = {
**{f"dogfoodc{index:02d}": "creator" for index in range(1, 11)},
**{f"dogfoodp{index:02d}": "player" for index in range(1, 31)},
}
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,
"activationStatus": "PASS",
"activationCompletedAt": activation_completed_at,
"activeReleaseRef": active_release,
"activationEvidenceRef": activation_evidence,
"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"