games-development-ai/deploy/collect-dogfood-account-report.sh

278 lines
12 KiB
Bash
Executable File
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

#!/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"