From d48cc60bbfb267272750ca9af72e13996ad7015a Mon Sep 17 00:00:00 2001 From: lili Date: Wed, 22 Jul 2026 23:38:40 -0700 Subject: [PATCH] fix(release): close staging activation safety gaps --- deploy/activate-staging-rc.sh | 377 +++++++++++++++--- deploy/prepare-staging-rc.sh | 92 ++++- deploy/provision-dogfood-users.sh | 75 +++- ...rovision-dogfood-with-nacos-transaction.sh | 207 +++++++--- deploy/smoke-test.sh | 61 ++- deploy/tests/test-dogfood-smoke.sh | 151 ++++++- ...est-provision-dogfood-nacos-transaction.sh | 128 +++++- deploy/tests/test-provision-dogfood-users.sh | 81 +++- deploy/tests/test-staging-rc.sh | 145 ++++++- 9 files changed, 1137 insertions(+), 180 deletions(-) diff --git a/deploy/activate-staging-rc.sh b/deploy/activate-staging-rc.sh index 0f5d004a..6938e0c9 100755 --- a/deploy/activate-staging-rc.sh +++ b/deploy/activate-staging-rc.sh @@ -6,7 +6,16 @@ set -euo pipefail RC_COMMIT="${RC_COMMIT:-}" RC_HOST="${RC_HOST:-root@100.64.0.7}" RC_ROOT="${RC_ROOT:-/root/game-staging/releases}" +GENERATION_ATTESTATION_ROOT="${GENERATION_ATTESTATION_ROOT:-/root/game-staging/attestations}" +BOOTSTRAP_ADMIN_TOKEN_FILE="${BOOTSTRAP_ADMIN_TOKEN_FILE:-}" ACTIVATION_TIMEOUT_SEC="${ACTIVATION_TIMEOUT_SEC:-180}" +ACTIVATION_MODE="activate" +if [ "${1:-}" = "--verify-r1-attestation" ]; then + ACTIVATION_MODE="verify-r1-attestation" +elif [ -n "${1:-}" ]; then + printf '未知参数:%s\n' "$1" >&2 + exit 2 +fi 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 @@ -51,13 +60,22 @@ if [ -n "$STORAGE_AUDIT_REF" ] \ printf 'STORAGE_AUDIT_REF 非法:只接受 sha256:<64hex>:evidence/storage-audit.tsv;留空则远端实时生成。\n' >&2 exit 2 fi +if [ "$ACTIVATION_MODE" = activate ] && [[ ! "$BOOTSTRAP_ADMIN_TOKEN_FILE" =~ ^/[A-Za-z0-9._/-]+$ ]]; then + printf 'BOOTSTRAP_ADMIN_TOKEN_FILE 必填,且必须是 mini-desktop 上的安全绝对路径。\n' >&2 + exit 2 +fi printf '激活 staging RC:host=%s commit=%s\n' "$RC_HOST" "$RC_COMMIT" -printf '注意:该操作会执行数据库迁移并切换五个 staging 服务。\n' +if [ "$ACTIVATION_MODE" = activate ]; then + printf '注意:该操作会执行数据库迁移并切换五个 staging 服务。\n' +else + printf '仅校验独立 R1 attestation,不访问数据库、不切换服务。\n' +fi "$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' + "$GENERATION_GATE_EVIDENCE_REF" "$GENERATION_ATTESTATION_ROOT" "$ACTIVATION_MODE" \ + "$BOOTSTRAP_ADMIN_TOKEN_FILE" <<'REMOTE_SCRIPT' set -Eeuo pipefail commit="$1" @@ -65,6 +83,9 @@ root="$2" timeout_sec="$3" expected_storage_audit_ref="$4" expected_generation_gate_ref="$5" +attestation_root="$6" +activation_mode="$7" +bootstrap_admin_token_file="$8" release="$root/$commit" active_pointer="$root/active" stable_prefix="gda-staging" @@ -79,16 +100,36 @@ pointer_changed=0 previous_was_active=0 previous="none" previous_valid=0 +migration_started=0 +database_restore_required=0 +database_restore_rc=not-run +bootstrap_token_validated=0 -mkdir -p "$evidence" -chmod 700 "$evidence" umask 077 -exec > >(tee -a "$activation_log") 2>&1 +if [ "$activation_mode" = activate ]; then + mkdir -p "$evidence" + chmod 700 "$evidence" + exec > >(tee -a "$activation_log") 2>&1 +else + evidence="$(mktemp -d)" + trap 'rm -rf "$evidence"' EXIT +fi log_step() { printf '[%s] %s\n' "$(date -u +%Y-%m-%dT%H:%M:%SZ)" "$1" } +delete_bootstrap_token() { + [ "$bootstrap_token_validated" -eq 1 ] || return 0 + if [ -f "$bootstrap_admin_token_file" ] && [ ! -L "$bootstrap_admin_token_file" ]; then + rm -f -- "$bootstrap_admin_token_file" + log_step "bootstrap admin token 文件已安全删除" + else + log_step "bootstrap admin token 文件状态异常,拒绝跟随或删除 symlink" + return 1 + fi +} + unit_name() { printf '%s-%s' "$1" "$2" } @@ -187,7 +228,8 @@ 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} + --server.port=${backend_port} --spring.cloud.nacos.discovery.enabled=${discovery_enabled} \\ + --spring.flyway.enabled=false EOF cat >"$launch_dir/studio.sh" <"$evidence/candidate-backend.status" + 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" 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 @@ -271,29 +331,55 @@ valid_prepared_release() { } verify_generation_gate_evidence() { - local ref="$1" expected_commit="$2" hash_and_path expected_hash relative_path actual_hash object_type + local ref="$1" expected_commit="$2" hash_and_path expected_hash relative_path actual_hash + local source_path canonical_root canonical_source component walk mode 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" + # attestation 是 R1 执行方在 commit 之外生成的内容寻址文件;hash 在外部引用中,避免“证明文件属于被证明 commit”的自引用。 + [ "${relative_path##*/}" = "$expected_hash.attestation" ] || { + log_step "生成门 attestation 文件名未绑定 SHA-256 path=$relative_path" return 1 } - [ -f "$release/$relative_path" ] && [ ! -L "$release/$relative_path" ] || { - log_step "生成门证据不是 RC 内普通文件 path=$relative_path" + [ -d "$attestation_root" ] && [ ! -L "$attestation_root" ] || { + log_step "attestation 根目录缺失或为 symlink root=$attestation_root" return 1 } - actual_hash="$(sha256sum "$release/$relative_path" | awk '{print $1}')" + canonical_root="$(realpath "$attestation_root")" || return 1 + [ "$canonical_root" = "$attestation_root" ] || { + log_step "attestation 根目录必须使用 canonical 绝对路径 root=$attestation_root" + return 1 + } + walk="$attestation_root" + IFS='/' read -r -a path_components <<<"$relative_path" + for component in "${path_components[@]}"; do + walk="$walk/$component" + [ ! -L "$walk" ] || { + log_step "attestation 路径不得包含 symlink path=$relative_path" + return 1 + } + done + source_path="$attestation_root/$relative_path" + [ -f "$source_path" ] || { + log_step "attestation 不是普通文件 path=$relative_path" + return 1 + } + canonical_source="$(realpath "$source_path")" || return 1 + [ "$canonical_source" = "$source_path" ] || return 1 + mode="$(stat -c '%a' "$source_path" 2>/dev/null || stat -f '%Lp' "$source_path")" + [ "$mode" = 600 ] || { + log_step "attestation 权限必须为 0600 actual=$mode path=$relative_path" + return 1 + } + actual_hash="$(sha256sum "$source_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' + python3 - "$source_path" "$expected_commit" <<'PY' import sys path, expected_commit = sys.argv[1:] @@ -336,6 +422,78 @@ PY log_step "生成 R1 门证据校验 PASS path=$relative_path" } +if [ "$activation_mode" = verify-r1-attestation ]; then + verify_generation_gate_evidence "$expected_generation_gate_ref" "$commit" + printf 'GENERATION_ATTESTATION_PASS commit=%s ref=%s\n' "$commit" "$expected_generation_gate_ref" + exit 0 +fi + +exec 9>"$root/.activation.lock" +if ! flock -n 9; then + log_step "已有 staging activation 正在执行,拒绝并发部署" + exit 75 +fi + +flyway_history() { + docker exec game-staging-mysql sh -lc \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -N -B ruoyi-vue-pro -e "SELECT installed_rank,COALESCE(version,\"\"),description,type,script,checksum,success FROM flyway_schema_history ORDER BY installed_rank"' +} + +run_flyway_migrations() { + local runtime_dir="$evidence/flyway-runtime" source_file="$evidence/StagingFlywayMigration.java" + local class_dir="$evidence/flyway-classes" jar="$release/game-cloud/huijing-server/target/huijing-server.jar" + mkdir -p "$runtime_dir" "$class_dir" + java -Djarmode=tools -jar "$jar" extract --destination "$runtime_dir" >/dev/null + cat >"$source_file" <<'JAVA' +import org.flywaydb.core.Flyway; + +/** 只执行 Flyway,不启动 Spring、消费者、定时任务或 Web 服务。 */ +public final class StagingFlywayMigration { + public static void main(String[] args) { + if (args.length != 2) { + throw new IllegalArgumentException("需要 migration 与 staging migration 两个目录"); + } + String url = System.getenv("STAGING_FLYWAY_URL"); + String user = System.getenv("STAGING_FLYWAY_USER"); + String password = System.getenv("STAGING_FLYWAY_PASSWORD"); + if (url == null || user == null || password == null) { + throw new IllegalStateException("缺少 Flyway 数据库凭据"); + } + Flyway flyway = Flyway.configure() + .dataSource(url, user, password) + .locations("filesystem:" + args[0], "filesystem:" + args[1]) + .failOnMissingLocations(true) + .load(); + flyway.validate(); + var result = flyway.migrate(); + System.out.printf("FLYWAY_MIGRATION_PASS executed=%d target=%s%n", + result.migrationsExecuted, result.targetSchemaVersion); + } +} +JAVA + javac -cp "$runtime_dir/lib/*" -d "$class_dir" "$source_file" + STAGING_FLYWAY_URL='jdbc:mysql://127.0.0.1:13306/ruoyi-vue-pro?useSSL=false&serverTimezone=Asia/Shanghai&allowPublicKeyRetrieval=true&nullCatalogMeansCurrent=true' \ + STAGING_FLYWAY_USER=root STAGING_FLYWAY_PASSWORD="$MYSQL_ROOT_PASSWORD" \ + java -cp "$class_dir:$runtime_dir/lib/*" StagingFlywayMigration \ + "$release/game-cloud/huijing-server/src/main/resources/db/migration" \ + "$release/game-cloud/huijing-server/src/main/resources/db/staging" \ + >"$evidence/flyway-migration.log" 2>&1 + grep -q '^FLYWAY_MIGRATION_PASS ' "$evidence/flyway-migration.log" +} + +restore_database_snapshot() { + local dump="$evidence/pre-migration-ruoyi-vue-pro.sql.gz" + [ -s "$dump" ] || return 1 + stop_stack "$stable_prefix" + docker exec game-staging-mysql sh -lc \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot -e "DROP DATABASE IF EXISTS `ruoyi-vue-pro`"' || return 1 + gzip -dc "$dump" | docker exec -i game-staging-mysql sh -lc \ + 'MYSQL_PWD="$MYSQL_ROOT_PASSWORD" exec mysql -uroot' || return 1 + flyway_history >"$evidence/flyway-after-compensation.tsv" || return 1 + cmp -s "$evidence/flyway-before.tsv" "$evidence/flyway-after-compensation.tsv" || return 1 + printf 'DATABASE_COMPENSATION=PASS snapshot=%s\n' "$dump" >"$evidence/database-compensation.status" +} + restore_active_pointer() { local target="$1" temp="$root/.active-rollback-$run_id" rm -f "$temp" @@ -355,6 +513,17 @@ failure_handler() { capture_journals "$candidate_prefix" candidate-failed capture_journals "$stable_prefix" stable-failed stop_stack "$candidate_prefix" + if [ "$database_restore_required" -eq 1 ]; then + log_step "迁移后失败,停止新栈并恢复迁移前数据库快照" + restore_database_snapshot + database_restore_rc=$? + printf 'database_restore_rc=%s\n' "$database_restore_rc" >"$evidence/database-restore-result.txt" + if [ "$database_restore_rc" -ne 0 ]; then + log_step "数据库补偿失败,禁止宣称服务回滚成功" + printf 'FIRST_RC_FULL_ROLLBACK=UNAVAILABLE database_restore=FAILED\n' >"$evidence/rollback-result.txt" + exit 71 + fi + fi if [ "$switch_started" -eq 1 ]; then stop_stack "$stable_prefix" if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -eq 1 ]; then @@ -363,12 +532,15 @@ failure_handler() { 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" + printf 'rollback_service_rc=not-available\nFIRST_RC_FULL_ROLLBACK=UNAVAILABLE\n' >"$evidence/rollback-result.txt" fi fi if [ "$pointer_changed" -eq 1 ]; then restore_active_pointer "$previous" fi + if [ "$bootstrap_token_validated" -eq 1 ]; then + log_step "激活失败,保留 0600 bootstrap admin token 供补偿后重试或人工吊销" + 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" @@ -391,15 +563,17 @@ case "$(stat -c '%a' "$env_file")" in *) 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" ] -) +# 只校验键存在和非空,绝不输出值;后续独立 Flyway 进程从当前受控环境读取数据库凭据。 +set -a +source "$env_file" +set +a +: "${MYSQL_ROOT_PASSWORD:?}" "${REDIS_PASSWORD:?}" "${NACOS_PASSWORD:?}" +: "${AIGC_CALLBACK_SECRET:?}" "${NEWAPI_KEY:?}" +[ -n "${DOGFOOD_PASSWORD_SEED:-}" ] || { + log_step "缺 DOGFOOD_PASSWORD_SEED,无法确定性预置内测账号" + false +} +[ "${AIGC_EXECUTOR_ENABLED:-}" = "true" ] cd "$release" sha256sum -c evidence/artifacts.sha256 >"$evidence/artifact-verification.txt" @@ -436,6 +610,22 @@ if [ "$previous_was_active" -eq 1 ] && [ "$previous_valid" -ne 1 ]; then fi printf 'previous=%s\nprevious_was_active=%s\n' "$previous" "$previous_was_active" >"$evidence/activation-context.txt" +log_step "启动不接共享数据面的候选探针;backend 因缺全局禁调度开关明确跳过" +for port in 14173 14174 18300 19501; do + if port_listening "$port"; then + log_step "候选端口已占用 port=$port" + false + fi +done +start_candidate_probe "$release" candidate "$candidate_prefix" 14173 14174 18300 19501 +capture_journals "$candidate_prefix" candidate-ready + +log_step "候选非数据面探针全绿,停止旧栈后执行独立 Flyway 迁移" +switch_started=1 +capture_journals "$stable_prefix" pre-switch-old +stop_stack "$stable_prefix" + +# 旧栈停写后再生成补偿快照,避免候选探针期间的新写入在失败恢复时丢失。 log_step "迁移前数据库快照" docker inspect game-staging-mysql >/dev/null docker exec game-staging-mysql sh -lc \ @@ -452,31 +642,15 @@ EXPECTED_AUDIT_FILE="$evidence/storage-audit.tsv" MYSQL_IMAGE="$mysql_image" \ 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_history >"$evidence/flyway-before.tsv" +migration_started=1 +database_restore_required=1 +run_flyway_migrations +flyway_history >"$evidence/flyway-after-migration.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" +log_step "独立 Flyway 迁移 PASS,开始切换正式五端口" for port in 48080 4173 4174 8300 9501; do if port_listening "$port"; then log_step "正式端口仍被非受管进程占用 port=$port" @@ -485,6 +659,114 @@ for port in 48080 4173 4174 8300 9501; do done start_stack "$release" stable "$stable_prefix" 48080 4173 4174 8300 9501 true + +log_step "校验 bootstrap admin token 文件" +[ -f "$bootstrap_admin_token_file" ] && [ ! -L "$bootstrap_admin_token_file" ] +bootstrap_token_mode="$(stat -c '%a' "$bootstrap_admin_token_file" 2>/dev/null || stat -f '%Lp' "$bootstrap_admin_token_file")" +[ "$bootstrap_token_mode" = 600 ] || { + log_step "bootstrap admin token 文件权限必须为 0600 actual=$bootstrap_token_mode" + false +} +bootstrap_token_validated=1 + +# 默认 admin 尚有效时先完成 40 人预置;事务返回即表示 Nacos 已逐字恢复,之后才允许禁用 admin。 +log_step "使用 bootstrap admin token 执行 40 人账号预置事务" +account_provision_evidence="$evidence/account-provision" +ADMIN_TOKEN_FILE="$bootstrap_admin_token_file" \ +DOGFOOD_PROVISION_ACK='PROVISION_DOGFOOD_WITH_NACOS_TRANSACTION' \ +BASE=http://127.0.0.1:48080 MYSQL_SSH_HOST='' \ +DOGFOOD_ACCOUNTS_FILE="$evidence/dogfood-accounts.tsv" \ +EVIDENCE_DIR="$account_provision_evidence" \ + bash "$release/deploy/provision-dogfood-with-nacos-transaction.sh" \ + >"$evidence/account-provision-transaction.log" 2>&1 +grep -q '^pass restored=true ' "$account_provision_evidence/transaction.status" +grep -q 'PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0' \ + "$account_provision_evidence/provision.log" || { + log_step "40 人终验缺失;若额度池不足,请先运行受控 new-api import 补建 neice_051-054" + false +} +chmod 600 "$evidence/account-provision-transaction.log" "$evidence/dogfood-accounts.tsv" +log_step "40 人账号与额度终验 PASS,Nacos 已恢复" + +log_step "轮换并验证狗粮审核账号" +reviewer_credentials="$evidence/reviewer-credentials.json" +reviewer_evidence="$evidence/reviewer-evidence.json" +reviewer_runtime_evidence="$evidence/reviewer-runtime-verification.json" +python3 "$release/deploy/manage-dogfood-reviewers.py" \ + --ack ROTATE_DOGFOOD_REVIEWERS \ + --admin-token-file "$bootstrap_admin_token_file" \ + --credentials-out "$reviewer_credentials" \ + --evidence-out "$reviewer_evidence" \ + >"$evidence/reviewer-rotation.log" 2>&1 + +# 工具已在轮换时验证默认公开密码失败;这里再验证新账号可登录,且被禁用 admin 的 bootstrap token 已失效。 +python3 - "$reviewer_credentials" "$reviewer_evidence" "$bootstrap_admin_token_file" \ + "$reviewer_runtime_evidence" <<'PY' +import json,os,stat,sys +from urllib.error import HTTPError,URLError +from urllib.request import Request,urlopen + +credentials_path,evidence_path,token_path,output_path=map(os.path.abspath,sys.argv[1:]) + +def read_private(path): + flags=os.O_RDONLY|getattr(os,"O_NOFOLLOW",0) + fd=os.open(path,flags) + with os.fdopen(fd,"r",encoding="utf-8") as handle: + mode=stat.S_IMODE(os.fstat(handle.fileno()).st_mode) + if mode != 0o600 or not stat.S_ISREG(os.fstat(handle.fileno()).st_mode): + raise RuntimeError(f"敏感文件必须是 0600 普通文件 path={path}") + return handle.read() + +credentials=json.loads(read_private(credentials_path)) +rotation=json.loads(read_private(evidence_path)) +bootstrap_token=read_private(token_path).strip() +if credentials.get("status") != "COMPLETE" or rotation.get("status") != "PASS": + raise RuntimeError("审核账号轮换输出未收口") +accounts=credentials.get("accounts") or {} +rotation_accounts={item.get("username"):item for item in rotation.get("accounts") or []} +if rotation_accounts.get("admin",{}).get("defaultPasswordLoginCode") not in (1002000000,1002000001): + raise RuntimeError("默认公开 admin 密码没有确定拒绝证据") + +def request_json(request): + try: + with urlopen(request,timeout=10) as response: + return response.status,json.loads(response.read().decode("utf-8")) + except HTTPError as error: + body=error.read().decode("utf-8") + return error.code,json.loads(body) if body else {} + except (URLError,TimeoutError,json.JSONDecodeError) as error: + raise RuntimeError("审核账号运行态验证请求失败") from error + +verified=[] +for username in ("dogfood_reviewer","dogfood_readonly"): + password=accounts.get(username) + if not isinstance(password,str) or not password: + raise RuntimeError(f"审核账号凭据缺失 username={username}") + payload=json.dumps({"username":username,"password":password}).encode("utf-8") + status,body=request_json(Request("http://127.0.0.1:48080/admin-api/system/auth/login", + data=payload,method="POST",headers={"Content-Type":"application/json","tenant-id":"1"})) + if status != 200 or body.get("code") != 0 or not (body.get("data") or {}).get("accessToken"): + raise RuntimeError(f"审核账号无法登录 username={username}") + verified.append(username) + +status,body=request_json(Request( + "http://127.0.0.1:48080/admin-api/system/auth/get-permission-info", + method="GET",headers={"Authorization":f"Bearer {bootstrap_token}","tenant-id":"1"})) +if status == 200 and body.get("code") == 0: + raise RuntimeError("bootstrap admin 旧 token 仍然有效") + +flags=os.O_WRONLY|os.O_CREAT|os.O_EXCL|getattr(os,"O_NOFOLLOW",0) +fd=os.open(output_path,flags,0o600) +with os.fdopen(fd,"w",encoding="utf-8") as handle: + json.dump({"status":"PASS","loginVerified":verified, + "defaultAdminPasswordRejected":True,"bootstrapTokenInvalidated":True}, + handle,ensure_ascii=False,indent=2) + handle.write("\n") +PY +chmod 600 "$reviewer_credentials" "$reviewer_evidence" "$reviewer_runtime_evidence" \ + "$evidence/reviewer-rotation.log" +log_step "狗粮审核账号轮换与运行态验证 PASS" + 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 @@ -499,6 +781,7 @@ printf 'active commit=%s at=%s evidence=%s\n' "$commit" "$(date -u +%Y-%m-%dT%H: stop_stack "$candidate_prefix" chmod -R go-rwx "$evidence" "$release/RC_STATUS" +delete_bootstrap_token trap - ERR log_step "RC_ACTIVATION_PASS commit=$commit active=$release" log_step "ACTIVATION_EVIDENCE=$evidence" diff --git a/deploy/prepare-staging-rc.sh b/deploy/prepare-staging-rc.sh index 17c03fbc..173c9c50 100755 --- a/deploy/prepare-staging-rc.sh +++ b/deploy/prepare-staging-rc.sh @@ -1,6 +1,6 @@ #!/usr/bin/env bash # 在 mini-desktop 的全新目录准备一个 commit-pinned staging RC。 -# 本脚本只克隆、构建和留证,不切换 current、不停止或启动任何服务。 +# 本脚本只克隆、构建和留证,不切换 active、不停止或启动任何服务。 set -euo pipefail RC_COMMIT="${RC_COMMIT:-}" @@ -85,28 +85,90 @@ printf '%s\n' "$previous" >"$evidence/rollback-target.txt" printf 'admin_stage_env_sha256=%s\n' "$(sha256sum game-admin/.env.stage | awk '{print $1}')" } >"$evidence/config-snapshot.txt" -printf '[1/6] 构建 Java 后端(跳过全量后端测试)\n' +printf '[1/7] 构建 Java 后端(构建阶段跳过全量测试,下一步单独执行安全关键集)\n' (cd game-cloud && mvn clean install -pl huijing-server -am -DskipTests) -printf '[2/6] 构建 game-studio staging\n' +printf '[2/7] 运行安全关键 Java 测试\n' +security_java_tests=( + DogfoodStartupValidatorTest + DogfoodSecurityStartupValidatorTest + DogfoodRbacSeedTest + TokenAuthenticationFilterTest + AuthenticationEntryPointImplTest + PassportServiceImplTest + ProjectServiceImplTest + RuntimePackageServiceImplTest + RuntimePackageApiImplDogfoodTest + AdminRuntimeControllerDogfoodTest + AppFeedControllerDogfoodTest + AdControllerDogfoodTest + TradeControllerDogfoodTest + AdminNewapiQuotaControllerTest + NewapiQuotaClaimConsumerTest + NewapiQuotaServiceImplTest +) +security_test_selector="$(IFS=,; printf '%s' "${security_java_tests[*]}")" +( + cd game-cloud + mvn test -pl huijing-server -am \ + -Dtest="$security_test_selector" -Dsurefire.failIfNoSpecifiedTests=false +) >"$evidence/security-java-tests.log" 2>&1 + +# 不能只相信 Maven 退出码:逐个核对 Surefire XML,防止选择器漂移后零测试仍签发 RC。 +verify_security_java_reports() { + python3 - "$evidence/security-java-tests-summary.tsv" "${security_java_tests[@]}" <<'PY' +import pathlib,sys,xml.etree.ElementTree as ET + +output=pathlib.Path(sys.argv[1]) +expected=sys.argv[2:] +rows={} +for report in pathlib.Path("game-cloud").glob("**/target/surefire-reports/TEST-*.xml"): + root=ET.parse(report).getroot() + name=root.attrib.get("name","").rsplit(".",1)[-1] + if name not in expected: + continue + tests=int(root.attrib.get("tests","0")) + failures=int(root.attrib.get("failures","0")) + errors=int(root.attrib.get("errors","0")) + skipped=int(root.attrib.get("skipped","0")) + if name in rows: + raise SystemExit(f"安全测试报告重复: {name}") + rows[name]=(tests,failures,errors,skipped) +missing=[name for name in expected if name not in rows] +if missing: + raise SystemExit("安全测试报告缺失: "+",".join(missing)) +for name,(tests,failures,errors,skipped) in rows.items(): + if tests < 1 or failures or errors: + raise SystemExit(f"安全测试未通过: {name} tests={tests} failures={failures} errors={errors}") +with output.open("w",encoding="utf-8") as handle: + handle.write("test_class\ttests\tfailures\terrors\tskipped\n") + for name in expected: + handle.write(name+"\t"+"\t".join(map(str,rows[name]))+"\n") +PY + chmod 600 "$evidence/security-java-tests-summary.tsv" +} +verify_security_java_reports + +printf '[3/7] 构建 game-studio staging\n' (cd game-studio && npm ci && npm run build -- --mode staging) -printf '[3/6] 构建 game-admin stage\n' +printf '[4/7] 构建 game-admin stage\n' corepack prepare pnpm@9.15.4 --activate (cd game-admin && pnpm install --frozen-lockfile && pnpm build:stage) -printf '[4/6] 准备 commit 内独立 Python 虚拟环境\n' +printf '[5/7] 准备 commit 内独立 Python 虚拟环境\n' UV_BIN="${UV_BIN:-/root/.local/bin/uv}" [ -x "$UV_BIN" ] || { printf '缺少 uv:%s\n' "$UV_BIN" >&2; exit 5; } (cd cheap-worker && "$UV_BIN" venv .venv && "$UV_BIN" pip install --python .venv/bin/python -r requirements.txt) (cd tier2/gen-worker && "$UV_BIN" venv .venv && "$UV_BIN" pip install --python .venv/bin/python -r requirements.txt) -printf '[5/6] 运行发布工具回归测试\n' +printf '[6/7] 运行发布工具回归测试\n' release_tool_tests=( deploy/tests/test-staging-rc.sh deploy/tests/test-provision-dogfood-users.sh deploy/tests/test-provision-dogfood-nacos-transaction.sh deploy/tests/test-import-newapi-pool.sh + deploy/tests/test-dogfood-smoke.sh ) ( export PATH="$release/cheap-worker/.venv/bin:$PATH" @@ -116,10 +178,14 @@ release_tool_tests=( done printf 'RUN game-runtime/tools/test_newapi_pool_provision.py\n' python3 -m unittest game-runtime/tools/test_newapi_pool_provision.py - printf 'RELEASE_TOOL_TESTS_PASS shell=%s python=1\n' "${#release_tool_tests[@]}" + printf 'RUN deploy/tests/test-dogfood-reviewer-security.py\n' + python3 -m unittest deploy/tests/test-dogfood-reviewer-security.py + printf 'RUN deploy/dogfood-ops/tests/test_dogfood_ops.py\n' + python3 -m unittest deploy/dogfood-ops/tests/test_dogfood_ops.py + printf 'RELEASE_TOOL_TESTS_PASS shell=%s python=3\n' "${#release_tool_tests[@]}" ) >"$evidence/release-tool-tests.log" 2>&1 -printf '[6/6] 记录制品与启动坐标\n' +printf '[7/7] 记录制品与启动坐标\n' { printf 'backend_jar=%s/game-cloud/huijing-server/target/huijing-server.jar\n' "$release" printf 'studio_dist=%s/game-studio/dist\n' "$release" @@ -142,9 +208,13 @@ printf '[6/6] 记录制品与启动坐标\n' printf 'release_tool_shell_tests=PASS\n' printf 'release_tool_shell_test_count=%s\n' "${#release_tool_tests[@]}" printf 'newapi_pool_python_tests=PASS\n' + printf 'dogfood_reviewer_python_tests=PASS\n' + printf 'dogfood_ops_python_tests=PASS\n' printf 'backend_build=PASS\n' - printf 'backend_tests=NOT_RUN\n' - printf 'backend_tests_reason=maven_build_used_DskipTests\n' + printf 'backend_security_tests=PASS\n' + printf 'backend_security_test_count=%s\n' "${#security_java_tests[@]}" + printf 'security_java_tests_sha256=%s\n' "$(sha256sum "$evidence/security-java-tests.log" | awk '{print $1}')" + printf 'security_java_tests_summary_sha256=%s\n' "$(sha256sum "$evidence/security-java-tests-summary.tsv" | awk '{print $1}')" printf 'studio_build=PASS\n' printf 'admin_build=PASS\n' } >"$evidence/verification-summary.txt" @@ -156,5 +226,5 @@ trap - ERR printf 'RC_PREPARE_PASS release=%s\n' "$release" printf 'rollback_target=%s\n' "$previous" -printf '下一步由统一部署窗口读取 evidence/artifact-paths.env,探活通过后再原子切换 current。\n' +printf '下一步由统一部署窗口读取 evidence/artifact-paths.env,探活通过后再原子切换 active。\n' REMOTE_SCRIPT diff --git a/deploy/provision-dogfood-users.sh b/deploy/provision-dogfood-users.sh index 03f3885c..33b04bf1 100755 --- a/deploy/provision-dogfood-users.sh +++ b/deploy/provision-dogfood-users.sh @@ -6,6 +6,8 @@ set -euo pipefail readonly EXPECTED_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' readonly CODE_USERNAME_EXISTS='1002090004' readonly CODE_REGISTER_DISABLED='1002090007' +readonly CODE_AD_DOGFOOD_DISABLED='1111002006' +readonly CODE_TRADE_DOGFOOD_DISABLED='1106008000' BASE="${BASE:-http://100.64.0.7:48080}" TENANT="${TENANT:-1}" @@ -18,6 +20,9 @@ MYSQL_BIN="${MYSQL_BIN:-mysql}" MYSQL_SSH_HOST="${MYSQL_SSH_HOST-__AUTO__}" MYSQL_ENV_FILE="${MYSQL_ENV_FILE:-/root/huijing-dev.env}" VERIFY_TIMEOUT_SEC="${VERIFY_TIMEOUT_SEC:-60}" +ADMIN_TOKEN_FILE="${ADMIN_TOKEN_FILE:-}" +ADMIN_USERNAME="${ADMIN_USERNAME:-}" +ADMIN_PASSWORD="${ADMIN_PASSWORD:-}" log() { printf '%s\n' "$*"; } die() { printf 'PROVISION_FAIL %s\n' "$*" >&2; exit 1; } @@ -29,8 +34,14 @@ require_env() { done } -require_env PROVISION_ACK DOGFOOD_PASSWORD_SEED ADMIN_USERNAME ADMIN_PASSWORD +require_env PROVISION_ACK DOGFOOD_PASSWORD_SEED [ "$PROVISION_ACK" = "$EXPECTED_ACK" ] || die "PROVISION_ACK 不匹配;确认临时配置后必须精确设为 $EXPECTED_ACK" +if [ -n "$ADMIN_TOKEN_FILE" ]; then + [ -z "$ADMIN_USERNAME" ] && [ -z "$ADMIN_PASSWORD" ] || \ + die "ADMIN_TOKEN_FILE 不能与 ADMIN_USERNAME/ADMIN_PASSWORD 混用" +else + require_env ADMIN_USERNAME ADMIN_PASSWORD +fi # 只允许调用 mini-desktop 本机或 Tailscale 地址,防止误向其它环境批量建号。 BASE_HOST="$(python3 - "$BASE" <<'PY' @@ -59,6 +70,26 @@ fi command -v curl >/dev/null || die "缺少 curl" command -v python3 >/dev/null || die "缺少 python3" +read_private_token() { + python3 - "$1" <<'PY' +import os,stat,sys +path=sys.argv[1] +flags=os.O_RDONLY|getattr(os,"O_NOFOLLOW",0) +try: + fd=os.open(path,flags) +except OSError as error: + raise SystemExit(f"无法安全打开 ADMIN_TOKEN_FILE:{error}") +with os.fdopen(fd,"r",encoding="utf-8") as handle: + metadata=os.fstat(handle.fileno()) + if not stat.S_ISREG(metadata.st_mode) or stat.S_IMODE(metadata.st_mode) != 0o600: + raise SystemExit("ADMIN_TOKEN_FILE 必须是 0600 普通文件") + token=handle.read().strip() +if not token or any(character.isspace() for character in token): + raise SystemExit("ADMIN_TOKEN_FILE 内容为空或包含空白字符") +print(token,end="") +PY +} + umask 077 BODY_FILE="$(mktemp)" TMP_ACCOUNTS="$(mktemp)" @@ -226,12 +257,12 @@ read_missing_claim_user_ids() { } log "预置目标:10 creator + 30 player;BASE host=$BASE_HOST" -log "运行前置:临时 dogfood=false、password-register/login=true、注册与登录 IP 时/日上限均为 60;结束后恢复狗粮配置。" +log "运行前置:dogfood=true,临时 password-register/provisioning=true,注册与登录 IP 时/日上限均为 60;结束后恢复配置。" -# dogfood=false 的只读行为校验;若仍在狗粮态,广告入口会被后端守卫拒绝。 +# 临时开窗只能放行密码注册;广告和资金入口必须继续由 dogfood 后端守卫拒绝。 request '' GET /app-api/ad/slot/list-enabled -[ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = '0' ] \ - || die "临时 provisioning 配置未生效:要求 dogfood=false(http=$LAST_HTTP code=$(json_value code))" +[ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = "$CODE_AD_DOGFOOD_DISABLED" ] \ + || die "dogfood 广告守卫未保持关闭(http=$LAST_HTTP code=$(json_value code))" read_counts MISSING_COUNT=$((40 - ACCOUNT_COUNT)) @@ -242,18 +273,23 @@ UNCLAIMED_COUNT=$((40 - CLAIMED_ACCOUNT_COUNT)) [ "$UNCLAIMED_COUNT" -ge 0 ] || die "目标账号 claim 计数异常:$CLAIMED_ACCOUNT_COUNT" REQUIRED_FREE=$((UNCLAIMED_COUNT + 12)) if [ "$FREE_COUNT" -lt "$REQUIRED_FREE" ]; then - die "额度池 FREE=${FREE_COUNT},不足以让剩余账号各 claim 一条并保留 12(claimed_accounts=${CLAIMED_ACCOUNT_COUNT} required_free=${REQUIRED_FREE});请先受控补池,本脚本不会创建 new-api 资源" + die "额度池 FREE=${FREE_COUNT},不足以让剩余账号各 claim 一条并保留 12(claimed_accounts=${CLAIMED_ACCOUNT_COUNT} required_free=${REQUIRED_FREE});请先运行受控 new-api import 补建 neice_051-054,本脚本不会创建 new-api 资源" fi # admin token 仅驻留内存,不输出、不写 accounts 文件。 -ADMIN_BODY="$(json_body "$ADMIN_USERNAME" "$ADMIN_PASSWORD")" -request '' POST /admin-api/system/auth/login "$ADMIN_BODY" -[ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = '0' ] || die "admin 登录失败" -ADMIN_TOKEN="$(json_value data.accessToken)" -[ -n "$ADMIN_TOKEN" ] || die "admin 登录响应缺 accessToken" +if [ -n "$ADMIN_TOKEN_FILE" ]; then + ADMIN_TOKEN="$(read_private_token "$ADMIN_TOKEN_FILE")" || die "ADMIN_TOKEN_FILE 安全校验失败" +else + ADMIN_BODY="$(json_body "$ADMIN_USERNAME" "$ADMIN_PASSWORD")" + request '' POST /admin-api/system/auth/login "$ADMIN_BODY" + [ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = '0' ] || die "admin 登录失败" + ADMIN_TOKEN="$(json_value data.accessToken)" + [ -n "$ADMIN_TOKEN" ] || die "admin 登录响应缺 accessToken" +fi printf 'username\trole\tuser_id\tpassword\n' >"$TMP_ACCOUNTS" TARGET_USER_IDS=() +FUND_GUARD_VERIFIED=0 for index in "${!TARGET_NAMES[@]}"; do username="${TARGET_NAMES[$index]}" if [ "$index" -lt 10 ]; then role='creator'; else role='player'; fi @@ -285,6 +321,21 @@ for index in "${!TARGET_NAMES[@]}"; do is_target_user_id "$user_id" && die "多个目标账号返回同一 userId=$user_id,拒绝继续" TARGET_USER_IDS+=("$user_id") + # mock token 已关闭;只用首个真实登录账号的 token 验证一次资金后端守卫,且不落日志或证据。 + if [ "$FUND_GUARD_VERIFIED" -eq 0 ]; then + PLAYER_TOKEN="$(json_value data.accessToken)" + [ -n "$PLAYER_TOKEN" ] || die "账号 $username 登录响应缺 accessToken,无法验证资金守卫" + request "$PLAYER_TOKEN" GET /app-api/trade/charge/points-balance + [ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = "$CODE_TRADE_DOGFOOD_DISABLED" ] \ + || die "dogfood 充值守卫未保持关闭(http=$LAST_HTTP code=$(json_value code))" + request "$PLAYER_TOKEN" GET '/app-api/trade/withdraw/page?pageNo=1&pageSize=1' + [ "$LAST_HTTP" = '200' ] && [ "$(json_value code)" = "$CODE_TRADE_DOGFOOD_DISABLED" ] \ + || die "dogfood 提现守卫未保持关闭(http=$LAST_HTTP code=$(json_value code))" + unset PLAYER_TOKEN + FUND_GUARD_VERIFIED=1 + log "已用真实玩家身份验证充值/提现狗粮守卫" + fi + if [ "$role" = 'creator' ]; then request "$ADMIN_TOKEN" PUT /admin-api/passport/player/set-creator \ "{\"userId\":$user_id,\"creatorFlag\":1}" @@ -317,7 +368,7 @@ while :; do && [ "$DUPLICATE_CLAIM_COUNT" -eq 0 ] && [ "$FREE_COUNT" -ge 12 ]; then break fi - [ "$SECONDS" -lt "$deadline" ] || die "终验未收敛:accounts=$ACCOUNT_COUNT creators=$CREATOR_COUNT role_mismatches=$ROLE_MISMATCH_COUNT claimed_accounts=$CLAIMED_ACCOUNT_COUNT duplicate_claims=$DUPLICATE_CLAIM_COUNT FREE=${FREE_COUNT};FREE 不足请受控补池,勿用本脚本创建 new-api 资源" + [ "$SECONDS" -lt "$deadline" ] || die "终验未收敛:accounts=$ACCOUNT_COUNT creators=$CREATOR_COUNT role_mismatches=$ROLE_MISMATCH_COUNT claimed_accounts=$CLAIMED_ACCOUNT_COUNT duplicate_claims=$DUPLICATE_CLAIM_COUNT FREE=${FREE_COUNT};额度池不足请先运行受控 new-api import 补建 neice_051-054" sleep 2 done diff --git a/deploy/provision-dogfood-with-nacos-transaction.sh b/deploy/provision-dogfood-with-nacos-transaction.sh index e0d29867..5eceb886 100755 --- a/deploy/provision-dogfood-with-nacos-transaction.sh +++ b/deploy/provision-dogfood-with-nacos-transaction.sh @@ -10,9 +10,19 @@ 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' + +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}" @@ -23,11 +33,14 @@ 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}" -DOGFOOD_NEGATIVE_TOKEN="${DOGFOOD_NEGATIVE_TOKEN:-test1}" +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() { @@ -38,14 +51,31 @@ 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" +[[ "$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 @@ -57,11 +87,16 @@ fi [ -n "$NACOS_PASSWORD" ] || die 'Nacos 口令为空' umask 077 -[ ! -e "$EVIDENCE_DIR" ] || die "证据目录已存在,拒绝覆盖:$EVIDENCE_DIR" -mkdir -p "$EVIDENCE_DIR" +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" -: >"$EVENT_LOG" +[ "$MODE" = watchdog ] || : >"$EVENT_LOG" chmod 600 "$EVENT_LOG" BODY_FILE="$(mktemp)" NACOS_PASSWORD_FILE="$(mktemp)" @@ -72,8 +107,11 @@ 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='' @@ -130,16 +168,19 @@ nacos_get() { [ "$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_md5() { + python3 - "$1" <<'PY' +import hashlib,sys +with open(sys.argv[1],"rb") as handle: + print(hashlib.md5(handle.read()).hexdigest()) +PY } -nacos_delete() { - request DELETE "$NACOS_BASE_URL/v1/cs/configs" \ - "dataId=$DATA_ID&group=$NACOS_GROUP&tenant=" "$NACOS_TOKEN" +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 ] } @@ -181,9 +222,10 @@ expect_code() { } verify_temporary_runtime() { - # 匿名广告位在 dogfood=false 时应正常返回;真实注册/登录由随后 provision 脚本继续实证。 - request GET "$BASE/app-api/ad/slot/list-enabled" - [ "$LAST_HTTP" = 200 ] && [ "$(json_value code)" = 0 ] + # 此时还没有真实玩家 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() { @@ -198,32 +240,78 @@ verify_restored_runtime() { 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() { + local current="$EVIDENCE_DIR/nacos-before-restore.tmp" temporary_md5 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 + 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 @@ -241,6 +329,7 @@ finalize() { 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" @@ -260,59 +349,55 @@ 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 +[ "$LAST_HTTP" = 200 ] || die 'Nacos dataId 不存在,无法执行内容 CAS;拒绝创建临时配置' # 在原 YAML 上只覆盖预置窗口所需键;原文始终单独留证并在 EXIT trap 逐字恢复。 -ORIGINAL_STATUS="$LAST_HTTP" python3 - "$ORIGINAL_CONFIG" "$TEMP_CONFIG" <<'PY' -import os,sys,yaml +PROVISION_TTL_SEC="$PROVISION_TTL_SEC" python3 - "$ORIGINAL_CONFIG" "$TEMP_CONFIG" <<'PY' +import datetime,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 {} +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", - "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 +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.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))), -}) +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" -nacos_publish "$TEMP_CONFIG" || die "发布临时 Nacos 配置失败 http=$LAST_HTTP" +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 '临时 dogfood=false 未在后端可信边界生效' +verify_temporary_runtime || die '窄 provisioning 开关未生效或安全守卫被关闭' log '临时配置已生效,执行既有账号预置脚本' -PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' \ - "$PROVISION_BIN" >"$EVIDENCE_DIR/provision.log" 2>&1 +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 '账号预置脚本完成,退出时恢复原配置' diff --git a/deploy/smoke-test.sh b/deploy/smoke-test.sh index d59c4cfd..ea5d96ec 100755 --- a/deploy/smoke-test.sh +++ b/deploy/smoke-test.sh @@ -15,6 +15,9 @@ elif [ -n "${1:-}" ]; then exit 2 fi +SYSTEMCTL_BIN="${SYSTEMCTL_BIN:-systemctl}" +PROC_ROOT="${PROC_ROOT:-/proc}" + PASS=0 FAIL=0 FAILED_ITEMS=() @@ -203,6 +206,50 @@ if [ "$MODE" = "dogfood-e2e" ]; then log "${R}DOGFOOD_E2E_ACK 无效;必须显式设置为 I_ACKNOWLEDGE_DOGFOOD_WRITES${N}" >&2 exit 2 fi + require_env EXPECTED_RC_COMMIT + if [[ ! "$EXPECTED_RC_COMMIT" =~ ^[0-9a-f]{40}$ ]]; then + log "${R}EXPECTED_RC_COMMIT 必须是完整 40 位小写提交哈希${N}" >&2 + exit 2 + fi + # 写模式只允许在 mini-desktop 本机对固定 staging 端口执行,禁止 ACK 配合 BASE 重定向到其它环境。 + if [ "$BASE" != "http://127.0.0.1:48080" ] || [ "$STUDIO_BASE" != "http://127.0.0.1:4173" ]; then + log "${R}写 smoke 仅允许 BASE=http://127.0.0.1:48080、STUDIO_BASE=http://127.0.0.1:4173${N}" >&2 + exit 2 + fi + if [ "$(hostname -s)" != "mini-desktop" ]; then + log "${R}写 smoke 只能在 mini-desktop 本机执行${N}" >&2 + exit 2 + fi + STAGING_ACTIVE_POINTER="${STAGING_ACTIVE_POINTER:-/root/game-staging/releases/active}" + if [ ! -L "$STAGING_ACTIVE_POINTER" ]; then + log "${R}缺少 staging active 指针:$STAGING_ACTIVE_POINTER${N}" >&2 + exit 2 + fi + ACTIVE_RELEASE="$(readlink -f "$STAGING_ACTIVE_POINTER" 2>/dev/null || true)" + if [ -z "$ACTIVE_RELEASE" ] || [ "${ACTIVE_RELEASE##*/}" != "$EXPECTED_RC_COMMIT" ]; then + log "${R}active 指针与 EXPECTED_RC_COMMIT 不一致${N}" >&2 + exit 2 + fi + if [ ! -f "$ACTIVE_RELEASE/RC_STATUS" ] || \ + ! grep -q "^active commit=$EXPECTED_RC_COMMIT " "$ACTIVE_RELEASE/RC_STATUS"; then + log "${R}active RC_STATUS 未证明目标 commit 已激活${N}" >&2 + exit 2 + fi + # active 指针只能证明部署意图;运行中 backend 的真实命令行也必须来自同一 RC。 + if ! "$SYSTEMCTL_BIN" is-active --quiet gda-staging-backend; then + log "${R}staging backend systemd unit 未运行${N}" >&2 + exit 2 + fi + BACKEND_PID="$("$SYSTEMCTL_BIN" show --property=MainPID --value gda-staging-backend 2>/dev/null || true)" + if ! [[ "$BACKEND_PID" =~ ^[1-9][0-9]*$ ]] || [ ! -r "$PROC_ROOT/$BACKEND_PID/cmdline" ]; then + log "${R}无法读取 staging backend 运行进程身份${N}" >&2 + exit 2 + fi + EXPECTED_BACKEND_JAR="$ACTIVE_RELEASE/game-cloud/huijing-server/target/huijing-server.jar" + if ! tr '\0' '\n' <"$PROC_ROOT/$BACKEND_PID/cmdline" | grep -Fqx -- "$EXPECTED_BACKEND_JAR"; then + log "${R}运行中的 backend 不属于 active RC${N}" >&2 + exit 2 + fi fi head_line "公开只读 readiness" @@ -390,8 +437,11 @@ if ! envelope_ok || ! printf '%s' "$LAST_BODY" | python3 -c 'import json,sys; gi fi mark_pass "readonly 审核写权限被拒绝且状态未变" -PRE_APPROVE_VERSION="$(scan_main_feed)" -PRE_APPROVE_SCAN_RC=$? +if PRE_APPROVE_VERSION="$(scan_main_feed)"; then + PRE_APPROVE_SCAN_RC=0 +else + PRE_APPROVE_SCAN_RC=$? +fi if [ "$PRE_APPROVE_SCAN_RC" -eq 0 ]; then e2e_fail "审核通过前 gameId=$GAME_ID 已进入主 feed(versionId=${PRE_APPROVE_VERSION})" elif [ "$PRE_APPROVE_SCAN_RC" -eq 1 ]; then @@ -415,8 +465,11 @@ done [ "$project_status" = "4" ] || e2e_fail "审核通过后项目未进入 published(4)" mark_pass "project published status=4" -FEED_VERSION_ID="$(scan_main_feed)" -POST_APPROVE_SCAN_RC=$? +if FEED_VERSION_ID="$(scan_main_feed)"; then + POST_APPROVE_SCAN_RC=0 +else + POST_APPROVE_SCAN_RC=$? +fi [ "$POST_APPROVE_SCAN_RC" -eq 0 ] || e2e_fail "审核通过后未能在主 feed 找到 gameId=${GAME_ID}(scanRc=${POST_APPROVE_SCAN_RC})" [ "$FEED_VERSION_ID" = "$VERSION_ID" ] || e2e_fail "feed versionId=$FEED_VERSION_ID 与生成版本 $VERSION_ID 不一致" mark_pass "exact game appears in main feed" diff --git a/deploy/tests/test-dogfood-smoke.sh b/deploy/tests/test-dogfood-smoke.sh index 9af34158..f472e15c 100755 --- a/deploy/tests/test-dogfood-smoke.sh +++ b/deploy/tests/test-dogfood-smoke.sh @@ -20,10 +20,10 @@ assert_contains() { assert_exit_code() { local expected="$1" actual="$2" message="$3" - [ "$actual" -eq "$expected" ] || fail "$message(expected=$expected actual=$actual)" + [ "$actual" -eq "$expected" ] || fail "${message}(expected=${expected} actual=${actual})" } -# 假 curl 只实现 readiness 所需响应,并记录方法与 URL;测试不访问任何真实服务。 +# 假 curl 实现完整写链状态机,并记录方法与 URL;测试不访问任何真实服务。 mkdir -p "$TMP_DIR/bin" cat >"$TMP_DIR/bin/curl" <<'FAKE_CURL' #!/usr/bin/env bash @@ -31,26 +31,77 @@ set -euo pipefail method="GET" output="" url="" +auth="" +data="" while [ "$#" -gt 0 ]; do case "$1" in -X) method="$2"; shift 2 ;; -o) output="$2"; shift 2 ;; - -w|-H|--connect-timeout|--max-time|--data) shift 2 ;; + -H) + [[ "$2" == Authorization:* ]] && auth="${2#Authorization: Bearer }" + shift 2 ;; + -w|--connect-timeout|--max-time) shift 2 ;; + --data) data="$2"; shift 2 ;; -sS) shift ;; http://*|https://*) url="$1"; shift ;; *) shift ;; esac done printf '%s %s\n' "$method" "$url" >>"$REQUEST_LOG" -case "$url" in - */actuator/health) - body='{"status":"UP"}' ;; - */app-api/feed/zones) - body='{"code":0,"data":[{"id":1}]}' ;; - */app-api/feed/stream*) - body='{"code":0,"data":{"list":[{"gameId":11,"versionId":22}],"hasMore":false}}' ;; - */app-api/runtime/package/22*) +approved_file="$REQUEST_LOG.approved" +telemetry_file="$REQUEST_LOG.telemetry" +case "$method $url" in + "POST "*/app-api/passport/password-login) + body='{"code":0,"data":{"accessToken":"creator-token","userId":101,"creatorFlag":1}}' ;; + "POST "*/admin-api/system/auth/login) + if grep -q reviewer <<<"$data"; then token=reviewer-token; else token=readonly-token; fi + body="{\"code\":0,\"data\":{\"accessToken\":\"$token\"}}" ;; + "POST "*/app-api/passport/password-register) + body='{"code":1002090007,"data":null}' ;; + "POST "*/app-api/passport/send-sms-code) + body='{"code":1002090008,"data":null}' ;; + "GET "*/app-api/ad/slot/list-enabled) + body='{"code":1111002006,"data":null}' ;; + "GET "*/app-api/trade/charge/points-balance|"GET "*/app-api/trade/withdraw/page*) + body='{"code":1106008000,"data":null}' ;; + "POST "*/app-api/studio/create) + body='{"code":0,"data":{"id":31,"aigcTaskId":32,"gameId":11}}' ;; + "GET "*/app-api/studio/task/31) + body='{"code":0,"data":{"status":"2","aigcTaskId":32,"gameId":11,"versionId":22}}' ;; + "GET "*/app-api/runtime/package/22/manifest*) + body='{"entry":"index.html"}' ;; + "GET "*/app-api/runtime/package/22*) body='{"code":0,"data":{"gameId":11,"versionId":22,"checksum":"aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa"}}' ;; + "POST "*/app-api/project/11/publish) + body='{"code":0,"data":{"admitted":true,"gates":[]}}' ;; + "GET "*/admin-api/project/review/page*) + body='{"code":0,"data":{"list":[{"id":11}]}}' ;; + "POST "*/admin-api/project/review) + if [ "$auth" = reviewer-token ]; then : >"$approved_file"; body='{"code":0,"data":true}'; + else body='{"code":1004030000,"data":null}'; fi ;; + "GET "*/app-api/project/11) + body='{"code":0,"data":{"status":"4"}}' ;; + "POST "*/app-api/runtime/session/start) + body='{"code":0,"data":{"sessionId":41}}' ;; + "POST "*/app-api/runtime/session/end) + body='{"code":0,"data":true}' ;; + "POST "*/app-api/telemetry/events/batch) + : >"$telemetry_file"; body='{"code":0,"data":{"accepted":2,"rejected":0}}' ;; + "GET "*/admin-api/telemetry/game-stat/11) + if [ -f "$telemetry_file" ]; then body='{"code":0,"data":{"playCount":1,"playEndCount":1}}'; + else body='{"code":0,"data":{"playCount":0,"playEndCount":0}}'; fi ;; + "GET "*/actuator/health) + body='{"status":"UP"}' ;; + "GET "*/app-api/feed/zones) + body='{"code":0,"data":[{"id":1}]}' ;; + "GET "*/app-api/feed/stream*) + if [ -f "$approved_file" ]; then + body='{"code":0,"data":{"list":[{"gameId":11,"versionId":22}],"hasMore":false}}' + else + body='{"code":0,"data":{"list":[{"gameId":99,"versionId":98}],"hasMore":false}}' + fi ;; + "GET "*/app-api/runtime/package/98*) + body='{"code":0,"data":{"gameId":99,"versionId":98,"checksum":"bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb"}}' ;; *) body='{"code":404,"data":null}' ;; esac @@ -62,6 +113,26 @@ chmod +x "$TMP_DIR/bin/curl" export PATH="$TMP_DIR/bin:$PATH" export REQUEST_LOG +cat >"$TMP_DIR/bin/hostname" <<'SH' +#!/usr/bin/env bash +printf 'mini-desktop\n' +SH +chmod +x "$TMP_DIR/bin/hostname" + +cat >"$TMP_DIR/bin/systemctl" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +if [ "$*" = 'is-active --quiet gda-staging-backend' ]; then + exit 0 +fi +if [ "$*" = 'show --property=MainPID --value gda-staging-backend' ]; then + printf '4242\n' + exit 0 +fi +exit 2 +SH +chmod +x "$TMP_DIR/bin/systemctl" + bash -n "$SMOKE" # 构建配置只能包含内网地址和模式开关,不能烘焙登录 token。 @@ -94,6 +165,17 @@ set -e assert_exit_code 2 "$missing_ack_rc" '缺 ACK 时没有 fail-closed' [ ! -s "$REQUEST_LOG" ] || fail '缺 ACK 时已发出网络请求' +# 写模式固定到 mini-desktop staging;ACK 不能把 BASE 重定向到任意远端。 +set +e +BASE=http://example.invalid DOGFOOD_CREATOR_USERNAME=creator DOGFOOD_CREATOR_PASSWORD=creator-pass \ + DOGFOOD_REVIEWER_USERNAME=reviewer DOGFOOD_REVIEWER_PASSWORD=reviewer-pass \ + DOGFOOD_READONLY_USERNAME=readonly DOGFOOD_READONLY_PASSWORD=readonly-pass \ + DOGFOOD_E2E_ACK=I_ACKNOWLEDGE_DOGFOOD_WRITES EXPECTED_RC_COMMIT=0000000000000000000000000000000000000000 \ + PATH="$TMP_DIR/bin:$PATH" "$SMOKE" --dogfood-e2e >"$TMP_DIR/wrong-target.out" 2>&1 +wrong_target_rc=$? +set -e +assert_exit_code 2 "$wrong_target_rc" '写模式允许重定向到非 staging BASE' + # 默认 readiness 只能发送 GET,且假服务完整返回时应通过。 PATH="$TMP_DIR/bin:$PATH" "$SMOKE" >"$TMP_DIR/readiness.out" 2>&1 assert_contains "$TMP_DIR/readiness.out" 'SMOKE_VERDICT=PASS' @@ -110,4 +192,51 @@ set -e assert_exit_code 2 "$unknown_rc" '未知参数未拒绝' [ ! -s "$REQUEST_LOG" ] || fail '未知参数触发了网络请求' +# 完整写模式回归:审核前 scan_main_feed 返回 1 是正确缺席,bash -e 不得提前退出。 +: >"$REQUEST_LOG" +rm -f "$REQUEST_LOG.approved" "$REQUEST_LOG.telemetry" +active_root="$TMP_DIR/releases" +active_release="$active_root/0000000000000000000000000000000000000000" +mkdir -p "$active_release" +ln -s "$active_release" "$active_root/active" +printf 'active commit=0000000000000000000000000000000000000000 at=test evidence=test\n' >"$active_release/RC_STATUS" +active_release="$(readlink -f "$active_root/active")" +proc_root="$TMP_DIR/proc" +mkdir -p "$proc_root/4242" +printf 'java\0-jar\0%s\0' "$TMP_DIR/releases/wrong/game-cloud/huijing-server/target/huijing-server.jar" \ + >"$proc_root/4242/cmdline" +set +e +DOGFOOD_CREATOR_USERNAME=creator DOGFOOD_CREATOR_PASSWORD=creator-pass \ + DOGFOOD_REVIEWER_USERNAME=reviewer DOGFOOD_REVIEWER_PASSWORD=reviewer-pass \ + DOGFOOD_READONLY_USERNAME=readonly DOGFOOD_READONLY_PASSWORD=readonly-pass \ + DOGFOOD_E2E_ACK=I_ACKNOWLEDGE_DOGFOOD_WRITES EXPECTED_RC_COMMIT=0000000000000000000000000000000000000000 \ + STAGING_ACTIVE_POINTER="$active_root/active" SYSTEMCTL_BIN="$TMP_DIR/bin/systemctl" PROC_ROOT="$proc_root" \ + BASE=http://127.0.0.1:48080 STUDIO_BASE=http://127.0.0.1:4173 \ + PATH="$TMP_DIR/bin:$PATH" bash "$SMOKE" --dogfood-e2e >"$TMP_DIR/wrong-process.out" 2>&1 +wrong_process_rc=$? +set -e +assert_exit_code 2 "$wrong_process_rc" '写 smoke 未拒绝与 active RC 不一致的 backend 进程' + +mkdir -p "$active_release/game-cloud/huijing-server/target" +printf 'java\0-jar\0%s\0' "$active_release/game-cloud/huijing-server/target/huijing-server.jar" \ + >"$proc_root/4242/cmdline" +set +e +DOGFOOD_CREATOR_USERNAME=creator DOGFOOD_CREATOR_PASSWORD=creator-pass \ + DOGFOOD_REVIEWER_USERNAME=reviewer DOGFOOD_REVIEWER_PASSWORD=reviewer-pass \ + DOGFOOD_READONLY_USERNAME=readonly DOGFOOD_READONLY_PASSWORD=readonly-pass \ + DOGFOOD_E2E_ACK=I_ACKNOWLEDGE_DOGFOOD_WRITES EXPECTED_RC_COMMIT=0000000000000000000000000000000000000000 \ + STAGING_ACTIVE_POINTER="$active_root/active" SYSTEMCTL_BIN="$TMP_DIR/bin/systemctl" PROC_ROOT="$proc_root" \ + BASE=http://127.0.0.1:48080 STUDIO_BASE=http://127.0.0.1:4173 \ + E2E_TIMEOUT_SEC=5 POLL_INTERVAL_SEC=0 EVIDENCE_OUT="$TMP_DIR/e2e.json" \ + PATH="$TMP_DIR/bin:$PATH" bash -e "$SMOKE" --dogfood-e2e >"$TMP_DIR/e2e.out" 2>&1 +e2e_rc=$? +set -e +if [ "$e2e_rc" -ne 0 ]; then + sed -n '1,200p' "$TMP_DIR/e2e.out" >&2 + fail "完整写模式回归失败 rc=$e2e_rc" +fi +assert_contains "$TMP_DIR/e2e.out" 'pre-approve game absent from main feed' +assert_contains "$TMP_DIR/e2e.out" 'DOGFOOD_E2E_VERDICT=PASS' +assert_contains "$TMP_DIR/e2e.json" '"status": "passed"' + printf 'PASS: dogfood smoke fail-closed regression\n' diff --git a/deploy/tests/test-provision-dogfood-nacos-transaction.sh b/deploy/tests/test-provision-dogfood-nacos-transaction.sh index e721f14a..1fa65242 100755 --- a/deploy/tests/test-provision-dogfood-nacos-transaction.sh +++ b/deploy/tests/test-provision-dogfood-nacos-transaction.sh @@ -15,6 +15,7 @@ mode_of() { cat >"$TMP_DIR/fake-curl" <<'PY' #!/usr/bin/env python3 import json,os,sys +import hashlib from pathlib import Path import yaml @@ -61,6 +62,11 @@ if "/v1/cs/configs" in url: respond(500,"false"); raise SystemExit content_arg=next((x for x in data if x.startswith("content@")),None) if not content_arg: respond(400,"false"); raise SystemExit + joined="&".join(data) + cas=next((part.split("=",1)[1] for part in joined.split("&") if part.startswith("casMd5=")),"") + current=hashlib.md5(config.read_bytes()).hexdigest() if config.exists() else "" + if not cas or cas != current: + respond(200,"false"); raise SystemExit config.write_bytes(Path(content_arg[len("content@"):]).read_bytes()) respond(200,"true"); raise SystemExit if method == "DELETE": @@ -82,7 +88,9 @@ elif url.endswith("/app-api/passport/password-login"): elif url.endswith("/app-api/passport/send-sms-code"): code=1002090008 if enabled else 0 elif "/app-api/trade/charge/points-balance" in url or "/app-api/trade/withdraw/page" in url: - code=1106008000 if enabled else 0 + # mock token 已关闭;Nacos 事务若仍用 test1 探测受保护端点,应立即因 401 失败。 + respond(401,json.dumps({"code":401,"data":None},separators=(",",":"))) + raise SystemExit else: code=404 respond(200,json.dumps({"code":code,"data":[]},separators=(",",":"))) @@ -94,6 +102,18 @@ set -euo pipefail printf '%s\n' "$*" >>"$MOCK_SYSTEMCTL_LOG" SH +cat >"$TMP_DIR/fake-systemd-run" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >>"$MOCK_SYSTEMD_RUN_LOG" +[ "${MOCK_SYSTEMD_RUN_FAIL:-0}" != 1 ] || exit 19 +SH + +cat >"$TMP_DIR/fake-flock" <<'SH' +#!/usr/bin/env bash +exit 0 +SH + cat >"$TMP_DIR/fake-provision" <<'SH' #!/usr/bin/env bash set -euo pipefail @@ -101,48 +121,75 @@ python3 - <<'PY' import os,yaml p=os.environ["MOCK_NACOS_CONFIG"] d=yaml.safe_load(open(p,encoding="utf-8")) -assert d["huijing"]["dogfood"]["enabled"] is False +assert d["huijing"]["dogfood"]["enabled"] is True +assert d["huijing"]["dogfood"]["provisioning-enabled"] is True +assert d["huijing"]["dogfood"]["provisioning-expires-at"] +assert d["huijing"]["dogfood"]["provisioning-run-id"] == os.environ["RUN_ID"] ppt=d["wanxiang"]["passport"] assert ppt["password-register-enabled"] is True assert ppt["password-login-enabled"] is True -assert min(ppt["register-ip-limit-per-hour"],ppt["register-ip-limit-per-day"], - ppt["login-ip-limit-per-hour"],ppt["login-ip-limit-per-day"]) >= 60 PY [ "${PROVISION_ACK:-}" = 'PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' ] +[ "${DOGFOOD_PROVISIONING_MODE:-}" = 'narrow' ] +[ -f "${ADMIN_TOKEN_FILE:-}" ] && [ ! -L "$ADMIN_TOKEN_FILE" ] +[ "$(stat -f '%Lp' "$ADMIN_TOKEN_FILE" 2>/dev/null || stat -c '%a' "$ADMIN_TOKEN_FILE")" = 600 ] +[ -z "${ADMIN_PASSWORD:-}" ] printf 'MOCK_PROVISION_CALLED\n' [ "${MOCK_PROVISION_FAIL:-0}" != 1 ] || exit 23 SH -chmod +x "$TMP_DIR/fake-curl" "$TMP_DIR/fake-systemctl" "$TMP_DIR/fake-provision" +chmod +x "$TMP_DIR/fake-curl" "$TMP_DIR/fake-systemctl" "$TMP_DIR/fake-systemd-run" \ + "$TMP_DIR/fake-flock" "$TMP_DIR/fake-provision" run_tx() { local evidence="$1" DOGFOOD_PROVISION_ACK='PROVISION_DOGFOOD_WITH_NACOS_TRANSACTION' \ + ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" \ NACOS_PASSWORD='test-secret-must-not-leak' \ CURL_BIN="$TMP_DIR/fake-curl" SYSTEMCTL_BIN="$TMP_DIR/fake-systemctl" \ + SYSTEMD_RUN_BIN="$TMP_DIR/fake-systemd-run" FLOCK_BIN="$TMP_DIR/fake-flock" \ PROVISION_BIN="$TMP_DIR/fake-provision" MOCK_NACOS_CONFIG="$TMP_DIR/nacos.yaml" \ MOCK_PUBLISH_COUNT="$TMP_DIR/publish-count" MOCK_SYSTEMCTL_LOG="$TMP_DIR/systemctl.log" \ + MOCK_SYSTEMD_RUN_LOG="$TMP_DIR/systemd-run.log" LOCK_FILE="$TMP_DIR/provision.lock" \ EVIDENCE_DIR="$evidence" RUN_ID="$(basename "$evidence")" RESTART_TIMEOUT_SEC=30 \ bash "$SCRIPT" } -bash -n "$SCRIPT" +run_watchdog() { + local recovery="$1" + NACOS_PASSWORD='test-secret-must-not-leak' CURL_BIN="$TMP_DIR/fake-curl" \ + SYSTEMCTL_BIN="$TMP_DIR/fake-systemctl" FLOCK_BIN="$TMP_DIR/fake-flock" \ + MOCK_NACOS_CONFIG="$TMP_DIR/nacos.yaml" MOCK_PUBLISH_COUNT="$TMP_DIR/publish-count" \ + MOCK_SYSTEMCTL_LOG="$TMP_DIR/systemctl.log" LOCK_FILE="$TMP_DIR/provision.lock" \ + RESTART_TIMEOUT_SEC=30 bash "$SCRIPT" --watchdog "$recovery" +} -# 场景一:原 dataId=404,成功后必须 DELETE 回 404;重复运行仍幂等。 +bash -n "$SCRIPT" +printf 'bootstrap-token-from-file\n' >"$TMP_DIR/bootstrap-admin.token" +chmod 600 "$TMP_DIR/bootstrap-admin.token" +rg -q 'flock' "$SCRIPT" || fail "缺单实例锁" +rg -q 'casMd5' "$SCRIPT" || fail "Nacos 发布/恢复缺内容 CAS" +rg -q 'provisioning-expires-at' "$SCRIPT" || fail "窄 provisioning 开关缺 TTL" +rg -q 'provisioning-run-id' "$SCRIPT" || fail "窄 provisioning 开关缺运行版本令牌" +rg -q -- '--watchdog' "$SCRIPT" || fail "缺 SIGKILL 独立 watchdog" +! rg -q 'dogfood.*enabled.*False' "$SCRIPT" || fail "事务仍会关闭 dogfood 总守卫" +rg -q 'password-register-enabled.*True' "$SCRIPT" || fail "事务未临时打开密码注册" +! rg -q 'provisioning charge still disabled\|provisioning withdraw still disabled' "$SCRIPT" || \ + fail "Nacos 临时窗口仍使用无效 mock token 探测受保护资金端点" + +# 场景一:原 dataId=404 时无法做内容 CAS,必须在修改 Nacos 和调用 provision 前 fail-closed。 rm -f "$TMP_DIR/nacos.yaml" "$TMP_DIR/publish-count" -run_tx "$TMP_DIR/evidence-404" >"$TMP_DIR/run-404.log" +set +e +run_tx "$TMP_DIR/evidence-404" >"$TMP_DIR/run-404.log" 2>&1 +rc=$? +set -e +[ "$rc" -ne 0 ] || fail "原 404 仍允许创建临时 Nacos 配置" [ ! -e "$TMP_DIR/nacos.yaml" ] || fail "原 404 成功后未删除临时配置" rg -q '^404$' "$TMP_DIR/evidence-404/nacos-original.status" || fail "未记录原 404" -rg -q '^pass restored=true ' "$TMP_DIR/evidence-404/transaction.status" || fail "404 场景未收口" -[ "$(mode_of "$TMP_DIR/evidence-404/nacos-temporary.yaml")" = 600 ] || fail "临时配置证据权限不是 600" while IFS= read -r evidence_file; do [ "$(mode_of "$evidence_file")" = 600 ] || fail "证据文件权限不是 600:$evidence_file" done < <(find "$TMP_DIR/evidence-404" -type f -print) if rg -q 'test-secret-must-not-leak' "$TMP_DIR/evidence-404"; then fail "Nacos 密钥进入证据"; fi -rm -f "$TMP_DIR/publish-count" -run_tx "$TMP_DIR/evidence-404-repeat" >"$TMP_DIR/run-404-repeat.log" -[ ! -e "$TMP_DIR/nacos.yaml" ] || fail "重复运行后未恢复 404" - # 场景二:已有配置必须逐字恢复,临时结构化合并不能抹掉 custom 键。 cat >"$TMP_DIR/original.yaml" <<'YAML' custom: @@ -161,20 +208,42 @@ run_tx "$TMP_DIR/evidence-existing" >"$TMP_DIR/run-existing.log" cmp -s "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" || fail "已有 Nacos 配置未逐字恢复" rg -q '^200$' "$TMP_DIR/evidence-existing/nacos-original.status" || fail "未记录原 200" rg -q 'retained: true' "$TMP_DIR/evidence-existing/nacos-temporary.yaml" || fail "临时合并丢失 custom 键" +rg -q '^pass restored=true ' "$TMP_DIR/evidence-existing/transaction.status" || fail "已有配置场景未收口" +[ "$(mode_of "$TMP_DIR/evidence-existing/nacos-temporary.yaml")" = 600 ] || fail "临时配置证据权限不是 600" +rg -q -- '--watchdog' "$TMP_DIR/systemd-run.log" || fail "未注册独立 watchdog" +rg -q 'Restart=on-failure' "$TMP_DIR/systemd-run.log" || fail "watchdog 抢锁失败后不会自动重试" -# 场景三:provision 失败仍必须恢复原 404,并保留原失败码。 -rm -f "$TMP_DIR/nacos.yaml" "$TMP_DIR/publish-count" +# watchdog 注册失败必须发生在 Nacos 写入前,且不能留下假的 armed 状态。 +cp "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" +rm -f "$TMP_DIR/publish-count" +set +e +MOCK_SYSTEMD_RUN_FAIL=1 run_tx "$TMP_DIR/evidence-watchdog-arm-fail" >"$TMP_DIR/watchdog-arm-fail.log" 2>&1 +rc=$? +set -e +[ "$rc" -ne 0 ] || fail "watchdog 注册失败仍继续预置" +cmp -s "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" || fail "watchdog 注册失败前已修改 Nacos" +[ ! -e "$TMP_DIR/evidence-watchdog-arm-fail/watchdog/armed" ] || fail "watchdog 注册失败留下 armed 假状态" + +# 重复运行必须仍以最新原文做 CAS,并在退出时逐字恢复。 +rm -f "$TMP_DIR/publish-count" +run_tx "$TMP_DIR/evidence-existing-repeat" >"$TMP_DIR/run-existing-repeat.log" +cmp -s "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" || fail "重复运行后未逐字恢复" + +# 场景三:provision 失败仍必须恢复原配置,并保留原失败码。 +cp "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" +rm -f "$TMP_DIR/publish-count" set +e MOCK_PROVISION_FAIL=1 run_tx "$TMP_DIR/evidence-provision-fail" >"$TMP_DIR/run-provision-fail.log" 2>&1 rc=$? set -e [ "$rc" -eq 23 ] || fail "provision 失败未保留 rc=23,actual=$rc" -[ ! -e "$TMP_DIR/nacos.yaml" ] || fail "provision 失败后未恢复原 404" +cmp -s "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" || fail "provision 失败后未恢复原配置" rg -q '^provision_failed rc=23 restored=true ' "$TMP_DIR/evidence-provision-fail/transaction.status" || \ fail "provision 失败证据状态错误" # 场景四:恢复 DELETE 失败必须硬失败 70,不能把 provision 成功当事务成功。 -rm -f "$TMP_DIR/nacos.yaml" "$TMP_DIR/publish-count" +cp "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" +rm -f "$TMP_DIR/publish-count" set +e MOCK_RESTORE_FAIL=1 run_tx "$TMP_DIR/evidence-restore-fail" >"$TMP_DIR/run-restore-fail.log" 2>&1 rc=$? @@ -184,6 +253,29 @@ rg -q '^restore_failed main_rc=0 ' "$TMP_DIR/evidence-restore-fail/transaction.s fail "恢复失败证据状态错误" rg -q 'DOGFOOD_NACOS_TX_RESTORE_FAIL' "$TMP_DIR/run-restore-fail.log" || fail "恢复失败未明确报警" +# 场景五:模拟父进程 SIGKILL 后遗留临时配置;独立 watchdog 必须凭 armed + CAS 恢复原文。 +watchdog_recovery="$TMP_DIR/evidence-existing/watchdog" +cp "$TMP_DIR/evidence-existing/nacos-temporary.yaml" "$TMP_DIR/nacos.yaml" +: >"$watchdog_recovery/armed" +chmod 600 "$watchdog_recovery/armed" +rm -f "$TMP_DIR/publish-count" +run_watchdog "$watchdog_recovery" >"$TMP_DIR/watchdog.log" +cmp -s "$TMP_DIR/original.yaml" "$TMP_DIR/nacos.yaml" || fail "watchdog 未恢复原配置" +rg -q '^WATCHDOG_RESTORE=PASS ' "$watchdog_recovery/watchdog.status" || fail "watchdog 未留下恢复证据" +[ ! -e "$watchdog_recovery/armed" ] || fail "watchdog 成功后仍保留 armed" + +# CAS 冲突时 watchdog 不能覆盖外部新配置,必须保留 armed 并硬失败等待人工处置。 +printf 'external: changed\n' >"$TMP_DIR/nacos.yaml" +: >"$watchdog_recovery/armed" +chmod 600 "$watchdog_recovery/armed" +set +e +run_watchdog "$watchdog_recovery" >"$TMP_DIR/watchdog-cas-conflict.log" 2>&1 +rc=$? +set -e +[ "$rc" -ne 0 ] || fail "watchdog CAS 冲突仍报告成功" +rg -q '^external: changed$' "$TMP_DIR/nacos.yaml" || fail "watchdog CAS 冲突覆盖了外部配置" +[ -e "$watchdog_recovery/armed" ] || fail "watchdog CAS 冲突后错误解除 armed" + restart_count="$(wc -l <"$TMP_DIR/systemctl.log" | tr -d ' ')" [ "$restart_count" -ge 8 ] || fail "临时/恢复重启次数不足:$restart_count" diff --git a/deploy/tests/test-provision-dogfood-users.sh b/deploy/tests/test-provision-dogfood-users.sh index 469515f4..ea256f99 100755 --- a/deploy/tests/test-provision-dogfood-users.sh +++ b/deploy/tests/test-provision-dogfood-users.sh @@ -32,6 +32,7 @@ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer state_file = os.environ["DOGFOOD_FAKE_STATE"] port_file = os.environ["DOGFOOD_FAKE_PORT_FILE"] +request_log = os.environ["DOGFOOD_FAKE_REQUEST_LOG"] def load_state(): @@ -67,20 +68,39 @@ class Handler(BaseHTTPRequestHandler): length = int(self.headers.get("Content-Length", "0")) return json.loads(self.rfile.read(length) or b"{}") + def record(self): + with open(request_log, "a", encoding="utf-8") as handle: + handle.write(f"{self.command} {self.path}\n") + def do_GET(self): + self.record() if self.path == "/app-api/ad/slot/list-enabled": - self.response({"code": 0, "data": []}) + self.response({"code": 1111002006, "data": None}) + return + if self.path == "/app-api/trade/charge/points-balance" or self.path.startswith( + "/app-api/trade/withdraw/page?" + ): + authorization = self.headers.get("Authorization") + if authorization == "Bearer fake-player-token": + self.response({"code": 1106008000, "data": None}) + else: + # mock token 已关闭:test1 或无 token 都先被可信鉴权边界拒绝。 + self.response({"code": 401, "data": None}, 401) return self.response({"code": 404}, 404) def do_POST(self): + self.record() body = self.json_body() if self.path == "/admin-api/system/auth/login": self.response({"code": 0, "data": {"accessToken": "fake-admin-token"}}) return state = load_state() if self.path.startswith("/admin-api/aigc/quota/claims/") and self.path.endswith("/reconcile"): - if self.headers.get("Authorization") != "Bearer fake-admin-token": + if self.headers.get("Authorization") not in ( + "Bearer fake-admin-token", + "Bearer bootstrap-token-from-file", + ): self.response({"code": 401, "msg": "未授权"}, 401) return try: @@ -125,6 +145,7 @@ class Handler(BaseHTTPRequestHandler): self.response({"code": 404}, 404) def do_PUT(self): + self.record() if self.path != "/admin-api/passport/player/set-creator": self.response({"code": 404}, 404) return @@ -222,9 +243,10 @@ chmod +x "$TMP_DIR/ssh" DOGFOOD_FAKE_STATE="$TMP_DIR/state.json" \ DOGFOOD_FAKE_PORT_FILE="$TMP_DIR/port" \ +DOGFOOD_FAKE_REQUEST_LOG="$TMP_DIR/requests.log" \ python3 "$TMP_DIR/fake-server.py" >"$TMP_DIR/server.log" 2>&1 & SERVER_PID=$! -for _ in $(seq 1 50); do +for _ in $(seq 1 300); do [ -s "$TMP_DIR/port" ] && break sleep 0.1 done @@ -237,8 +259,7 @@ run_provision() { local expected="${3:-pass}" if ! PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' \ DOGFOOD_PASSWORD_SEED='test-seed-must-not-appear' \ - ADMIN_USERNAME='admin' \ - ADMIN_PASSWORD='admin-password-must-not-appear' \ + ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" \ BASE="http://127.0.0.1:$PORT" \ MYSQL_BIN="$TMP_DIR/mysql" \ MYSQL_PASSWORD='fake-mysql-password' \ @@ -257,8 +278,19 @@ run_provision() { [ "$expected" = pass ] || fail "缺失 claim 的终验场景被错误放行:$DOGFOOD_FAKE_COUNTS_MODE" } +printf 'bootstrap-token-from-file\n' >"$TMP_DIR/bootstrap-admin.token" +chmod 600 "$TMP_DIR/bootstrap-admin.token" + # 第一遍模拟 40 条注册消息全部异常:账号已创建但 claim 全缺,脚本必须逐个主动补偿。 run_provision "$TMP_DIR/first.log" +! rg -q '^POST /admin-api/system/auth/login$' "$TMP_DIR/requests.log" || \ + fail "ADMIN_TOKEN_FILE 场景仍触发 admin 密码登录" +for protected_path in \ + '/app-api/ad/slot/list-enabled' \ + '/app-api/trade/charge/points-balance' \ + '/app-api/trade/withdraw/page'; do + rg -q "^GET ${protected_path}" "$TMP_DIR/requests.log" || fail "未预检安全守卫:$protected_path" +done python3 - "$TMP_DIR/state.json" <<'PY' import json, sys with open(sys.argv[1], encoding="utf-8") as handle: @@ -310,9 +342,42 @@ for log_file in "$TMP_DIR/first.log" "$TMP_DIR/second.log" "$TMP_DIR/recover-one || fail "缺少终验通过证据:$log_file" ! grep -q 'test-seed-must-not-appear' "$log_file" || fail "日志泄漏 seed" ! grep -q 'admin-password-must-not-appear' "$log_file" || fail "日志泄漏 admin 密码" - ! grep -q 'fake-admin-token\|fake-player-token\|fake-mysql-password' "$log_file" || fail "日志泄漏 token 或数据库密码" + ! grep -q 'bootstrap-token-from-file\|fake-admin-token\|fake-player-token\|fake-mysql-password' "$log_file" || \ + fail "日志泄漏 token 或数据库密码" done +# 手工场景仍可显式使用用户名密码;不能与 token 文件混用。 +rm -f "$TMP_DIR/manual-requests.log" +mv "$TMP_DIR/requests.log" "$TMP_DIR/token-requests.log" +: >"$TMP_DIR/requests.log" +PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' \ + DOGFOOD_PASSWORD_SEED='test-seed-must-not-appear' ADMIN_USERNAME='admin' \ + ADMIN_PASSWORD='admin-password-must-not-appear' BASE="http://127.0.0.1:$PORT" \ + MYSQL_BIN="$TMP_DIR/mysql" MYSQL_PASSWORD='fake-mysql-password' MYSQL_SSH_HOST='' \ + DOGFOOD_FAKE_STATE="$TMP_DIR/state.json" DOGFOOD_ACCOUNTS_FILE="$TMP_DIR/manual-accounts.tsv" \ + VERIFY_TIMEOUT_SEC=2 PATH="$TMP_DIR:$PATH" "$SCRIPT" >"$TMP_DIR/manual.log" 2>&1 +rg -q '^POST /admin-api/system/auth/login$' "$TMP_DIR/requests.log" || fail "手工账号密码模式未登录 admin" + +# token 文件必须是 0600 普通文件,且两种认证来源不能同时提供。 +chmod 644 "$TMP_DIR/bootstrap-admin.token" +if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_SEED=x \ + ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" BASE="http://127.0.0.1:$PORT" \ + "$SCRIPT" >"$TMP_DIR/wide-token.log" 2>&1; then + fail "权限过宽的 ADMIN_TOKEN_FILE 未被拒绝" +fi +chmod 600 "$TMP_DIR/bootstrap-admin.token" +ln -s "$TMP_DIR/bootstrap-admin.token" "$TMP_DIR/bootstrap-admin-link.token" +if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_SEED=x \ + ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin-link.token" BASE="http://127.0.0.1:$PORT" \ + "$SCRIPT" >"$TMP_DIR/symlink-token.log" 2>&1; then + fail "symlink ADMIN_TOKEN_FILE 未被拒绝" +fi +if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_SEED=x \ + ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" ADMIN_USERNAME=admin ADMIN_PASSWORD=secret \ + BASE="http://127.0.0.1:$PORT" "$SCRIPT" >"$TMP_DIR/mixed-auth.log" 2>&1; then + fail "token 文件与用户名密码混用未被拒绝" +fi + # 数据库缺失查询若返回本次 40 人之外的编号,脚本必须在请求后端前拒绝,防止越权消耗额度。 DOGFOOD_FAKE_MISSING_MODE=rogue run_provision "$TMP_DIR/rogue-missing-id.log" '' fail rg -q '不属于本次 40 个目标账号' "$TMP_DIR/rogue-missing-id.log" || fail "未拒绝越界补偿 userId" @@ -322,12 +387,12 @@ DOGFOOD_FAKE_COUNTS_MODE=duplicate run_provision "$TMP_DIR/duplicate-claims.log" rg -q 'duplicate_claims=1' "$TMP_DIR/duplicate-claims.log" || fail "未报告重复 claim" # 危险边界必须在任何网络请求前拒绝。 -if PROVISION_ACK='wrong' DOGFOOD_PASSWORD_SEED=x ADMIN_USERNAME=x ADMIN_PASSWORD=x \ +if PROVISION_ACK='wrong' DOGFOOD_PASSWORD_SEED=x ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" \ BASE="http://127.0.0.1:$PORT" "$SCRIPT" >"$TMP_DIR/bad-ack.log" 2>&1; then fail "错误 ACK 未被拒绝" fi if PROVISION_ACK='PROVISION_DOGFOOD_40_USERS_WITH_TEMP_CONFIG' DOGFOOD_PASSWORD_SEED=x \ - ADMIN_USERNAME=x ADMIN_PASSWORD=x BASE='http://localhost:48080' \ + ADMIN_TOKEN_FILE="$TMP_DIR/bootstrap-admin.token" BASE='http://localhost:48080' \ "$SCRIPT" >"$TMP_DIR/bad-base.log" 2>&1; then fail "非法 BASE host 未被拒绝" fi diff --git a/deploy/tests/test-staging-rc.sh b/deploy/tests/test-staging-rc.sh index 9808feef..60b1a73c 100755 --- a/deploy/tests/test-staging-rc.sh +++ b/deploy/tests/test-staging-rc.sh @@ -8,7 +8,7 @@ PREPARE="$ROOT_DIR/deploy/prepare-staging-rc.sh" SMOKE="$ROOT_DIR/deploy/smoke-test.sh" STORAGE_AUDIT="$ROOT_DIR/deploy/generate-staging-storage-audit.sh" RESTORE_VERIFY="$ROOT_DIR/deploy/verify-mysql-dump-restore.sh" -GENERATION_GATE_REF="sha256:$(printf '0%.0s' $(seq 1 64)):evidence/generation-r1-gate.txt" +GENERATION_GATE_REF="sha256:$(printf '0%.0s' $(seq 1 64)):r1/$(printf '0%.0s' $(seq 1 64)).attestation" TMP_DIR="$(mktemp -d)" SERVER_PID="" @@ -41,6 +41,7 @@ chmod +x "$TMP_DIR/fake-ssh" export RC_TEST_TMP="$TMP_DIR" RC_COMMIT="0000000000000000000000000000000000000000" \ GENERATION_GATE_EVIDENCE_REF="$GENERATION_GATE_REF" \ +BOOTSTRAP_ADMIN_TOKEN_FILE=/root/game-staging/credentials/bootstrap-admin.token \ SSH_BIN="$TMP_DIR/fake-ssh" \ bash "$ACTIVATE" >"$TMP_DIR/activate-wrapper.log" @@ -55,15 +56,60 @@ rg -q 'start_stack.*48080 4173 4174 8300 9501 true' "$TMP_DIR/remote-script.sh" rg -q 'mv -Tf.*active_pointer' "$TMP_DIR/remote-script.sh" || fail "active 指针不是原子替换" rg -Fq 'spring.cloud.nacos.discovery.enabled=${discovery_enabled}' "$TMP_DIR/remote-script.sh" || \ fail "backend launcher 没有显式控制 Nacos discovery" -rg -q 'start_stack.*18080 14173 14174 18300 19501 false' "$TMP_DIR/remote-script.sh" || \ - fail "候选栈没有关闭 Nacos discovery" +rg -q 'start_candidate_probe.*14173 14174 18300 19501' "$TMP_DIR/remote-script.sh" || \ + fail "候选非数据面探针未启动" rg -q 'verify_generation_gate_evidence' "$TMP_DIR/remote-script.sh" || fail "缺生成 R1 证据校验" +! rg -q 'git .*cat-file.*expected_commit' "$TMP_DIR/remote-script.sh" || fail "R1 attestation 仍要求属于被证明 commit,存在自引用" +rg -q 'attestation_root' "$TMP_DIR/remote-script.sh" || fail "缺独立 R1 attestation 根目录" +rg -q 'flock -n' "$TMP_DIR/remote-script.sh" || fail "activation 缺单实例部署锁" +rg -q 'candidate_backend=SKIPPED_NO_ISOLATED_DATA_PLANE' "$TMP_DIR/remote-script.sh" || \ + fail "候选探活没有明确跳过共享数据面 backend" +rg -q 'run_flyway_migrations' "$TMP_DIR/remote-script.sh" || fail "迁移未拆成独立受控步骤" +rg -q 'restore_database_snapshot' "$TMP_DIR/remote-script.sh" || fail "迁移后失败缺数据库快照补偿" +rg -q 'FIRST_RC_FULL_ROLLBACK=UNAVAILABLE' "$TMP_DIR/remote-script.sh" || fail "首个 RC 未明确禁止宣称完整回滚" +rg -q 'manage-dogfood-reviewers\.py' "$TMP_DIR/remote-script.sh" || fail "激活未调用审核账号轮换工具" +rg -q 'provision-dogfood-with-nacos-transaction\.sh' "$TMP_DIR/remote-script.sh" || \ + fail "激活未在 admin 禁用前执行账号预置事务" +rg -q 'ADMIN_TOKEN_FILE="\$bootstrap_admin_token_file"' "$TMP_DIR/remote-script.sh" || \ + fail "激活账号预置未使用 bootstrap token 文件" +rg -q 'PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0' \ + "$TMP_DIR/remote-script.sh" || fail "激活未核对 40 人账号终验" +rg -q '请先运行受控 new-api import' "$TMP_DIR/remote-script.sh" || \ + fail "额度池不足没有给出受控 new-api import 指引" +rg -q -- '--ack ROTATE_DOGFOOD_REVIEWERS' "$TMP_DIR/remote-script.sh" || fail "审核账号轮换 ACK 不精确" +rg -q 'reviewer-runtime-verification\.json' "$TMP_DIR/remote-script.sh" || fail "缺 reviewer/readonly 登录与旧 token 失效运行态证据" +rg -q 'rm -f.*bootstrap_admin_token_file' "$TMP_DIR/remote-script.sh" || fail "轮换成功后未删除 bootstrap token 文件" +! rg -q 'admin123' "$ACTIVATE" || fail "激活脚本写入了默认公开密码" for required_marker in actor_prompt_eval judge_prompt_eval game_content_gold live_prompt full_gate fresh_25 historical_11 production_shadow_20 runtime_eligible; do rg -q "$required_marker" "$TMP_DIR/remote-script.sh" || fail "生成门未核对 R1 字段:$required_marker" done gate_line="$(rg -n 'verify_generation_gate_evidence' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" -snapshot_line="$(rg -n '迁移前数据库快照' "$TMP_DIR/remote-script.sh" | cut -d: -f1)" +snapshot_line="$(rg -n '迁移前数据库快照' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" [ "$gate_line" -lt "$snapshot_line" ] || fail "生成门校验没有发生在数据库快照/迁移前" +candidate_probe_line="$(rg -n '启动不接共享数据面的候选探针' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +stop_old_line="$(rg -n '候选非数据面探针全绿,停止旧栈' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +restore_verify_line="$(rg -n '在隔离临时 MySQL 容器中验证快照可恢复' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +migration_line="$(rg -n '^run_flyway_migrations$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +[ "$candidate_probe_line" -lt "$stop_old_line" ] || fail "候选非数据面探针没有在停止旧栈前完成" +[ "$stop_old_line" -lt "$snapshot_line" ] || fail "迁移快照前旧栈仍可能写入,失败补偿会丢数据" +[ "$snapshot_line" -lt "$restore_verify_line" ] || fail "隔离恢复验证没有使用停写后的最终快照" +[ "$restore_verify_line" -lt "$migration_line" ] || fail "未先完成隔离恢复验证就执行正式迁移" +reviewer_line="$(rg -n 'manage-dogfood-reviewers\.py' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +provision_line="$(rg -n 'provision-dogfood-with-nacos-transaction\.sh' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +provision_pass_line="$(rg -n 'PROVISION_PASS accounts=40 creators=10 role_mismatches=0 claimed_accounts=40 duplicate_claims=0' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +runtime_verify_line="$(rg -n '狗粮审核账号轮换与运行态验证 PASS' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +stable_smoke_line="$(rg -n 'stable-smoke\.log' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +active_switch_line="$(rg -n '正式栈全绿,原子切换 active 指针' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +[ "$provision_line" -lt "$provision_pass_line" ] || fail "账号预置后未核对终验" +[ "$provision_pass_line" -lt "$reviewer_line" ] || fail "admin 禁用发生在账号预置恢复前" +[ "$reviewer_line" -lt "$runtime_verify_line" ] || fail "审核账号轮换后未做运行态验证" +[ "$runtime_verify_line" -lt "$stable_smoke_line" ] || fail "审核账号运行态验证没有位于写 smoke 前" +[ "$stable_smoke_line" -lt "$active_switch_line" ] || fail "active 指针在 smoke 前切换" +token_delete_line="$(rg -n '^delete_bootstrap_token$' "$TMP_DIR/remote-script.sh" | tail -1 | cut -d: -f1)" +[ "$active_switch_line" -lt "$token_delete_line" ] || fail "bootstrap token 未在 smoke+active 后最后删除" +failure_handler_body="$(sed -n '/^failure_handler()/,/^}/p' "$TMP_DIR/remote-script.sh")" +! printf '%s\n' "$failure_handler_body" | rg -q '^ delete_bootstrap_token' || \ + fail "激活失败路径删除了补偿后重试所需的 bootstrap token" if rg -q '/root/games-development-ai' "$TMP_DIR/remote-script.sh"; then fail "激活脚本引用了远端脏旧仓" fi @@ -88,6 +134,14 @@ if RC_COMMIT="0000000000000000000000000000000000000000" \ fi [ ! -s "$TMP_DIR/ssh-called" ] || fail "非法生成门路径后仍发生 SSH" +# 真激活缺 bootstrap token 文件路径时必须在 SSH 前 fail-closed,不能回退仓库默认密码。 +if RC_COMMIT="0000000000000000000000000000000000000000" \ + GENERATION_GATE_EVIDENCE_REF="$GENERATION_GATE_REF" SSH_BIN="$TMP_DIR/reject-ssh" \ + bash "$ACTIVATE" >"$TMP_DIR/missing-bootstrap-token.log" 2>&1; then + fail "缺 BOOTSTRAP_ADMIN_TOKEN_FILE 仍允许激活" +fi +[ ! -s "$TMP_DIR/ssh-called" ] || fail "缺 bootstrap token 路径后仍发生 SSH" + # 旧变量仅允许受控路径和 hash;任意 URL 必须在 SSH 前失败。 if RC_COMMIT="0000000000000000000000000000000000000000" \ GENERATION_GATE_EVIDENCE_REF="$GENERATION_GATE_REF" \ @@ -96,17 +150,92 @@ if RC_COMMIT="0000000000000000000000000000000000000000" \ fail "任意 OBJECT_SNAPSHOT_REF 仍允许激活" fi -# prepare 必须在 ready 前运行本任务全部 shell 回归,并明确记录跳过的后端测试。 +# prepare 必须在 ready 前运行发布工具回归与安全关键 Java 测试,并把日志哈希绑定 RC commit。 for test_name in test-staging-rc.sh test-provision-dogfood-users.sh \ - test-provision-dogfood-nacos-transaction.sh test-import-newapi-pool.sh; do + test-provision-dogfood-nacos-transaction.sh test-import-newapi-pool.sh \ + test-dogfood-smoke.sh; do rg -q "$test_name" "$PREPARE" || fail "prepare 未运行发布工具回归:$test_name" done rg -q 'release-tool-tests\.log' "$PREPARE" || fail "prepare 未保存发布工具测试日志" -rg -q 'backend_tests=NOT_RUN' "$PREPARE" || fail "prepare 未诚实记录后端测试未运行" +rg -q 'security-java-tests\.log' "$PREPARE" || fail "prepare 未保存安全关键 Java 测试日志" +rg -q 'DogfoodStartupValidatorTest' "$PREPARE" || fail "prepare 未运行 Dogfood 启动安全测试" +rg -q 'DogfoodSecurityStartupValidatorTest' "$PREPARE" || fail "prepare 未运行 Dogfood 配置安全测试" +rg -q 'TokenAuthenticationFilterTest' "$PREPARE" || fail "prepare 未运行令牌认证过滤器测试" +rg -q 'AuthenticationEntryPointImplTest' "$PREPARE" || fail "prepare 未运行未认证响应测试" +rg -q 'RuntimePackageServiceImplTest' "$PREPARE" || fail "prepare 未运行 artifact CAS 测试" +rg -q 'RuntimePackageApiImplDogfoodTest' "$PREPARE" || fail "prepare 未运行 runtime RPC 安全测试" +rg -q 'AdminNewapiQuotaControllerTest' "$PREPARE" || fail "prepare 未运行配额修复入口测试" +rg -q 'NewapiQuotaClaimConsumerTest' "$PREPARE" || fail "prepare 未运行配额 MQ 消费测试" +rg -q 'NewapiQuotaServiceImplTest' "$PREPARE" || fail "prepare 未运行配额服务测试" +rg -q 'test-dogfood-reviewer-security\.py' "$PREPARE" || fail "prepare 未运行审核账号安全测试" +rg -q 'test_dogfood_ops\.py' "$PREPARE" || fail "prepare 未运行波次证据账本测试" +rg -q 'backend_security_tests=PASS' "$PREPARE" || fail "prepare 未把安全 Java 测试结果绑定 RC" +rg -q 'security_java_tests_sha256=' "$PREPARE" || fail "prepare 摘要未绑定安全测试日志哈希" +rg -q 'verify_security_java_reports' "$PREPARE" || fail "prepare 未校验安全测试报告实际包含全部目标类" +rg -q 'security-java-tests-summary\.tsv' "$PREPARE" || fail "prepare 未保存可审计的 Java 测试计数摘要" +rg -q 'security_java_tests_summary_sha256=' "$PREPARE" || fail "prepare 摘要未绑定 Java 测试计数证据" +! rg -q 'backend_tests=NOT_RUN' "$PREPARE" || fail "prepare 仍宣称后端测试未运行" venv_line="$(rg -n '准备 commit 内独立 Python 虚拟环境' "$PREPARE" | cut -d: -f1)" tool_test_line="$(rg -n '运行发布工具回归测试' "$PREPARE" | cut -d: -f1)" [ "$venv_line" -lt "$tool_test_line" ] || fail "发布工具回归未使用 commit 内已建 venv" rg -q 'cheap-worker/\.venv/bin.*PATH' "$PREPARE" || fail "发布工具回归未显式使用 commit 内 Python" +rg -q '原子切换 active' "$PREPARE" || fail "prepare 完成提示仍未使用 active 指针" + +# 独立 attestation 不属于被证明 commit:内容寻址文件应通过,宽权限、symlink、commit 漂移必须拒绝。 +cat >"$TMP_DIR/exec-ssh" <<'SH' +#!/usr/bin/env bash +set -euo pipefail +while [ "$#" -gt 0 ] && [ "$1" != bash ]; do shift; done +[ "${1:-}" = bash ] || exit 97 +exec "$@" +SH +chmod +x "$TMP_DIR/exec-ssh" +attestation_root="$(realpath "$TMP_DIR")/attestations" +mkdir -p "$attestation_root/r1" +attestation_file="$attestation_root/r1/r1.attestation" +cat >"$attestation_file" <"$TMP_DIR/attestation-pass.log" +rg -q 'GENERATION_ATTESTATION_PASS' "$TMP_DIR/attestation-pass.log" || fail "独立 attestation 未通过" + +chmod 644 "$attestation_file" +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "权限过宽 attestation 未拒绝" +fi +chmod 600 "$attestation_file" +ln -s "$attestation_file" "$attestation_root/r1/symlink.attestation" +if RC_COMMIT=0000000000000000000000000000000000000000 \ + GENERATION_GATE_EVIDENCE_REF="sha256:$attestation_hash:r1/symlink.attestation" \ + GENERATION_ATTESTATION_ROOT="$attestation_root" SSH_BIN="$TMP_DIR/exec-ssh" \ + bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "symlink attestation 未拒绝" +fi +if RC_COMMIT=1111111111111111111111111111111111111111 \ + GENERATION_GATE_EVIDENCE_REF="$attestation_ref" GENERATION_ATTESTATION_ROOT="$attestation_root" \ + SSH_BIN="$TMP_DIR/exec-ssh" bash "$ACTIVATE" --verify-r1-attestation >/dev/null 2>&1; then + fail "rc_commit 漂移 attestation 未拒绝" +fi # 直接验证 DB-only 审计:生成、外部引用 fail-closed、旧 hash/路径校验。 FAKE_RC="$TMP_DIR/fake-rc" @@ -267,7 +396,7 @@ export RC_TEST_PORT_FILE="$TMP_DIR/port" export RC_TEST_METHOD_FILE="$TMP_DIR/methods" python3 "$TMP_DIR/fake-readiness.py" & SERVER_PID=$! -for _ in $(seq 1 50); do +for _ in $(seq 1 300); do [ -s "$TMP_DIR/port" ] && break sleep 0.1 done