209 lines
9.0 KiB
Bash
Executable File
209 lines
9.0 KiB
Bash
Executable File
#!/usr/bin/env bash
|
||
# 在 mini-infra 校验离线 emit SQL,并通过 Tailscale 受控导入 mini-desktop staging MySQL。
|
||
# SQL 仅经 stdin 传输;日志与证据只保留哈希、用户名和聚合计数,不输出 token。
|
||
set -euo pipefail
|
||
|
||
readonly EXPECTED_ACK='IMPORT_NEWAPI_POOL_TO_STAGING'
|
||
readonly EXPECTED_SOURCE_HOST='mini-infra'
|
||
readonly EXPECTED_TARGET_HOST='root@100.64.0.7'
|
||
readonly MYSQL_CONTAINER='game-staging-mysql'
|
||
readonly MYSQL_DATABASE='ruoyi-vue-pro'
|
||
readonly POOL_TABLE='newapi_quota_pool'
|
||
|
||
NEWAPI_POOL_IMPORT_ACK="${NEWAPI_POOL_IMPORT_ACK:-}"
|
||
NEWAPI_POOL_SQL_FILE="${NEWAPI_POOL_SQL_FILE:-}"
|
||
NEWAPI_POOL_IMPORT_HOST="${NEWAPI_POOL_IMPORT_HOST:-$EXPECTED_TARGET_HOST}"
|
||
NEWAPI_POOL_IMPORT_EVIDENCE="${NEWAPI_POOL_IMPORT_EVIDENCE:-./newapi-pool-import.evidence}"
|
||
HOSTNAME_BIN="${HOSTNAME_BIN:-hostname}"
|
||
SSH_BIN="${SSH_BIN:-ssh}"
|
||
|
||
die() { printf 'NEWAPI_POOL_IMPORT_FAIL %s\n' "$*" >&2; exit 1; }
|
||
mode_of() { stat -f '%Lp' "$1" 2>/dev/null || stat -c '%a' "$1"; }
|
||
|
||
[ "$NEWAPI_POOL_IMPORT_ACK" = "$EXPECTED_ACK" ] || \
|
||
die "NEWAPI_POOL_IMPORT_ACK 必须精确设为 $EXPECTED_ACK"
|
||
[ -f "$NEWAPI_POOL_SQL_FILE" ] && [ ! -L "$NEWAPI_POOL_SQL_FILE" ] || \
|
||
die 'NEWAPI_POOL_SQL_FILE 必须是本机普通文件'
|
||
case "$(mode_of "$NEWAPI_POOL_SQL_FILE")" in
|
||
400|600) ;;
|
||
*) die 'emit SQL 含 token,文件权限必须是 400 或 600' ;;
|
||
esac
|
||
|
||
source_hostname="$("$HOSTNAME_BIN" -s 2>/dev/null || "$HOSTNAME_BIN")"
|
||
source_hostname="${source_hostname%%.*}"
|
||
[ "$source_hostname" = "$EXPECTED_SOURCE_HOST" ] || \
|
||
die "只允许在 mini-infra 离线执行,actual=$source_hostname"
|
||
[ "$NEWAPI_POOL_IMPORT_HOST" = "$EXPECTED_TARGET_HOST" ] || \
|
||
die "导入目标只允许 $EXPECTED_TARGET_HOST"
|
||
[ ! -e "$NEWAPI_POOL_IMPORT_EVIDENCE" ] || \
|
||
die "证据文件已存在,拒绝覆盖:$NEWAPI_POOL_IMPORT_EVIDENCE"
|
||
|
||
umask 077
|
||
validation_file="$(mktemp)"
|
||
remote_output_file="$(mktemp)"
|
||
cleanup() { rm -f "$validation_file" "$remote_output_file"; }
|
||
trap cleanup EXIT
|
||
|
||
# 只接受预置工具生成的四条幂等 INSERT;解析仅输出非敏感 username/user id。
|
||
python3 - "$NEWAPI_POOL_SQL_FILE" >"$validation_file" <<'PY'
|
||
import re
|
||
import sys
|
||
|
||
path = sys.argv[1]
|
||
comment_re = re.compile(r"^-- pool_entry username=(neice_[0-9]{3}) newapi_user_id=([0-9]+)$")
|
||
insert_re = re.compile(
|
||
r"^INSERT INTO newapi_quota_pool "
|
||
r"\(newapi_user_id, newapi_token_id, newapi_token_key, grant_quota, "
|
||
r"quota_per_unit_snapshot, usd_rate_snapshot, status\) VALUES "
|
||
r"\(([0-9]+), [0-9]+, '[A-Za-z0-9_-]{16,128}', [0-9]+, [0-9]+, "
|
||
r"[0-9]+(?:\.[0-9]+)?, 'FREE'\) ON DUPLICATE KEY UPDATE "
|
||
r"newapi_token_id=VALUES\(newapi_token_id\), "
|
||
r"newapi_token_key=VALUES\(newapi_token_key\), "
|
||
r"grant_quota=VALUES\(grant_quota\), "
|
||
r"quota_per_unit_snapshot=VALUES\(quota_per_unit_snapshot\), "
|
||
r"usd_rate_snapshot=VALUES\(usd_rate_snapshot\);$"
|
||
)
|
||
entries = []
|
||
pending = None
|
||
with open(path, encoding="utf-8") as handle:
|
||
for line_number, raw in enumerate(handle, 1):
|
||
line = raw.strip()
|
||
if not line:
|
||
continue
|
||
comment = comment_re.fullmatch(line)
|
||
if comment:
|
||
if pending is not None:
|
||
raise SystemExit(f"第 {line_number} 行前一条 pool_entry 缺 INSERT")
|
||
pending = (comment.group(1), int(comment.group(2)))
|
||
continue
|
||
if line.startswith("--"):
|
||
continue
|
||
insert = insert_re.fullmatch(line)
|
||
if not insert or pending is None:
|
||
raise SystemExit(f"第 {line_number} 行不是受控额度池 INSERT")
|
||
sql_user_id = int(insert.group(1))
|
||
if sql_user_id != pending[1]:
|
||
raise SystemExit(f"第 {line_number} 行 user id 与 pool_entry 不一致")
|
||
entries.append(pending)
|
||
pending = None
|
||
if pending is not None:
|
||
raise SystemExit("最后一条 pool_entry 缺 INSERT")
|
||
expected_names = [f"neice_{number:03d}" for number in range(51, 55)]
|
||
names = [entry[0] for entry in entries]
|
||
user_ids = [entry[1] for entry in entries]
|
||
if names != expected_names:
|
||
raise SystemExit(f"只允许导入 neice_051..054,actual={names}")
|
||
if len(set(user_ids)) != 4:
|
||
raise SystemExit("四条 pool_entry 的 newapi_user_id 必须唯一")
|
||
print("4\t" + ",".join(str(user_id) for user_id in user_ids))
|
||
PY
|
||
|
||
IFS=$'\t' read -r source_row_count target_user_ids <"$validation_file"
|
||
[ "$source_row_count" = 4 ] && [[ "$target_user_ids" =~ ^[0-9]+(,[0-9]+){3}$ ]] || \
|
||
die 'emit SQL 校验结果非法'
|
||
source_sql_sha256="$(sha256sum "$NEWAPI_POOL_SQL_FILE" | awk '{print $1}')"
|
||
|
||
# 远端脚本固定容器、数据库和表;SQL 由 stdin 送入临时 mode600 文件,事务执行后立即销毁。
|
||
remote_script="$(cat <<'REMOTE'
|
||
set -euo pipefail
|
||
expected_hash="$1"
|
||
target_user_ids="$2"
|
||
sql_file="$(mktemp)"
|
||
mysql_error=''
|
||
cleanup_remote() {
|
||
rm -f "$sql_file"
|
||
[ -z "$mysql_error" ] || rm -f "$mysql_error"
|
||
}
|
||
trap cleanup_remote EXIT
|
||
umask 077
|
||
cat >"$sql_file"
|
||
chmod 600 "$sql_file"
|
||
[ "$(sha256sum "$sql_file" | awk '{print $1}')" = "$expected_hash" ] || {
|
||
printf '远端 SQL 哈希不匹配\n' >&2
|
||
exit 4
|
||
}
|
||
docker inspect game-staging-mysql >/dev/null
|
||
|
||
aggregate() {
|
||
local query
|
||
query="SELECT COUNT(*), COALESCE(SUM(status='FREE'),0), COALESCE(SUM(status='CLAIMED'),0), COALESCE(SUM(status='INVALID'),0), (SELECT COUNT(*) FROM newapi_quota_pool WHERE status='FREE' AND deleted=b'0') FROM newapi_quota_pool WHERE newapi_user_id IN (${target_user_ids}) AND deleted=b'0';"
|
||
docker exec game-staging-mysql sh -lc \
|
||
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B "$1" -e "$2"' \
|
||
sh 'ruoyi-vue-pro' "$query"
|
||
}
|
||
|
||
before="$(aggregate)"
|
||
mysql_error="$(mktemp)"
|
||
set +e
|
||
{
|
||
printf 'START TRANSACTION;\n'
|
||
cat "$sql_file"
|
||
printf '\nCOMMIT;\n'
|
||
} | docker exec -i game-staging-mysql sh -lc \
|
||
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B "$1"' sh 'ruoyi-vue-pro' \
|
||
>/dev/null 2>"$mysql_error"
|
||
mysql_rc="${PIPESTATUS[1]}"
|
||
set -e
|
||
if [ "$mysql_rc" -ne 0 ]; then
|
||
# mysql stderr 可能回显含 token 的 SQL,仅保留返回码并让临时错误文件随 EXIT 清理。
|
||
printf 'staging MySQL 事务导入失败 rc=%s\n' "$mysql_rc" >&2
|
||
exit "$mysql_rc"
|
||
fi
|
||
rm -f "$mysql_error"
|
||
mysql_error=''
|
||
after="$(aggregate)"
|
||
printf 'BEFORE\t%s\n' "$before"
|
||
printf 'AFTER\t%s\n' "$after"
|
||
REMOTE
|
||
)"
|
||
remote_script_base64="$(printf '%s' "$remote_script" | base64 | tr -d '\n')"
|
||
remote_command="bash -c 'script_b64=\$1; shift; eval \"\$(printf %s \"\$script_b64\" | base64 -d)\"' _ '$remote_script_base64' '$source_sql_sha256' '$target_user_ids'"
|
||
|
||
if ! "$SSH_BIN" -o ProxyCommand=none "$NEWAPI_POOL_IMPORT_HOST" "$remote_command" \
|
||
<"$NEWAPI_POOL_SQL_FILE" >"$remote_output_file"; then
|
||
die '远端事务导入失败;未签发通过证据'
|
||
fi
|
||
|
||
before_line="$(awk -F '\t' '$1=="BEFORE" {print; count++} END {exit count!=1}' "$remote_output_file")" || \
|
||
die '远端缺唯一 BEFORE 聚合'
|
||
after_line="$(awk -F '\t' '$1=="AFTER" {print; count++} END {exit count!=1}' "$remote_output_file")" || \
|
||
die '远端缺唯一 AFTER 聚合'
|
||
IFS=$'\t' read -r _ before_total before_free before_claimed before_invalid before_global_free <<<"$before_line"
|
||
IFS=$'\t' read -r _ after_total after_free after_claimed after_invalid after_global_free <<<"$after_line"
|
||
for value in "$before_total" "$before_free" "$before_claimed" "$before_invalid" "$before_global_free" \
|
||
"$after_total" "$after_free" "$after_claimed" "$after_invalid" "$after_global_free"; do
|
||
[[ "$value" =~ ^[0-9]+$ ]] || die '远端聚合包含非整数'
|
||
done
|
||
[ "$after_total" -eq 4 ] || die "导入后四个目标 user id 未全部存在,actual=$after_total"
|
||
[ $((after_free + after_claimed + after_invalid)) -eq 4 ] || die '导入后目标状态聚合不守恒'
|
||
[ "$before_total" -le "$after_total" ] || die '导入后目标行数倒退'
|
||
|
||
mkdir -p "$(dirname "$NEWAPI_POOL_IMPORT_EVIDENCE")"
|
||
{
|
||
printf 'schema=newapi-pool-import/1\n'
|
||
printf 'captured_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)"
|
||
printf 'source_host=%s\n' "$source_hostname"
|
||
printf 'target_host=100.64.0.7\n'
|
||
printf 'database=%s\n' "$MYSQL_DATABASE"
|
||
printf 'table=%s\n' "$POOL_TABLE"
|
||
printf 'source_sql_sha256=%s\n' "$source_sql_sha256"
|
||
printf 'source_row_count=%s\n' "$source_row_count"
|
||
printf 'source_usernames=neice_051,neice_052,neice_053,neice_054\n'
|
||
printf 'before_target_total=%s\n' "$before_total"
|
||
printf 'before_target_free=%s\n' "$before_free"
|
||
printf 'before_target_claimed=%s\n' "$before_claimed"
|
||
printf 'before_target_invalid=%s\n' "$before_invalid"
|
||
printf 'before_global_free=%s\n' "$before_global_free"
|
||
printf 'after_target_total=%s\n' "$after_total"
|
||
printf 'after_target_free=%s\n' "$after_free"
|
||
printf 'after_target_claimed=%s\n' "$after_claimed"
|
||
printf 'after_target_invalid=%s\n' "$after_invalid"
|
||
printf 'after_global_free=%s\n' "$after_global_free"
|
||
printf 'inserted_target_rows=%s\n' "$((after_total - before_total))"
|
||
printf 'status=PASS\n'
|
||
} >"$NEWAPI_POOL_IMPORT_EVIDENCE"
|
||
chmod 400 "$NEWAPI_POOL_IMPORT_EVIDENCE"
|
||
|
||
printf 'NEWAPI_POOL_IMPORT_PASS rows=4 sql_sha256=%s before=%s after=%s evidence=%s\n' \
|
||
"$source_sql_sha256" "$before_total" "$after_total" "$NEWAPI_POOL_IMPORT_EVIDENCE"
|