319 lines
12 KiB
Bash
Executable File
319 lines
12 KiB
Bash
Executable File
#!/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 '账号预置脚本完成,退出时恢复原配置'
|