games-development-ai/deploy/activate-staging-rc.sh

506 lines
19 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
# 激活 prepare-staging-rc.sh 已构建的 commit-pinned RC。
# 本机只负责经 Tailscale 直连下发;所有起停、证据和回滚都在 mini-desktop 完成。
set -euo pipefail
RC_COMMIT="${RC_COMMIT:-}"
RC_HOST="${RC_HOST:-root@100.64.0.7}"
RC_ROOT="${RC_ROOT:-/root/game-staging/releases}"
ACTIVATION_TIMEOUT_SEC="${ACTIVATION_TIMEOUT_SEC:-180}"
if [ -n "${GENERATION_GATE_EVIDENCE_REF:-}" ] && [ -n "${GENERATION_GATE_REF:-}" ] \
&& [ "$GENERATION_GATE_EVIDENCE_REF" != "$GENERATION_GATE_REF" ]; then
printf 'GENERATION_GATE_EVIDENCE_REF 与 GENERATION_GATE_REF 冲突,拒绝选择其一继续。\n' >&2
exit 2
fi
GENERATION_GATE_EVIDENCE_REF="${GENERATION_GATE_EVIDENCE_REF:-${GENERATION_GATE_REF:-}}"
# 新变量表达真实语义;旧变量仅兼容受控 hash + RC 内相对路径格式。
if [ -n "${STORAGE_AUDIT_REF:-}" ] && [ -n "${OBJECT_SNAPSHOT_REF:-}" ] \
&& [ "$STORAGE_AUDIT_REF" != "$OBJECT_SNAPSHOT_REF" ]; then
printf 'STORAGE_AUDIT_REF 与旧 OBJECT_SNAPSHOT_REF 冲突,拒绝选择其一继续。\n' >&2
exit 2
fi
STORAGE_AUDIT_REF="${STORAGE_AUDIT_REF:-${OBJECT_SNAPSHOT_REF:-}}"
SSH_BIN="${SSH_BIN:-ssh}"
if [[ ! "$RC_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then
printf 'RC_COMMIT 必须是完整 40 位小写提交哈希。\n' >&2
exit 2
fi
if [[ ! "$ACTIVATION_TIMEOUT_SEC" =~ ^[0-9]+$ ]] || [ "$ACTIVATION_TIMEOUT_SEC" -lt 30 ]; then
printf 'ACTIVATION_TIMEOUT_SEC 必须是至少 30 秒的整数。\n' >&2
exit 2
fi
validate_evidence_ref() {
local ref="$1" path component
[[ "$ref" =~ ^sha256:[0-9a-f]{64}:[A-Za-z0-9._/-]+$ ]] || return 1
path="${ref#sha256:}"
path="${path#*:}"
[ -n "$path" ] && [[ "$path" != /* ]] || return 1
IFS='/' read -r -a components <<<"$path"
for component in "${components[@]}"; do
[ -n "$component" ] && [ "$component" != '.' ] && [ "$component" != '..' ] \
&& [ "$component" != '.git' ] || return 1
done
}
if [ -z "$GENERATION_GATE_EVIDENCE_REF" ] || ! validate_evidence_ref "$GENERATION_GATE_EVIDENCE_REF"; then
printf 'GENERATION_GATE_EVIDENCE_REF 必填,格式为 sha256:<64hex>:<安全相对路径>。\n' >&2
exit 2
fi
if [ -n "$STORAGE_AUDIT_REF" ] \
&& [[ ! "$STORAGE_AUDIT_REF" =~ ^sha256:[0-9a-f]{64}:evidence/storage-audit\.tsv$ ]]; then
printf 'STORAGE_AUDIT_REF 非法:只接受 sha256:<64hex>:evidence/storage-audit.tsv留空则远端实时生成。\n' >&2
exit 2
fi
printf '激活 staging RChost=%s commit=%s\n' "$RC_HOST" "$RC_COMMIT"
printf '注意:该操作会执行数据库迁移并切换五个 staging 服务。\n'
"$SSH_BIN" -o ProxyCommand=none "$RC_HOST" bash -s -- \
"$RC_COMMIT" "$RC_ROOT" "$ACTIVATION_TIMEOUT_SEC" "$STORAGE_AUDIT_REF" \
"$GENERATION_GATE_EVIDENCE_REF" <<'REMOTE_SCRIPT'
set -Eeuo pipefail
commit="$1"
root="$2"
timeout_sec="$3"
expected_storage_audit_ref="$4"
expected_generation_gate_ref="$5"
release="$root/$commit"
active_pointer="$root/active"
stable_prefix="gda-staging"
run_id="$(date -u +%Y%m%dT%H%M%SZ)"
short="${commit:0:12}"
candidate_prefix="gda-rc-${short}-${run_id}"
evidence="$release/evidence/activation-$run_id"
activation_log="$evidence/activation.log"
env_file="/root/huijing-dev.env"
switch_started=0
pointer_changed=0
previous_was_active=0
previous="none"
previous_valid=0
mkdir -p "$evidence"
chmod 700 "$evidence"
umask 077
exec > >(tee -a "$activation_log") 2>&1
log_step() {
printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1"
}
unit_name() {
printf '%s-%s' "$1" "$2"
}
stop_stack() {
local prefix="$1" service unit
for service in backend studio admin cheap-agent cheap-worker; do
unit="$(unit_name "$prefix" "$service")"
systemctl stop "$unit" >/dev/null 2>&1 || true
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
done
}
capture_journals() {
local prefix="$1" label="$2" service unit
mkdir -p "$evidence/journal-$label"
for service in backend studio admin cheap-agent cheap-worker; do
unit="$(unit_name "$prefix" "$service")"
journalctl -u "$unit" --no-pager -n 300 >"$evidence/journal-$label/$service.log" 2>&1 || true
done
}
port_listening() {
local port="$1"
ss -H -ltn | awk -v suffix=":$port" '$4 ~ suffix"$" {found=1} END {exit !found}'
}
wait_port() {
local name="$1" port="$2" deadline=$((SECONDS + timeout_sec))
while [ "$SECONDS" -lt "$deadline" ]; do
if port_listening "$port"; then
log_step "$name readiness PASS port=$port"
return 0
fi
sleep 2
done
log_step "$name readiness FAIL port=$port"
return 1
}
wait_url() {
local name="$1" url="$2" deadline=$((SECONDS + timeout_sec)) code
while [ "$SECONDS" -lt "$deadline" ]; do
code="$(curl -sS --noproxy '*' --connect-timeout 2 --max-time 5 -o /dev/null -w '%{http_code}' "$url" 2>/dev/null || true)"
if [ "$code" = "200" ]; then
log_step "$name readiness PASS http=200"
return 0
fi
sleep 2
done
log_step "$name readiness FAIL url=$url"
return 1
}
wait_json_health() {
local name="$1" url="$2" field="$3" expected="$4" deadline=$((SECONDS + timeout_sec)) body
while [ "$SECONDS" -lt "$deadline" ]; do
body="$(curl -sS --noproxy '*' --connect-timeout 2 --max-time 5 "$url" 2>/dev/null || true)"
if HEALTH_FIELD="$field" HEALTH_EXPECTED="$expected" python3 -c '
import json,os,sys
value=json.load(sys.stdin)
for part in os.environ["HEALTH_FIELD"].split("."):
value=value.get(part) if isinstance(value,dict) else None
assert str(value).lower() == os.environ["HEALTH_EXPECTED"].lower()
' <<<"$body" >/dev/null 2>&1; then
log_step "$name readiness PASS"
return 0
fi
sleep 2
done
log_step "$name readiness FAIL url=$url"
return 1
}
run_unit() {
local unit="$1" launcher="$2"
systemctl reset-failed "$unit" >/dev/null 2>&1 || true
systemd-run --quiet --collect --unit="$unit" \
--property=Restart=on-failure --property=RestartSec=2s \
--property=TimeoutStopSec=30s "$launcher"
}
write_launchers() {
local target="$1" label="$2" backend_port="$3" studio_port="$4" admin_port="$5"
local agent_port="$6" worker_port="$7" discovery_enabled="$8"
local launch_dir="$evidence/launchers-$label" quoted_target
quoted_target="$(printf '%q' "$target")"
mkdir -p "$launch_dir"
cat >"$launch_dir/backend.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
set -a
source /root/huijing-dev.env
set +a
export SPRING_PROFILES_ACTIVE=staging
export AIGC_WORKER_URL=http://127.0.0.1:${worker_port}/generate
exec java -jar ${quoted_target}/game-cloud/huijing-server/target/huijing-server.jar \\
--server.port=${backend_port} --spring.cloud.nacos.discovery.enabled=${discovery_enabled}
EOF
cat >"$launch_dir/studio.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
cd ${quoted_target}/game-studio
exec node node_modules/vite/bin/vite.js preview \\
--host 0.0.0.0 --port ${studio_port} --strictPort --outDir dist
EOF
cat >"$launch_dir/admin.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
cd ${quoted_target}/game-admin
exec node node_modules/vite/bin/vite.js preview \\
--host 0.0.0.0 --port ${admin_port} --strictPort --outDir dist-stage
EOF
cat >"$launch_dir/cheap-agent.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
set -a
source /root/huijing-dev.env
set +a
export NO_PROXY=100.64.0.8,localhost,127.0.0.1
export no_proxy=100.64.0.8,localhost,127.0.0.1
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
export CHEAP_SERVICE_PORT=${agent_port}
cd ${quoted_target}/cheap-worker
exec .venv/bin/python cheap_service_app.py
EOF
cat >"$launch_dir/cheap-worker.sh" <<EOF
#!/usr/bin/env bash
set -euo pipefail
set -a
source /root/huijing-dev.env
set +a
: "\${AIGC_CALLBACK_SECRET:?AIGC_CALLBACK_SECRET 未配置}"
export NO_PROXY=100.64.0.8,localhost,127.0.0.1
export no_proxy=100.64.0.8,localhost,127.0.0.1
unset http_proxy https_proxy HTTP_PROXY HTTPS_PROXY
export CHEAP_SERVICE_URL=http://127.0.0.1:${agent_port}
cd ${quoted_target}/cheap-worker
exec .venv/bin/python worker_service.py --port ${worker_port} --callback-secret "\$AIGC_CALLBACK_SECRET"
EOF
chmod 700 "$launch_dir"/*.sh
printf '%s' "$launch_dir"
}
start_stack() {
local target="$1" label="$2" prefix="$3" backend_port="$4" studio_port="$5" admin_port="$6"
local agent_port="$7" worker_port="$8" discovery_enabled="$9" launch_dir
launch_dir="$(write_launchers "$target" "$label" "$backend_port" "$studio_port" "$admin_port" \
"$agent_port" "$worker_port" "$discovery_enabled")"
run_unit "$(unit_name "$prefix" cheap-agent)" "$launch_dir/cheap-agent.sh"
wait_port "$label cheap-agent" "$agent_port"
run_unit "$(unit_name "$prefix" cheap-worker)" "$launch_dir/cheap-worker.sh"
wait_json_health "$label cheap-worker" "http://127.0.0.1:$worker_port/health" ok true
run_unit "$(unit_name "$prefix" backend)" "$launch_dir/backend.sh"
wait_json_health "$label backend" "http://127.0.0.1:$backend_port/actuator/health" status UP
run_unit "$(unit_name "$prefix" studio)" "$launch_dir/studio.sh"
wait_url "$label studio" "http://127.0.0.1:$studio_port/"
run_unit "$(unit_name "$prefix" admin)" "$launch_dir/admin.sh"
wait_url "$label admin" "http://127.0.0.1:$admin_port/"
}
valid_prepared_release() {
local target="$1" expected="${2:-}"
[ -d "$target/.git" ] || return 1
[ -f "$target/evidence/artifact-paths.env" ] || return 1
[ -f "$target/game-cloud/huijing-server/target/huijing-server.jar" ] || return 1
[ -d "$target/game-studio/dist" ] || return 1
[ -d "$target/game-admin/dist-stage" ] || return 1
[ -x "$target/cheap-worker/.venv/bin/python" ] || return 1
if [ -n "$expected" ]; then
[ "$(git -C "$target" rev-parse HEAD)" = "$expected" ] || return 1
fi
}
verify_generation_gate_evidence() {
local ref="$1" expected_commit="$2" hash_and_path expected_hash relative_path actual_hash object_type
hash_and_path="${ref#sha256:}"
expected_hash="${hash_and_path%%:*}"
relative_path="${hash_and_path#*:}"
# wrapper 已做路径语法校验;远端仍独立确认它是候选 commit 中的普通 tracked 文件。
object_type="$(git -C "$release" cat-file -t "$expected_commit:$relative_path" 2>/dev/null || true)"
[ "$object_type" = blob ] || {
log_step "生成门证据不是候选 commit 内的 tracked blob path=$relative_path"
return 1
}
[ -f "$release/$relative_path" ] && [ ! -L "$release/$relative_path" ] || {
log_step "生成门证据不是 RC 内普通文件 path=$relative_path"
return 1
}
actual_hash="$(sha256sum "$release/$relative_path" | awk '{print $1}')"
[ "$actual_hash" = "$expected_hash" ] || {
log_step "生成门证据 SHA-256 不匹配 path=$relative_path"
return 1
}
# 证据由 R1 执行方生成,激活器只验证,不创建或改写任何“通过”结论。
python3 - "$release/$relative_path" "$expected_commit" <<'PY'
import sys
path, expected_commit = sys.argv[1:]
values = {}
with open(path, encoding="utf-8") as handle:
for line_number, raw in enumerate(handle, 1):
line = raw.strip()
if not line or line.startswith("#"):
continue
if "=" not in line:
raise SystemExit(f"生成门证据第 {line_number} 行不是 key=value")
key, value = line.split("=", 1)
key, value = key.strip(), value.strip()
if not key or key in values:
raise SystemExit(f"生成门证据键为空或重复: {key!r}")
values[key] = value
required = {
"schema": "generation-r1-gate/1",
"rc_commit": expected_commit,
"r1_status": "PASS",
"actor_prompt_eval": "PASS",
"judge_prompt_eval": "PASS",
"game_content_gold": "PASS",
"live_prompt": "PASS",
"full_gate": "PASS",
"fresh_25": "PASS",
"historical_11": "PASS",
"production_shadow_20": "PASS",
"runtime_eligible": "true",
}
for key, expected in required.items():
actual = values.get(key)
if actual != expected:
raise SystemExit(f"生成门证据未通过 {key}: expected={expected} actual={actual!r}")
PY
printf 'generation_gate_ref=%s\ngeneration_gate_path=%s\n' "$ref" "$relative_path" \
>"$evidence/generation-gate-result.txt"
log_step "生成 R1 门证据校验 PASS path=$relative_path"
}
restore_active_pointer() {
local target="$1" temp="$root/.active-rollback-$run_id"
rm -f "$temp"
if [ "$target" = "none" ]; then
rm -f "$active_pointer"
else
ln -s "$target" "$temp"
mv -Tf "$temp" "$active_pointer"
fi
}
failure_handler() {
local rc=$?
trap - ERR
set +e
log_step "ACTIVATION FAIL rc=${rc},开始保留证据并恢复旧栈"
capture_journals "$candidate_prefix" candidate-failed
capture_journals "$stable_prefix" stable-failed
stop_stack "$candidate_prefix"
if [ "$switch_started" -eq 1 ]; then
stop_stack "$stable_prefix"
if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -eq 1 ]; then
log_step "恢复旧版本服务 target=$previous"
start_stack "$previous" rollback "$stable_prefix" 48080 4173 4174 8300 9501 true
rollback_rc=$?
printf 'rollback_service_rc=%s\n' "$rollback_rc" >"$evidence/rollback-result.txt"
else
printf 'rollback_service_rc=not-required\n' >"$evidence/rollback-result.txt"
fi
fi
if [ "$pointer_changed" -eq 1 ]; then
restore_active_pointer "$previous"
fi
printf 'failed commit=%s at=%s evidence=%s\n' "$commit" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$evidence" \
>"$release/RC_STATUS"
chmod -R go-rwx "$evidence" "$release/RC_STATUS"
log_step "ACTIVATION_EVIDENCE=$evidence"
exit "$rc"
}
trap failure_handler ERR
log_step "校验 commit-pinned RC 与配置"
valid_prepared_release "$release" "$commit"
grep -q "^ready commit=$commit " "$release/RC_STATUS"
[ -z "$(git -C "$release" status --porcelain --untracked-files=no)" ]
# 生成门是数据库访问、Nacos/服务切换之前的硬前置;引用缺失、漂移或 R1 任一项不绿立即失败。
verify_generation_gate_evidence "$expected_generation_gate_ref" "$commit"
[ -f "$env_file" ]
case "$(stat -c '%a' "$env_file")" in
*00) ;;
*) log_step "拒绝读取权限过宽的 $env_file"; false ;;
esac
# 只校验键存在和非空,绝不输出值。
(
set -a
source "$env_file"
set +a
: "${MYSQL_ROOT_PASSWORD:?}" "${REDIS_PASSWORD:?}" "${NACOS_PASSWORD:?}"
: "${AIGC_CALLBACK_SECRET:?}" "${NEWAPI_KEY:?}"
[ "${AIGC_EXECUTOR_ENABLED:-}" = "true" ]
)
cd "$release"
sha256sum -c evidence/artifacts.sha256 >"$evidence/artifact-verification.txt"
log_step "生成并校验 DB-only 存储审计"
storage_audit_output="$(RC_RELEASE="$release" EXPECTED_STORAGE_AUDIT_REF="$expected_storage_audit_ref" \
bash "$release/deploy/generate-staging-storage-audit.sh")"
storage_audit_ref="$(printf '%s\n' "$storage_audit_output" | sed -n 's/^STORAGE_AUDIT_PASS ref=//p')"
[[ "$storage_audit_ref" =~ ^sha256:[0-9a-f]{64}:evidence/storage-audit\.tsv$ ]]
cp "$release/evidence/storage-audit.tsv" "$evidence/storage-audit.tsv"
cp "$release/evidence/storage-audit.ref" "$evidence/storage-audit.ref"
printf 'storage_audit_ref=%s\n' "$storage_audit_ref" >"$evidence/storage-audit-result.txt"
log_step "$storage_audit_output"
if [ -L "$active_pointer" ]; then
previous="$(readlink -f "$active_pointer" || true)"
[ -n "$previous" ] || previous="none"
elif [ -L "$root/current" ]; then
# 首次迁移兼容旧 current 指针;成功后只维护 canonical active。
previous="$(readlink -f "$root/current" || true)"
[ -n "$previous" ] || previous="none"
fi
if [ "$previous" != "none" ] && valid_prepared_release "$previous"; then
previous_valid=1
fi
for service in backend studio admin cheap-agent cheap-worker; do
if systemctl is-active --quiet "$(unit_name "$stable_prefix" "$service")"; then
previous_was_active=1
fi
done
if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -ne 1 ]; then
log_step "已有受管服务运行,但 active/current 未指向可回滚 RC拒绝切换"
false
fi
printf 'previous=%s\nprevious_was_active=%s\n' "$previous" "$previous_was_active" >"$evidence/activation-context.txt"
log_step "迁移前数据库快照"
docker inspect game-staging-mysql >/dev/null
docker exec game-staging-mysql sh -lc \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysqldump -uroot --single-transaction --routines --events --databases ruoyi-vue-pro' \
| gzip -9 >"$evidence/pre-migration-ruoyi-vue-pro.sql.gz"
test -s "$evidence/pre-migration-ruoyi-vue-pro.sql.gz"
chmod 600 "$evidence/pre-migration-ruoyi-vue-pro.sql.gz"
log_step "在隔离临时 MySQL 容器中验证快照可恢复"
mysql_image="$(docker inspect game-staging-mysql --format '{{.Config.Image}}')"
DUMP_FILE="$evidence/pre-migration-ruoyi-vue-pro.sql.gz" \
EXPECTED_AUDIT_FILE="$evidence/storage-audit.tsv" MYSQL_IMAGE="$mysql_image" \
bash "$release/deploy/verify-mysql-dump-restore.sh" >"$evidence/mysql-restore-verify.log" 2>&1
grep -q '^RESTORE_VERIFY_PASS ' "$evidence/mysql-restore-verify.log"
log_step "数据库快照隔离恢复验证 PASS"
log_step "启动候选栈backend 启动时先执行 Flyway migrations"
for port in 18080 14173 14174 18300 19501; do
if port_listening "$port"; then
log_step "候选端口已占用 port=$port"
false
fi
done
start_stack "$release" candidate "$candidate_prefix" 18080 14173 14174 18300 19501 false
docker exec game-staging-mysql sh -lc \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B ruoyi-vue-pro -e "SELECT installed_rank,version,success FROM flyway_schema_history ORDER BY installed_rank DESC LIMIT 20"' \
>"$evidence/flyway-after-candidate.tsv"
flyway_failed_count="$(docker exec game-staging-mysql sh -lc \
'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B ruoyi-vue-pro -e "SELECT COUNT(*) FROM flyway_schema_history WHERE success <> 1"')"
[ "$flyway_failed_count" = "0" ]
log_step "候选栈执行只读 smoke"
BASE=http://127.0.0.1:18080 STUDIO_BASE=http://127.0.0.1:14173 \
bash "$release/deploy/smoke-test.sh" >"$evidence/candidate-smoke.log" 2>&1
capture_journals "$candidate_prefix" candidate-ready
log_step "候选栈全绿,开始切换正式五端口"
switch_started=1
capture_journals "$stable_prefix" pre-switch-old
stop_stack "$stable_prefix"
for port in 48080 4173 4174 8300 9501; do
if port_listening "$port"; then
log_step "正式端口仍被非受管进程占用 port=$port"
false
fi
done
start_stack "$release" stable "$stable_prefix" 48080 4173 4174 8300 9501 true
BASE=http://127.0.0.1:48080 STUDIO_BASE=http://127.0.0.1:4173 \
bash "$release/deploy/smoke-test.sh" >"$evidence/stable-smoke.log" 2>&1
capture_journals "$stable_prefix" stable-ready
log_step "正式栈全绿,原子切换 active 指针"
active_temp="$root/.active-$run_id"
ln -s "$release" "$active_temp"
mv -Tf "$active_temp" "$active_pointer"
pointer_changed=1
printf 'active commit=%s at=%s evidence=%s\n' "$commit" "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$evidence" \
>"$release/RC_STATUS"
stop_stack "$candidate_prefix"
chmod -R go-rwx "$evidence" "$release/RC_STATUS"
trap - ERR
log_step "RC_ACTIVATION_PASS commit=$commit active=$release"
log_step "ACTIVATION_EVIDENCE=$evidence"
REMOTE_SCRIPT