games-development-ai/deploy/provision-dogfood-with-nacos-transaction.sh

319 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
# 在受控 Nacos 临时配置窗口内预置狗粮账号,并在任何退出路径恢复原配置。
# 本脚本不创建额度池资源;调用既有 provision-dogfood-users.sh 完成账号与额度绑定。
set -Eeuo pipefail
readonly EXPECTED_ACK='PROVISION_DOGFOOD_WITH_NACOS_TRANSACTION'
readonly DATA_ID='huijing-server-staging.yaml'
readonly NACOS_GROUP='DEFAULT_GROUP'
readonly CODE_USERNAME_OR_PASSWORD_ERROR='1002090005'
readonly CODE_PASSWORD_REGISTER_DISABLED='1002090007'
readonly CODE_DOGFOOD_SMS_DISABLED='1002090008'
readonly CODE_AD_DOGFOOD_DISABLED='1111002006'
readonly CODE_TRADE_DOGFOOD_DISABLED='1106008000'
DOGFOOD_PROVISION_ACK="${DOGFOOD_PROVISION_ACK:-}"
BASE="${BASE:-http://100.64.0.7:48080}"
TENANT="${TENANT:-1}"
NACOS_BASE_URL="${NACOS_BASE_URL:-http://100.64.0.8:8848/nacos}"
NACOS_USERNAME="${NACOS_USERNAME:-nacos}"
NACOS_PASSWORD="${NACOS_PASSWORD:-}"
NACOS_ENV_FILE="${NACOS_ENV_FILE:-/root/huijing-dev.env}"
BACKEND_UNIT="${BACKEND_UNIT:-gda-staging-backend}"
PROVISION_BIN="${PROVISION_BIN:-$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)/provision-dogfood-users.sh}"
CURL_BIN="${CURL_BIN:-curl}"
SYSTEMCTL_BIN="${SYSTEMCTL_BIN:-systemctl}"
SLEEP_BIN="${SLEEP_BIN:-sleep}"
RESTART_TIMEOUT_SEC="${RESTART_TIMEOUT_SEC:-180}"
DOGFOOD_NEGATIVE_TOKEN="${DOGFOOD_NEGATIVE_TOKEN:-test1}"
RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
EVIDENCE_DIR="${EVIDENCE_DIR:-/root/game-staging/evidence/dogfood-provision-$RUN_ID}"
EVENT_LOG=''
log() {
local line
line="[$(date -u +%Y-%m-%dT%H:%M:%SZ)] $*"
printf '%s\n' "$line"
[ -z "$EVENT_LOG" ] || printf '%s\n' "$line" >>"$EVENT_LOG"
}
die() { printf 'DOGFOOD_NACOS_TX_FAIL %s\n' "$*" >&2; exit 1; }
[ "$DOGFOOD_PROVISION_ACK" = "$EXPECTED_ACK" ] || \
die "DOGFOOD_PROVISION_ACK 不匹配;必须精确确认 $EXPECTED_ACK"
[[ "$RESTART_TIMEOUT_SEC" =~ ^[0-9]+$ ]] && [ "$RESTART_TIMEOUT_SEC" -ge 30 ] || \
die 'RESTART_TIMEOUT_SEC 必须是至少 30 秒的整数'
[ -x "$PROVISION_BIN" ] || die "预置脚本不可执行:$PROVISION_BIN"
command -v python3 >/dev/null || die '缺少 python3'
python3 -c 'import yaml' >/dev/null 2>&1 || die '缺少 PyYAML,无法安全合并既有 Nacos YAML'
if [ -z "$NACOS_PASSWORD" ]; then
[ -f "$NACOS_ENV_FILE" ] || die "缺少 Nacos 凭据来源:$NACOS_ENV_FILE"
set -a
# shellcheck disable=SC1090
. "$NACOS_ENV_FILE"
set +a
NACOS_PASSWORD="${NACOS_PASSWORD:-}"
fi
[ -n "$NACOS_PASSWORD" ] || die 'Nacos 口令为空'
umask 077
[ ! -e "$EVIDENCE_DIR" ] || die "证据目录已存在,拒绝覆盖:$EVIDENCE_DIR"
mkdir -p "$EVIDENCE_DIR"
chmod 700 "$EVIDENCE_DIR"
EVENT_LOG="$EVIDENCE_DIR/transaction.log"
: >"$EVENT_LOG"
chmod 600 "$EVENT_LOG"
BODY_FILE="$(mktemp)"
NACOS_PASSWORD_FILE="$(mktemp)"
NACOS_TOKEN_FILE="$(mktemp)"
printf '%s' "$NACOS_PASSWORD" >"$NACOS_PASSWORD_FILE"
chmod 600 "$NACOS_PASSWORD_FILE" "$NACOS_TOKEN_FILE"
ORIGINAL_CONFIG="$EVIDENCE_DIR/nacos-original.yaml"
TEMP_CONFIG="$EVIDENCE_DIR/nacos-temporary.yaml"
ORIGINAL_STATUS_FILE="$EVIDENCE_DIR/nacos-original.status"
SUMMARY_FILE="$EVIDENCE_DIR/transaction.status"
restore_required=0
finalizing=0
NACOS_TOKEN=''
LAST_HTTP=''
LAST_BODY=''
cleanup_body() { rm -f "$BODY_FILE" "$NACOS_PASSWORD_FILE" "$NACOS_TOKEN_FILE"; }
request() {
local method="$1" url="$2" data="${3:-}" token="${4:-}" content_file="${5:-}"
local -a args=(-sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}' -X "$method")
if [ -n "$token" ]; then
[ "$method" != GET ] || args+=(-G)
# token 通过 mode600 临时文件交给 curl,避免出现在进程参数或日志。
args+=(--data-urlencode "accessToken@$NACOS_TOKEN_FILE")
fi
[ -z "$data" ] || args+=(--data "$data")
[ -z "$content_file" ] || args+=(--data-urlencode "content@$content_file")
LAST_HTTP="$("$CURL_BIN" "${args[@]}" "$url" 2>/dev/null || printf '000')"
LAST_BODY="$(<"$BODY_FILE")"
}
json_value() {
local path="$1"
printf '%s' "$LAST_BODY" | python3 -c '
import json,sys
try:
value=json.load(sys.stdin)
for part in sys.argv[1].split("."):
value=value.get(part) if isinstance(value,dict) else None
if isinstance(value,bool): print("true" if value else "false")
else: print("" if value is None else value)
except Exception: print("")
' "$path"
}
nacos_login() {
# 用户名与口令分别 URL encode;响应 token 只驻内存和临时响应文件,不落 evidence。
LAST_HTTP="$("$CURL_BIN" -sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}' \
-X POST --data-urlencode "username=$NACOS_USERNAME" --data-urlencode "password@$NACOS_PASSWORD_FILE" \
"$NACOS_BASE_URL/v1/auth/login" 2>/dev/null || printf '000')"
LAST_BODY="$(<"$BODY_FILE")"
[ "$LAST_HTTP" = 200 ] || return 1
NACOS_TOKEN="$(json_value accessToken)"
[ -n "$NACOS_TOKEN" ] || return 1
printf '%s' "$NACOS_TOKEN" >"$NACOS_TOKEN_FILE"
chmod 600 "$NACOS_TOKEN_FILE"
}
nacos_get() {
local output="$1"
request GET "$NACOS_BASE_URL/v1/cs/configs?dataId=$DATA_ID&group=$NACOS_GROUP&tenant=" '' "$NACOS_TOKEN"
# 从 curl 原始响应文件复制,保留 Nacos 配置末尾换行等字节;命令替换会吞尾换行,不能用于快照。
cp "$BODY_FILE" "$output"
chmod 600 "$output"
[ "$LAST_HTTP" = 200 ] || [ "$LAST_HTTP" = 404 ]
}
nacos_publish() {
local content_file="$1"
request POST "$NACOS_BASE_URL/v1/cs/configs" \
"dataId=$DATA_ID&group=$NACOS_GROUP&tenant=" "$NACOS_TOKEN" "$content_file"
[ "$LAST_HTTP" = 200 ] && [ "$LAST_BODY" = true ]
}
nacos_delete() {
request DELETE "$NACOS_BASE_URL/v1/cs/configs" \
"dataId=$DATA_ID&group=$NACOS_GROUP&tenant=" "$NACOS_TOKEN"
[ "$LAST_HTTP" = 200 ] && [ "$LAST_BODY" = true ]
}
verify_nacos_exact() {
local expected_file="$1" actual="$EVIDENCE_DIR/nacos-verify.tmp"
nacos_get "$actual" || return 1
[ "$LAST_HTTP" = 200 ] || return 1
cmp -s "$expected_file" "$actual"
}
restart_backend() {
"$SYSTEMCTL_BIN" restart "$BACKEND_UNIT"
local deadline=$((SECONDS + RESTART_TIMEOUT_SEC))
while [ "$SECONDS" -lt "$deadline" ]; do
request GET "$BASE/actuator/health"
if [ "$LAST_HTTP" = 200 ] && [ "$(json_value status)" = UP ]; then
return 0
fi
"$SLEEP_BIN" 2
done
return 1
}
expect_code() {
local name="$1" expected="$2" method="$3" path="$4" data="${5:-}" auth="${6:-}"
local -a args=(-sS --connect-timeout 5 --max-time 30 -o "$BODY_FILE" -w '%{http_code}' -X "$method"
-H "tenant-id: $TENANT")
[ -z "$auth" ] || args+=(-H "Authorization: Bearer $auth")
[ -z "$data" ] || args+=(-H 'Content-Type: application/json' --data "$data")
LAST_HTTP="$("$CURL_BIN" "${args[@]}" "$BASE$path" 2>/dev/null || printf '000')"
LAST_BODY="$(<"$BODY_FILE")"
local actual
actual="$(json_value code)"
if [ "$LAST_HTTP" != 200 ] || [ "$actual" != "$expected" ]; then
log "$name FAIL expected=$expected actual=${actual:-none} http=$LAST_HTTP"
return 1
fi
log "$name PASS code=$expected"
}
verify_temporary_runtime() {
# 匿名广告位在 dogfood=false 时应正常返回;真实注册/登录由随后 provision 脚本继续实证。
request GET "$BASE/app-api/ad/slot/list-enabled"
[ "$LAST_HTTP" = 200 ] && [ "$(json_value code)" = 0 ]
}
verify_restored_runtime() {
local suffix="${RUN_ID//[^0-9]/}"
local username="dogfoodverify${suffix:0:15}"
local login_body="{\"username\":\"$username\",\"password\":\"DogfoodVerify1\"}"
expect_code 'password register disabled' "$CODE_PASSWORD_REGISTER_DISABLED" POST \
/app-api/passport/password-register "$login_body"
# 查无用户统一返回用户名/密码错误;若登录开关关闭会返回另一业务码。
expect_code 'password login enabled' "$CODE_USERNAME_OR_PASSWORD_ERROR" POST \
/app-api/passport/password-login "$login_body"
expect_code 'SMS disabled' "$CODE_DOGFOOD_SMS_DISABLED" POST \
/app-api/passport/send-sms-code '{"mobile":"13800000000"}'
expect_code 'ad disabled' "$CODE_AD_DOGFOOD_DISABLED" GET /app-api/ad/slot/list-enabled
expect_code 'charge disabled' "$CODE_TRADE_DOGFOOD_DISABLED" GET \
/app-api/trade/charge/points-balance '' "$DOGFOOD_NEGATIVE_TOKEN"
expect_code 'withdraw disabled' "$CODE_TRADE_DOGFOOD_DISABLED" GET \
'/app-api/trade/withdraw/page?pageNo=1&pageSize=1' '' "$DOGFOOD_NEGATIVE_TOKEN"
}
restore_original() {
log '开始恢复原 Nacos 配置'
nacos_login || return 1
original_status="$(<"$ORIGINAL_STATUS_FILE")"
if [ "$original_status" = 200 ]; then
nacos_publish "$ORIGINAL_CONFIG" || return 1
verify_nacos_exact "$ORIGINAL_CONFIG" || return 1
elif [ "$original_status" = 404 ]; then
nacos_delete || return 1
verify_file="$EVIDENCE_DIR/nacos-restore-verify.tmp"
nacos_get "$verify_file" || return 1
[ "$LAST_HTTP" = 404 ] || return 1
else
return 1
fi
restart_backend || return 1
verify_restored_runtime || return 1
log '原 Nacos 配置与狗粮安全边界恢复完成'
}
finalize() {
local main_rc=$? restore_rc=0
[ "$finalizing" -eq 0 ] || return
finalizing=1
trap - EXIT INT TERM
set +e
if [ "$restore_required" -eq 1 ]; then
restore_original
restore_rc=$?
fi
cleanup_body
if [ "$restore_rc" -ne 0 ]; then
printf 'restore_failed main_rc=%s restore_rc=%s evidence=%s\n' "$main_rc" "$restore_rc" "$EVIDENCE_DIR" >"$SUMMARY_FILE"
chmod 600 "$SUMMARY_FILE"
printf 'DOGFOOD_NACOS_TX_RESTORE_FAIL evidence=%s\n' "$EVIDENCE_DIR" >&2
exit 70
fi
if [ "$main_rc" -ne 0 ]; then
printf 'provision_failed rc=%s restored=true evidence=%s\n' "$main_rc" "$EVIDENCE_DIR" >"$SUMMARY_FILE"
chmod 600 "$SUMMARY_FILE"
printf 'DOGFOOD_NACOS_TX_FAIL rc=%s restored=true evidence=%s\n' "$main_rc" "$EVIDENCE_DIR" >&2
exit "$main_rc"
fi
printf 'pass restored=true evidence=%s\n' "$EVIDENCE_DIR" >"$SUMMARY_FILE"
chmod 600 "$SUMMARY_FILE"
printf 'DOGFOOD_NACOS_TX_PASS restored=true evidence=%s\n' "$EVIDENCE_DIR"
}
trap finalize EXIT
trap 'exit 130' INT
trap 'exit 143' TERM
log "开始狗粮账号预置配置事务 runId=$RUN_ID"
nacos_login || die 'Nacos 登录失败'
nacos_get "$ORIGINAL_CONFIG" || die "读取 $DATA_ID 失败 http=$LAST_HTTP"
printf '%s\n' "$LAST_HTTP" >"$ORIGINAL_STATUS_FILE"
chmod 600 "$ORIGINAL_STATUS_FILE"
restore_required=1
# 在原 YAML 上只覆盖预置窗口所需键;原文始终单独留证并在 EXIT trap 逐字恢复。
ORIGINAL_STATUS="$LAST_HTTP" python3 - "$ORIGINAL_CONFIG" "$TEMP_CONFIG" <<'PY'
import os,sys,yaml
source,target=sys.argv[1:]
if os.environ["ORIGINAL_STATUS"] == "404":
doc={}
else:
with open(source,encoding="utf-8") as f:
doc=yaml.safe_load(f) or {}
if not isinstance(doc,dict):
raise SystemExit("Nacos staging 配置顶层必须是 YAML object")
# 若历史配置使用过 dotted key,先移除同义键,避免 Spring 扁平化后与嵌套键冲突;原文仍会逐字恢复。
for key in (
"huijing.dogfood.enabled",
"wanxiang.passport.password-register-enabled",
"wanxiang.passport.password-login-enabled",
"wanxiang.passport.register-ip-limit-per-hour",
"wanxiang.passport.register-ip-limit-per-day",
"wanxiang.passport.login-ip-limit-per-hour",
"wanxiang.passport.login-ip-limit-per-day",
):
doc.pop(key,None)
huijing=doc.setdefault("huijing",{})
if not isinstance(huijing,dict): raise SystemExit("huijing 必须是 object")
dogfood=huijing.setdefault("dogfood",{})
if not isinstance(dogfood,dict): raise SystemExit("huijing.dogfood 必须是 object")
dogfood["enabled"]=False
wanxiang=doc.setdefault("wanxiang",{})
if not isinstance(wanxiang,dict): raise SystemExit("wanxiang 必须是 object")
passport=wanxiang.setdefault("passport",{})
if not isinstance(passport,dict): raise SystemExit("wanxiang.passport 必须是 object")
passport.update({
"password-register-enabled": True,
"password-login-enabled": True,
"register-ip-limit-per-hour": 60,
"register-ip-limit-per-day": 60,
"login-ip-limit-per-hour": 60,
"login-ip-limit-per-day": max(60,int(passport.get("login-ip-limit-per-day",60))),
})
with open(target,"w",encoding="utf-8") as f:
yaml.safe_dump(doc,f,allow_unicode=True,sort_keys=False)
PY
chmod 600 "$TEMP_CONFIG"
nacos_publish "$TEMP_CONFIG" || die "发布临时 Nacos 配置失败 http=$LAST_HTTP"
verify_nacos_exact "$TEMP_CONFIG" || die '临时 Nacos 配置回读不一致'
restart_backend || die '临时配置发布后 backend 未恢复健康'
verify_temporary_runtime || die '临时 dogfood=false 未在后端可信边界生效'
log '临时配置已生效,执行既有账号预置脚本'
PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' \
"$PROVISION_BIN" >"$EVIDENCE_DIR/provision.log" 2>&1
chmod 600 "$EVIDENCE_DIR/provision.log"
log '账号预置脚本完成,退出时恢复原配置'