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

404 lines
16 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'
MODE='transaction'
WATCHDOG_RECOVERY_DIR=''
if [ "${1:-}" = '--watchdog' ]; then
MODE='watchdog'
WATCHDOG_RECOVERY_DIR="${2:-}"
elif [ -n "${1:-}" ]; then
printf 'DOGFOOD_NACOS_TX_FAIL 未知参数:%s\n' "$1" >&2
exit 2
fi
DOGFOOD_PROVISION_ACK="${DOGFOOD_PROVISION_ACK:-}"
ADMIN_TOKEN_FILE="${ADMIN_TOKEN_FILE:-}"
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}"
SYSTEMD_RUN_BIN="${SYSTEMD_RUN_BIN:-systemd-run}"
FLOCK_BIN="${FLOCK_BIN:-flock}"
SLEEP_BIN="${SLEEP_BIN:-sleep}"
RESTART_TIMEOUT_SEC="${RESTART_TIMEOUT_SEC:-180}"
PROVISION_TTL_SEC="${PROVISION_TTL_SEC:-900}"
RUN_ID="${RUN_ID:-$(date -u +%Y%m%dT%H%M%SZ)-$$}"
EVIDENCE_DIR="${EVIDENCE_DIR:-/root/game-staging/evidence/dogfood-provision-$RUN_ID}"
LOCK_FILE="${LOCK_FILE:-/root/game-staging/locks/dogfood-provision.lock}"
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; }
[[ "$RESTART_TIMEOUT_SEC" =~ ^[0-9]+$ ]] && [ "$RESTART_TIMEOUT_SEC" -ge 30 ] || \
die 'RESTART_TIMEOUT_SEC 必须是至少 30 秒的整数'
[[ "$PROVISION_TTL_SEC" =~ ^[0-9]+$ ]] && [ "$PROVISION_TTL_SEC" -ge 60 ] || \
die 'PROVISION_TTL_SEC 必须是至少 60 秒的整数'
[ "$MODE" = watchdog ] || [ "$DOGFOOD_PROVISION_ACK" = "$EXPECTED_ACK" ] || \
die "DOGFOOD_PROVISION_ACK 不匹配;必须精确确认 $EXPECTED_ACK"
[ "$MODE" = watchdog ] || [ -x "$PROVISION_BIN" ] || die "预置脚本不可执行:$PROVISION_BIN"
[ "$MODE" = watchdog ] || [ -n "$ADMIN_TOKEN_FILE" ] || die 'ADMIN_TOKEN_FILE 必填'
[ "$MODE" = watchdog ] || { [ -f "$ADMIN_TOKEN_FILE" ] && [ ! -L "$ADMIN_TOKEN_FILE" ]; } || \
die 'ADMIN_TOKEN_FILE 必须是非 symlink 普通文件'
if [ "$MODE" != watchdog ]; then
token_mode="$(stat -f '%Lp' "$ADMIN_TOKEN_FILE" 2>/dev/null || stat -c '%a' "$ADMIN_TOKEN_FILE")"
[ "$token_mode" = 600 ] || die "ADMIN_TOKEN_FILE 权限必须为 0600 actual=$token_mode"
fi
command -v python3 >/dev/null || die '缺少 python3'
python3 -c 'import yaml' >/dev/null 2>&1 || die '缺少 PyYAML无法安全合并既有 Nacos YAML'
mkdir -p "$(dirname "$LOCK_FILE")"
exec 9>"$LOCK_FILE"
if [ "$MODE" = watchdog ]; then
"$FLOCK_BIN" -w 30 9 || die 'watchdog 无法取得预置恢复锁'
else
"$FLOCK_BIN" -n 9 || die '已有 dogfood 账号预置事务运行,拒绝并发'
fi
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
if [ "$MODE" = watchdog ]; then
[ -d "$WATCHDOG_RECOVERY_DIR" ] && [ ! -L "$WATCHDOG_RECOVERY_DIR" ] || die 'watchdog recovery 目录非法'
EVIDENCE_DIR="$(dirname "$WATCHDOG_RECOVERY_DIR")"
else
[ ! -e "$EVIDENCE_DIR" ] || die "证据目录已存在,拒绝覆盖:$EVIDENCE_DIR"
mkdir -p "$EVIDENCE_DIR"
fi
chmod 700 "$EVIDENCE_DIR"
EVENT_LOG="$EVIDENCE_DIR/transaction.log"
[ "$MODE" = watchdog ] || : >"$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"
RECOVERY_DIR="$EVIDENCE_DIR/watchdog"
WATCHDOG_UNIT="dogfood-provision-watchdog-${RUN_ID//[^A-Za-z0-9_-]/-}"
restore_required=0
finalizing=0
watchdog_armed=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_md5() {
python3 - "$1" <<'PY'
import hashlib,sys
with open(sys.argv[1],"rb") as handle:
print(hashlib.md5(handle.read()).hexdigest())
PY
}
nacos_publish() {
local content_file="$1" expected_md5="$2"
[[ "$expected_md5" =~ ^[0-9a-f]{32}$ ]] || return 1
request POST "$NACOS_BASE_URL/v1/cs/configs" \
"dataId=$DATA_ID&group=$NACOS_GROUP&tenant=&casMd5=$expected_md5" "$NACOS_TOKEN" "$content_file"
[ "$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() {
# 此时还没有真实玩家 token只校验免登录边界资金守卫由预置脚本在首次真实登录后验证。
expect_code 'provisioning SMS still disabled' "$CODE_DOGFOOD_SMS_DISABLED" POST \
/app-api/passport/send-sms-code '{"mobile":"13800000000"}'
expect_code 'provisioning ad still disabled' "$CODE_AD_DOGFOOD_DISABLED" GET /app-api/ad/slot/list-enabled
}
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
}
restore_original() {
local current="$EVIDENCE_DIR/nacos-before-restore.tmp" temporary_md5
log '开始恢复原 Nacos 配置'
nacos_login || return 1
nacos_get "$current" || return 1
[ "$LAST_HTTP" = 200 ] || return 1
cmp -s "$TEMP_CONFIG" "$current" || return 1
temporary_md5="$(nacos_md5 "$TEMP_CONFIG")"
nacos_publish "$ORIGINAL_CONFIG" "$temporary_md5" || return 1
verify_nacos_exact "$ORIGINAL_CONFIG" || return 1
restart_backend || return 1
verify_restored_runtime || return 1
log '原 Nacos 配置与狗粮安全边界恢复完成'
}
arm_watchdog() {
mkdir -p "$RECOVERY_DIR"
chmod 700 "$RECOVERY_DIR"
{
printf 'schema=dogfood-provision-watchdog/1\n'
printf 'run_id=%s\n' "$RUN_ID"
printf 'ttl_sec=%s\n' "$PROVISION_TTL_SEC"
printf 'temporary_md5=%s\n' "$(nacos_md5 "$TEMP_CONFIG")"
printf 'original_md5=%s\n' "$(nacos_md5 "$ORIGINAL_CONFIG")"
} >"$RECOVERY_DIR/metadata"
: >"$RECOVERY_DIR/armed"
chmod 600 "$RECOVERY_DIR/metadata" "$RECOVERY_DIR/armed"
if ! "$SYSTEMD_RUN_BIN" --quiet --collect --unit="$WATCHDOG_UNIT" \
--on-active="${PROVISION_TTL_SEC}s" --property=Type=oneshot \
--property=Restart=on-failure --property=RestartSec=15s \
/bin/bash "$(realpath "${BASH_SOURCE[0]}")" --watchdog "$RECOVERY_DIR"; then
rm -f "$RECOVERY_DIR/armed"
return 1
fi
watchdog_armed=1
log "独立恢复 watchdog 已注册 unit=$WATCHDOG_UNIT ttl=${PROVISION_TTL_SEC}s"
}
disarm_watchdog() {
[ "$watchdog_armed" -eq 1 ] || return 0
rm -f "$RECOVERY_DIR/armed"
"$SYSTEMCTL_BIN" stop "$WATCHDOG_UNIT.timer" "$WATCHDOG_UNIT.service" >/dev/null 2>&1 || true
printf 'disarmed_at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$RECOVERY_DIR/disarmed"
chmod 600 "$RECOVERY_DIR/disarmed"
watchdog_armed=0
}
if [ "$MODE" = watchdog ]; then
EVENT_LOG="$EVIDENCE_DIR/watchdog.log"
touch "$EVENT_LOG"
chmod 600 "$EVENT_LOG"
[ -f "$RECOVERY_DIR/armed" ] || {
log 'watchdog 已解除,无需恢复'
cleanup_body
exit 0
}
log 'watchdog TTL 到期,开始独立恢复 Nacos 安全配置'
if restore_original; then
rm -f "$RECOVERY_DIR/armed"
printf 'WATCHDOG_RESTORE=PASS at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$RECOVERY_DIR/watchdog.status"
chmod 600 "$RECOVERY_DIR/watchdog.status"
cleanup_body
exit 0
fi
printf 'WATCHDOG_RESTORE=FAILED at=%s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" >"$RECOVERY_DIR/watchdog.status"
chmod 600 "$RECOVERY_DIR/watchdog.status"
cleanup_body
die "watchdog 恢复失败,保留 armed 标记等待人工处置:$RECOVERY_DIR"
fi
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
disarm_watchdog
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"
[ "$LAST_HTTP" = 200 ] || die 'Nacos dataId 不存在,无法执行内容 CAS拒绝创建临时配置'
# 在原 YAML 上只覆盖预置窗口所需键;原文始终单独留证并在 EXIT trap 逐字恢复。
PROVISION_TTL_SEC="$PROVISION_TTL_SEC" python3 - "$ORIGINAL_CONFIG" "$TEMP_CONFIG" <<'PY'
import datetime,os,sys,yaml
source,target=sys.argv[1:]
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",
"huijing.dogfood.provisioning-enabled",
"huijing.dogfood.provisioning-expires-at",
"huijing.dogfood.provisioning-run-id",
"wanxiang.passport.password-register-enabled",
):
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"]=True
dogfood["provisioning-enabled"]=True
dogfood["provisioning-run-id"]=os.environ["RUN_ID"]
expires_at=datetime.datetime.now(datetime.timezone.utc)+datetime.timedelta(seconds=int(os.environ["PROVISION_TTL_SEC"]))
dogfood["provisioning-expires-at"]=expires_at.isoformat().replace("+00:00","Z")
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["password-register-enabled"]=True
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"
arm_watchdog || die '无法注册独立恢复 watchdog未修改 Nacos'
original_md5="$(nacos_md5 "$ORIGINAL_CONFIG")"
nacos_publish "$TEMP_CONFIG" "$original_md5" || die "发布临时 Nacos 配置 CAS 失败 http=$LAST_HTTP"
restore_required=1
verify_nacos_exact "$TEMP_CONFIG" || die '临时 Nacos 配置回读不一致'
restart_backend || die '临时配置发布后 backend 未恢复健康'
verify_temporary_runtime || die '窄 provisioning 开关未生效或安全守卫被关闭'
log '临时配置已生效,执行既有账号预置脚本'
env -u ADMIN_USERNAME -u ADMIN_PASSWORD \
PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PROVISIONING_MODE=narrow \
ADMIN_TOKEN_FILE="$ADMIN_TOKEN_FILE" "$PROVISION_BIN" >"$EVIDENCE_DIR/provision.log" 2>&1
chmod 600 "$EVIDENCE_DIR/provision.log"
log '账号预置脚本完成,退出时恢复原配置'