From 5e2d038938265c926e06d3cc6d0841f6dcc06148 Mon Sep 17 00:00:00 2001 From: shunli Date: Sat, 8 Aug 2026 11:11:19 +0800 Subject: [PATCH] =?UTF-8?q?feat(08-08-production-deployment-validation):?= =?UTF-8?q?=20=E5=A2=9E=E5=8A=A0=E7=94=9F=E4=BA=A7=E7=9B=AE=E6=A0=87?= =?UTF-8?q?=E4=B8=8E=E8=BF=81=E7=A7=BB=E5=8F=AA=E8=AF=BB=E9=97=A8=E7=A6=81?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- deploy/trialrun/deploy-existing.sh | 61 ++++ deploy/trialrun/deploy.sh | 15 +- .../trialrun/production-preflight-remote.sh | 280 ++++++++++++++++ deploy/trialrun/production-preflight.mjs | 316 ++++++++++++++++++ deploy/trialrun/validate-env-contract.js | 85 +++++ .../deployment-safety-contract.test.js | 211 +++++++++++- 6 files changed, 965 insertions(+), 3 deletions(-) create mode 100755 deploy/trialrun/production-preflight-remote.sh create mode 100755 deploy/trialrun/production-preflight.mjs diff --git a/deploy/trialrun/deploy-existing.sh b/deploy/trialrun/deploy-existing.sh index 042ce7a4..cdce062b 100755 --- a/deploy/trialrun/deploy-existing.sh +++ b/deploy/trialrun/deploy-existing.sh @@ -5,6 +5,8 @@ ROOT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")/../.." && pwd)" DEPLOY_SERVER="${DEPLOY_SERVER:-}" DEPLOY_REFRESH_SERVER="${DEPLOY_REFRESH_SERVER:-}" DEPLOY_CONTRACT_UNIT="${DEPLOY_CONTRACT_UNIT:-hyb-oa-automatic-backup-daily.service}" +PROVENANCE_SCRIPT="deploy/trialrun/check-source-provenance.mjs" +PRODUCTION_PREFLIGHT_SCRIPT="deploy/trialrun/production-preflight.mjs" # shellcheck source=deployment-contract-lib.sh source "$ROOT_DIR/deploy/trialrun/deployment-contract-lib.sh" @@ -21,6 +23,11 @@ deployment_contract_safe_ssh_target "$DEPLOY_REFRESH_SERVER" \ deployment_contract_safe_service "$DEPLOY_CONTRACT_UNIT" \ || fail "DEPLOY_CONTRACT_UNIT 格式无效" command -v ssh >/dev/null 2>&1 || fail "缺少 ssh" +command -v node >/dev/null 2>&1 || fail "缺少 node" + +if [[ "${DEPLOY_ALLOW_UNPUBLISHED_COMMIT:-0}" == "1" || -n "${DEPLOY_UNPUBLISHED_REASON:-}" ]]; then + fail "生产部署验证不接受未进入上游的提交;灾备 override 必须走独立获批流程" +fi declare -A contract=() echo "[deploy-existing] 开始只读聚合体检" @@ -34,6 +41,43 @@ deployment_contract_load "$DEPLOY_SERVER" "$DEPLOY_CONTRACT_UNIT" contract \ || fail "无法读取完整既有部署合同" echo "[deploy-existing] 已读取受控 unit 合同;路径与标识不回显" + +if [[ -z "${EXPECTED_PRODUCTION_ORIGIN+x}" ]]; then + fail "生产部署必须显式提供 EXPECTED_PRODUCTION_ORIGIN(仅进程环境)" +fi +if [[ -z "${EXPECTED_PRODUCTION_DB_NAME+x}" ]]; then + fail "生产部署必须显式提供 EXPECTED_PRODUCTION_DB_NAME(仅进程环境)" +fi +if [[ -z "${EXPECTED_PENDING_MIGRATIONS+x}" ]]; then + fail "生产部署必须显式提供 EXPECTED_PENDING_MIGRATIONS(可为空)" +fi +[[ -f "$ROOT_DIR/$PROVENANCE_SCRIPT" ]] || fail "缺少发布来源校验脚本" +[[ -f "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT" ]] || fail "缺少生产迁移 preflight 脚本" + +echo ">>> 0.1 校验已批准发布来源(生产 preflight 前)" +provenance_json="$(node "$ROOT_DIR/$PROVENANCE_SCRIPT" 2>/dev/null)" \ + || fail "发布来源校验未通过" +source_commit="$(node -e 'const value = JSON.parse(process.argv[1]); process.stdout.write(value.commit || "")' "$provenance_json")" \ + || fail "发布来源提交不可用" +[[ "$source_commit" =~ ^[0-9a-f]{40,64}$ ]] || fail "发布来源提交格式无效" + +echo ">>> 0.2 生产目标与迁移 preflight(只读,先于 deploy.sh)" +PRODUCTION_PREFLIGHT_REPORT_ROOT="${PRODUCTION_PREFLIGHT_REPORT_ROOT:-artifacts/production-preflight-$(date +%Y%m%d-%H%M%S)}" +EXPECTED_PRODUCTION_ORIGIN="$EXPECTED_PRODUCTION_ORIGIN" \ +EXPECTED_PRODUCTION_DB_NAME="$EXPECTED_PRODUCTION_DB_NAME" \ +EXPECTED_PENDING_MIGRATIONS="$EXPECTED_PENDING_MIGRATIONS" \ +DEPLOY_SERVER="$DEPLOY_SERVER" \ +REMOTE_DIR="${contract[REMOTE_DIR]}" \ +REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ +COMPOSE_FILE="${contract[COMPOSE_FILE]}" \ +COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \ +POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \ +STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \ +CANDIDATE_COMMIT="$source_commit" \ +PRODUCTION_PREFLIGHT_REPORT_DIR="$PRODUCTION_PREFLIGHT_REPORT_ROOT/pre" \ +node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT" \ + || fail "生产目标或迁移 preflight 未通过;未进入 deploy.sh" + DEPLOY_SERVER="$DEPLOY_SERVER" \ REMOTE_DIR="${contract[REMOTE_DIR]}" \ REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ @@ -43,9 +87,26 @@ POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \ STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \ DEPLOY_DATA_MODE=existing \ DEPLOY_DEFER_FINAL_SUCCESS=1 \ +DEPLOY_STATUS_OUTPUT_MODE=quiet \ SYNC_ENV=0 \ bash "$ROOT_DIR/deploy/trialrun/deploy.sh" +echo ">>> 6.1 部署后 migration status 与目标只读复核" +EXPECTED_PRODUCTION_ORIGIN="$EXPECTED_PRODUCTION_ORIGIN" \ +EXPECTED_PRODUCTION_DB_NAME="$EXPECTED_PRODUCTION_DB_NAME" \ +EXPECTED_PENDING_MIGRATIONS="" \ +DEPLOY_SERVER="$DEPLOY_SERVER" \ +REMOTE_DIR="${contract[REMOTE_DIR]}" \ +REMOTE_ENV_FILE="${contract[REMOTE_ENV_FILE]}" \ +COMPOSE_FILE="${contract[COMPOSE_FILE]}" \ +COMPOSE_PROJECT_NAME="${contract[COMPOSE_PROJECT_NAME]}" \ +POSTGRES_DATA_DIR="${contract[POSTGRES_DATA_DIR]}" \ +STORAGE_HOST_DIR="${contract[STORAGE_HOST_DIR]}" \ +CANDIDATE_COMMIT="$source_commit" \ +PRODUCTION_PREFLIGHT_REPORT_DIR="$PRODUCTION_PREFLIGHT_REPORT_ROOT/post" \ +node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT" \ + || fail "部署后迁移 status/目标复核失败;应用已部署但未进入合同 refresh" + refresh_output="" refresh_status=0 if refresh_output="$(ssh "$DEPLOY_REFRESH_SERVER" refresh 2>/dev/null)"; then diff --git a/deploy/trialrun/deploy.sh b/deploy/trialrun/deploy.sh index f7fc9003..074a32e0 100755 --- a/deploy/trialrun/deploy.sh +++ b/deploy/trialrun/deploy.sh @@ -16,6 +16,7 @@ ENABLE_WORKER="${ENABLE_WORKER:-0}" ENABLE_MINIO="${ENABLE_MINIO:-0}" SYNC_ENV="${SYNC_ENV:-0}" DEPLOY_DEFER_FINAL_SUCCESS="${DEPLOY_DEFER_FINAL_SUCCESS:-0}" +DEPLOY_STATUS_OUTPUT_MODE="${DEPLOY_STATUS_OUTPUT_MODE:-verbose}" COMPOSE_FILE="deploy/trialrun/docker-compose.yml" LOCAL_ENV_FILE="deploy/trialrun/.env" ENV_CONTRACT_SCRIPT="deploy/trialrun/validate-env-contract.js" @@ -97,6 +98,10 @@ if [[ "$DEPLOY_DEFER_FINAL_SUCCESS" != "0" && "$DEPLOY_DEFER_FINAL_SUCCESS" != " echo "[ERROR] DEPLOY_DEFER_FINAL_SUCCESS 只能为 0 或 1" >&2 exit 1 fi +if [[ "$DEPLOY_STATUS_OUTPUT_MODE" != "quiet" && "$DEPLOY_STATUS_OUTPUT_MODE" != "verbose" ]]; then + echo "[ERROR] DEPLOY_STATUS_OUTPUT_MODE 只能为 quiet 或 verbose" >&2 + exit 1 +fi if paths_overlap "$REMOTE_DIR" "$POSTGRES_DATA_DIR"; then echo "[ERROR] POSTGRES_DATA_DIR 不得与 REMOTE_DIR 同目录或相互嵌套,避免源码同步触碰数据库" >&2 exit 1 @@ -216,7 +221,7 @@ fi echo ">>> 6. 在服务器执行部署与 smoke" ssh "${DEPLOY_SERVER}" \ - "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' COMPOSE_PROJECT_NAME='${COMPOSE_PROJECT_NAME}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE' + "REMOTE_DIR='${REMOTE_DIR}' REMOTE_ENV_FILE='${REMOTE_ENV_FILE}' COMPOSE_FILE='${COMPOSE_FILE}' COMPOSE_PROJECT_NAME='${COMPOSE_PROJECT_NAME}' POSTGRES_DATA_DIR='${POSTGRES_DATA_DIR}' STORAGE_HOST_DIR='${STORAGE_HOST_DIR}' DEPLOY_DATA_MODE='${DEPLOY_DATA_MODE}' ENABLE_WORKER='${ENABLE_WORKER}' ENABLE_MINIO='${ENABLE_MINIO}' DEPLOY_STATUS_OUTPUT_MODE='${DEPLOY_STATUS_OUTPUT_MODE}' BUILD_SHA='${SOURCE_COMMIT}' VITE_BUILD_SHA='${SOURCE_SHORT_COMMIT}' bash -s" <<'REMOTE' set -euo pipefail cd "$REMOTE_DIR" @@ -280,7 +285,13 @@ if [[ "$ENABLE_WORKER" == "1" ]]; then fi echo ">>> 4.6 校验 Prisma migration status" -if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status' /dev/null 2>&1; then + echo "[ERROR] Prisma migration status 未通过,停止部署(详情留在受控 preflight 现场)" >&2 + exit 1 + fi + echo "[PASS] Prisma migration status 命令完成(详情留在受控 preflight 现场)" +elif ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status' &2 "${compose_cmd[@]}" logs --tail=200 server || true exit 1 diff --git a/deploy/trialrun/production-preflight-remote.sh b/deploy/trialrun/production-preflight-remote.sh new file mode 100755 index 00000000..488d62f2 --- /dev/null +++ b/deploy/trialrun/production-preflight-remote.sh @@ -0,0 +1,280 @@ +#!/usr/bin/env bash + +# Read-only production target and migration gate. This script deliberately +# runs before deploy.sh is allowed to synchronize or execute migrate deploy. +set -euo pipefail + +fail() { + printf '[production-preflight][FAIL] code=%s\n' "$1" >&2 + exit 1 +} + +pass() { + printf '[production-preflight][PASS] %s\n' "$1" +} + +for key in REMOTE_DIR REMOTE_ENV_FILE COMPOSE_FILE COMPOSE_PROJECT_NAME \ + POSTGRES_DATA_DIR STORAGE_HOST_DIR EXPECTED_PRODUCTION_ORIGIN_SHA256 \ + EXPECTED_PRODUCTION_DB_NAME_SHA256 CANDIDATE_MIGRATIONS_CSV \ + EXPECTED_PENDING_MIGRATIONS_CSV; do + [[ -n "${!key:-}" || "$key" == "EXPECTED_PENDING_MIGRATIONS_CSV" ]] \ + || fail "${key}_MISSING" +done + +[[ "$EXPECTED_PRODUCTION_ORIGIN_SHA256" =~ ^[0-9a-f]{64}$ ]] \ + || fail "PROD_TARGET_ORIGIN_HASH_INVALID" +[[ "$EXPECTED_PRODUCTION_DB_NAME_SHA256" =~ ^[0-9a-f]{64}$ ]] \ + || fail "PROD_TARGET_DB_HASH_INVALID" + +cd "$REMOTE_DIR" +[[ -f "$REMOTE_ENV_FILE" && ! -L "$REMOTE_ENV_FILE" && -r "$REMOTE_ENV_FILE" ]] \ + || fail "PROD_TARGET_ENV_UNREADABLE" + +dotenv_value() { + local key="$1" + local value + value="$(awk -v wanted="$key" ' + $0 !~ /^[[:space:]]*#/ && $0 ~ "^[[:space:]]*" wanted "=" { + sub("^[[:space:]]*" wanted "=", "") + print + exit + } + ' "$REMOTE_ENV_FILE")" || return 1 + value="${value%$'\r'}" + case "$value" in + \"*\") value="${value:1:${#value}-2}" ;; + \'*\') value="${value:1:${#value}-2}" ;; + esac + printf '%s' "$value" +} + +hash_text() { + if command -v sha256sum >/dev/null 2>&1; then + printf '%s' "$1" | sha256sum | awk '{print $1}' + return 0 + fi + if command -v shasum >/dev/null 2>&1; then + printf '%s' "$1" | shasum -a 256 | awk '{print $1}' + return 0 + fi + return 1 +} + +reject_non_production_identity() { + local value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + [[ "$value" != *"<"* && "$value" != *">"* ]] || return 1 + [[ "$value" != *"localhost"* && "$value" != *"127.0.0.1"* \ + && "$value" != *"::1"* && "$value" != *"hyb_oa_regression"* \ + && "$value" != *"trialrun"* && "$value" != *"staging"* \ + && "$value" != *"stage"* && "$value" != *"regression"* \ + && "$value" != *"shadow"* && "$value" != *"/test"* ]] +} + +reject_non_production_host() { + local value="$(printf '%s' "$1" | tr '[:upper:]' '[:lower:]')" + case "$value" in + localhost|localhost:*|127.*|0.*|::|::1|::1:*|\[::\]|\[::1\]|*::ffff:*) + return 1 + ;; + esac + reject_non_production_identity "$value" +} + +actual_origin="$(dotenv_value APP_ORIGIN)" || fail "PROD_TARGET_ORIGIN_MISSING" +actual_db_name="$(dotenv_value POSTGRES_DB)" || fail "PROD_TARGET_DB_NAME_MISSING" +node_env="$(dotenv_value NODE_ENV)" || fail "PROD_TARGET_NODE_ENV_MISSING" +cookie_secure="$(dotenv_value COOKIE_SECURE)" || fail "PROD_TARGET_COOKIE_SECURE_MISSING" + +[[ "$(hash_text "$actual_origin")" == "$EXPECTED_PRODUCTION_ORIGIN_SHA256" ]] \ + || fail "PROD_TARGET_ORIGIN_MISMATCH" +[[ "$(hash_text "$actual_db_name")" == "$EXPECTED_PRODUCTION_DB_NAME_SHA256" ]] \ + || fail "PROD_TARGET_DB_NAME_MISMATCH" +[[ "$node_env" == "production" ]] || fail "PROD_TARGET_NODE_ENV_NOT_PRODUCTION" +[[ "$cookie_secure" == "true" ]] || fail "PROD_TARGET_COOKIE_SECURE_NOT_TRUE" + +case "$actual_origin" in + https://*) ;; + *) fail "PROD_TARGET_ORIGIN_NOT_HTTPS" ;; +esac +origin_host="${actual_origin#https://}" +[[ "$origin_host" != */* && "$origin_host" != *"?"* && "$origin_host" != *"#"* ]] \ + || fail "PROD_TARGET_ORIGIN_NOT_ORIGIN_ONLY" +reject_non_production_host "$origin_host" \ + || fail "PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY" +reject_non_production_identity "$actual_db_name" \ + || fail "PROD_TARGET_DB_NON_PRODUCTION_IDENTITY" + +if docker compose version >/dev/null 2>&1; then + compose_cmd=(docker compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE") +elif command -v docker-compose >/dev/null 2>&1; then + compose_cmd=(docker-compose -p "$COMPOSE_PROJECT_NAME" -f "$COMPOSE_FILE" --env-file "$REMOTE_ENV_FILE") +else + fail "PROD_TARGET_COMPOSE_UNAVAILABLE" +fi + +"${compose_cmd[@]}" config >/dev/null 2>&1 || fail "PROD_TARGET_COMPOSE_CONFIG_INVALID" + +container_ids() { + docker ps \ + --filter "label=com.docker.compose.project=$COMPOSE_PROJECT_NAME" \ + --filter "label=com.docker.compose.service=$1" \ + --format '{{.ID}}' +} + +mapfile -t server_ids < <(container_ids server 2>/dev/null || true) +mapfile -t db_ids < <(container_ids db 2>/dev/null || true) +[[ "${#server_ids[@]}" -eq 1 && "${#db_ids[@]}" -eq 1 ]] \ + || fail "PROD_TARGET_RUNTIME_NOT_UNIQUE" +server_id="${server_ids[0]}" + +container_env() { + docker inspect -f '{{range .Config.Env}}{{println .}}{{end}}' "$1" \ + | sed -n "s/^$2=//p" | tail -n 1 +} +[[ "$(container_env "$server_id" NODE_ENV)" == "production" ]] \ + || fail "PROD_TARGET_RUNTIME_NODE_ENV_NOT_PRODUCTION" +[[ "$(container_env "$server_id" COOKIE_SECURE)" == "true" ]] \ + || fail "PROD_TARGET_RUNTIME_COOKIE_SECURE_NOT_TRUE" +[[ "$(hash_text "$(container_env "$server_id" APP_ORIGIN)")" == "$EXPECTED_PRODUCTION_ORIGIN_SHA256" ]] \ + || fail "PROD_TARGET_RUNTIME_ORIGIN_MISMATCH" +postgres_user="$(dotenv_value POSTGRES_USER)" || fail "PROD_TARGET_DB_USER_MISSING" +postgres_db="$(dotenv_value POSTGRES_DB)" || fail "PROD_TARGET_DB_NAME_MISSING" +db_value() { + local sql="$1" + "${compose_cmd[@]}" exec -T db psql -X -U "$postgres_user" -d "$postgres_db" -At -F '|' -c "$sql" /dev/null +} + +runtime_db_name="$(db_value 'SELECT current_database();' | tr -d '\r\n')" \ + || fail "PROD_DB_IDENTITY_UNAVAILABLE" +[[ "$(hash_text "$runtime_db_name")" == "$EXPECTED_PRODUCTION_DB_NAME_SHA256" ]] \ + || fail "PROD_DB_IDENTITY_MISMATCH" + +database_url_identity="$("${compose_cmd[@]}" exec -T server node -e ' + try { + const url = new URL(process.env.DATABASE_URL || ""); + if (!["postgres:", "postgresql:"].includes(url.protocol)) process.exit(1); + const database = decodeURIComponent(url.pathname.replace(/^\/+/, "").split("?")[0]); + if (!url.hostname || !database) process.exit(1); + process.stdout.write(`${url.hostname}\n${database}`); + } catch { + process.exit(1); + } +' /dev/null)" \ + || fail "PROD_DATABASE_URL_IDENTITY_UNAVAILABLE" +database_url_host="${database_url_identity%%$'\n'*}" +database_url_db="${database_url_identity#*$'\n'}" +[[ "$database_url_identity" == *$'\n'* && -n "$database_url_host" && -n "$database_url_db" ]] \ + || fail "PROD_DATABASE_URL_IDENTITY_UNAVAILABLE" +reject_non_production_host "$database_url_host" \ + || fail "PROD_DATABASE_URL_HOST_NON_PRODUCTION_IDENTITY" +[[ "$(hash_text "$database_url_db")" == "$EXPECTED_PRODUCTION_DB_NAME_SHA256" ]] \ + || fail "PROD_DATABASE_URL_IDENTITY_MISMATCH" +pass "target_identity_and_database_identity" + +# Required pre-write status gate. No deploy, seed, app switch or business +# write may run before this command completes and its raw output is captured. +set +e +prisma_status_output="$("${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status' &1)" +prisma_status_code=$? +set -e +printf '[production-preflight][PRISMA_STATUS_BEGIN]\n' +printf '%s\n' "$prisma_status_output" +printf '[production-preflight][PRISMA_STATUS_END] exit=%s\n' "$prisma_status_code" + +if printf '%s' "$prisma_status_output" | grep -Eiq 'failed migration|migration(s)? have failed|migration failed|rolled back|diverged|drift|could not find migration|database schema is not in sync'; then + fail "PROD_MIGRATION_STATUS_UNSAFE" +fi +if ! printf '%s' "$prisma_status_output" \ + | grep -Eiq 'Database schema is up to date|have not yet been applied|No pending migrations'; then + fail "PROD_MIGRATION_STATUS_UNREADABLE" +fi +if [[ "$prisma_status_code" -ne 0 ]] \ + && ! printf '%s' "$prisma_status_output" | grep -Eiq 'have not yet been applied'; then + fail "PROD_MIGRATION_STATUS_UNREADABLE" +fi + +declare -a status_pending_ids=() +if printf '%s' "$prisma_status_output" | grep -Eiq 'have not yet been applied'; then + status_pending_text="$(printf '%s\n' "$prisma_status_output" | awk ' + tolower($0) ~ /have not yet been applied/ { capture=1; next } + capture { print } + ' || true)" + mapfile -t status_pending_ids < <( + printf '%s\n' "$status_pending_text" \ + | grep -oE '[0-9]{14}_[A-Za-z0-9_]+' \ + | sort -u || true + ) + [[ "${#status_pending_ids[@]}" -gt 0 ]] || fail "PROD_MIGRATION_STATUS_PENDING_IDS_UNREADABLE" +fi + +migration_rows="$(db_value ' + SELECT migration_name, + CASE + WHEN rolled_back_at IS NOT NULL THEN '\''rolled_back'\'' + WHEN logs IS NOT NULL AND btrim(logs) <> '\'''\'' THEN '\''logged_failure'\'' + WHEN finished_at IS NULL THEN '\''unfinished'\'' + ELSE '\''applied'\'' + END + FROM "_prisma_migrations" + ORDER BY migration_name; +' | tr -d '\r')" || fail "PROD_MIGRATION_TABLE_UNAVAILABLE" +[[ -n "$migration_rows" ]] || fail "PROD_MIGRATION_TABLE_EMPTY" + +declare -A candidate_set=() +declare -A applied_set=() +declare -A seen_db_migrations=() +IFS=',' read -r -a candidate_ids <<<"$CANDIDATE_MIGRATIONS_CSV" +for migration in "${candidate_ids[@]}"; do + [[ "$migration" =~ ^[0-9]{14}_[A-Za-z0-9_]+$ ]] || fail "CANDIDATE_MIGRATION_ID_INVALID" + [[ -z "${candidate_set[$migration]:-}" ]] || fail "CANDIDATE_MIGRATION_DUPLICATE" + candidate_set["$migration"]=1 +done +[[ "${#candidate_ids[@]}" -gt 0 ]] || fail "CANDIDATE_MIGRATION_MANIFEST_EMPTY" + +abnormal_count=0 +while IFS='|' read -r migration state; do + [[ -n "$migration" ]] || continue + [[ -z "${seen_db_migrations[$migration]:-}" ]] || fail "PROD_MIGRATION_DUPLICATE_HISTORY" + seen_db_migrations["$migration"]=1 + if [[ "$state" == "applied" ]]; then + applied_set["$migration"]=1 + else + abnormal_count=$((abnormal_count + 1)) + fi + [[ -n "${candidate_set[$migration]:-}" ]] || fail "PROD_MIGRATION_UNKNOWN_APPLIED" +done <<<"$migration_rows" +[[ "$abnormal_count" -eq 0 ]] || fail "PROD_MIGRATION_FAILED_OR_ROLLED_BACK" + +# Before rsync/deploy the running image may not contain newly committed +# migration directories. The SQL candidate-vs-applied diff is therefore the +# allowlist authority; status IDs are only used to reject unknown pending work. +if [[ "${#status_pending_ids[@]}" -gt 0 ]]; then + for migration in "${status_pending_ids[@]}"; do + [[ "$migration" =~ ^[0-9]{14}_[A-Za-z0-9_]+$ ]] || fail "PROD_MIGRATION_STATUS_PENDING_ID_INVALID" + [[ -n "${candidate_set[$migration]:-}" ]] || fail "PROD_MIGRATION_STATUS_UNKNOWN_PENDING" + done +fi + +pending_ids=() +for migration in "${candidate_ids[@]}"; do + [[ -n "${applied_set[$migration]:-}" ]] || pending_ids+=("$migration") +done +actual_pending_csv="" +if [[ "${#pending_ids[@]}" -gt 0 ]]; then + actual_pending_csv="$(printf '%s\n' "${pending_ids[@]}" | sort | paste -sd, -)" +fi +expected_pending_csv="$(printf '%s' "$EXPECTED_PENDING_MIGRATIONS_CSV" | tr ',' '\n' | sed '/^$/d' | sort | paste -sd, -)" +[[ "$actual_pending_csv" == "$expected_pending_csv" ]] \ + || fail "PROD_MIGRATION_PENDING_ALLOWLIST_MISMATCH" +pass "migration_reconciliation pending=${actual_pending_csv:-none}" + +terminated_count="$(db_value "SELECT count(*) FROM contract_registry WHERE status = 'terminated';" | tr -d '\r\n')" \ + || fail "PROD_TERMINATED_COUNT_UNAVAILABLE" +[[ "$terminated_count" =~ ^[0-9]+$ ]] || fail "PROD_TERMINATED_COUNT_INVALID" +if [[ "$terminated_count" -eq 0 ]]; then + pass "rollbackWindow=OPEN terminatedRows=0" +else + pass "rollbackWindow=CLOSED terminatedRows=${terminated_count}" +fi + +pass "migration_status_and_target_checks_complete" diff --git a/deploy/trialrun/production-preflight.mjs b/deploy/trialrun/production-preflight.mjs new file mode 100755 index 00000000..206c87a1 --- /dev/null +++ b/deploy/trialrun/production-preflight.mjs @@ -0,0 +1,316 @@ +#!/usr/bin/env node + +import { createHash } from "node:crypto"; +import { mkdirSync, readFileSync, unlinkSync, writeFileSync } from "node:fs"; +import { spawnSync } from "node:child_process"; +import { isIP } from "node:net"; +import path from "node:path"; +import process from "node:process"; +import { fileURLToPath, pathToFileURL } from "node:url"; + +const SCRIPT_DIR = path.dirname(fileURLToPath(import.meta.url)); +const ROOT_DIR = path.resolve(SCRIPT_DIR, "../.."); +const REMOTE_SCRIPT = path.join(SCRIPT_DIR, "production-preflight-remote.sh"); +const MIGRATION_ID_PATTERN = /^\d{14}_[A-Za-z0-9_]+$/u; +const SSH_TARGET_PATTERN = /^(?:[A-Za-z0-9._-]+@)?[A-Za-z0-9._-]+$/u; +const SAFE_ABSOLUTE_PATH_PATTERN = /^\/[A-Za-z0-9._/-]+$/u; +const SAFE_COMPOSE_FILE_PATTERN = /^[A-Za-z0-9._/-]+$/u; +const SAFE_PROJECT_PATTERN = /^[a-z0-9][a-z0-9_-]*$/u; +const FORBIDDEN_IDENTITY_TOKEN_PATTERN = /(^|[._-])(local|localhost|dev|development|test|testing|stage|staging|trialrun|regression|shadow|example)([._-]|$)/u; +const PRISMA_STATUS_KNOWN_PATTERN = /(?:database schema is up to date|have not yet been applied|no pending migrations)/iu; + +export class ProductionPreflightError extends Error { + constructor(code) { + super(code); + this.name = "ProductionPreflightError"; + this.code = code; + } +} + +function fail(code) { + throw new ProductionPreflightError(code); +} + +function isNonProductionHost(input) { + const host = String(input ?? "").trim().toLowerCase().replace(/^\[|\]$/gu, ""); + if (!host) return true; + if (host === "localhost" || host === "::" || host === "::1" || host.startsWith("::ffff:")) { + return true; + } + const version = isIP(host); + if (version === 4) { + const firstOctet = Number(host.split(".")[0]); + return firstOctet === 0 || firstOctet === 127; + } + return false; +} + +export function validateProductionOrigin(input) { + const raw = String(input ?? "").trim(); + let url; + try { + url = new URL(raw); + } catch { + fail("PROD_TARGET_ORIGIN_INVALID"); + } + + if (url.protocol !== "https:" || url.username || url.password) { + fail("PROD_TARGET_ORIGIN_NOT_HTTPS"); + } + const host = url.hostname.toLowerCase(); + if ( + isNonProductionHost(host) || + host.includes("example") || + FORBIDDEN_IDENTITY_TOKEN_PATTERN.test(host) + ) { + fail("PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY"); + } + if (url.pathname !== "/" || url.search || url.hash || raw !== url.origin) { + fail("PROD_TARGET_ORIGIN_NOT_ORIGIN_ONLY"); + } + + return url.origin; +} + +export function validateProductionDbName(input) { + const raw = String(input ?? "").trim(); + if (!/^[A-Za-z][A-Za-z0-9_-]{0,62}$/u.test(raw)) { + fail("PROD_TARGET_DB_NAME_INVALID"); + } + if (raw.includes("<") || raw.includes(">") || FORBIDDEN_IDENTITY_TOKEN_PATTERN.test(raw.toLowerCase())) { + fail("PROD_TARGET_DB_NON_PRODUCTION_IDENTITY"); + } + return raw; +} + +export function parseMigrationList(input, { field, allowEmpty = true } = {}) { + const raw = String(input ?? "").trim(); + if (!raw && allowEmpty) return []; + if (!raw && !allowEmpty) fail(`${field}_MISSING`); + + const values = raw.split(/[\s,]+/u).filter(Boolean); + const seen = new Set(); + for (const value of values) { + if (!MIGRATION_ID_PATTERN.test(value)) fail(`${field}_INVALID`); + if (seen.has(value)) fail(`${field}_DUPLICATE`); + seen.add(value); + } + return [...seen].sort(); +} + +export function collectMigrationManifest(rootDir, commit) { + const result = spawnSync( + "git", + ["ls-tree", "-r", "--name-only", commit, "--", "server/prisma/migrations"], + { cwd: rootDir, encoding: "utf8", stdio: ["ignore", "pipe", "pipe"] }, + ); + if (result.status !== 0) fail("CANDIDATE_MIGRATION_MANIFEST_UNAVAILABLE"); + + const ids = new Set(); + for (const relativePath of result.stdout.split(/\r?\n/u).map((line) => line.trim()).filter(Boolean)) { + const parts = relativePath.split("/"); + if (parts.length !== 5 || parts[0] !== "server" || parts[1] !== "prisma" || parts[2] !== "migrations") { + continue; + } + if (parts[4] !== "migration.sql" || !MIGRATION_ID_PATTERN.test(parts[3])) { + fail("CANDIDATE_MIGRATION_MANIFEST_INVALID"); + } + ids.add(parts[3]); + } + + const manifest = [...ids].sort(); + if (manifest.length === 0) fail("CANDIDATE_MIGRATION_MANIFEST_EMPTY"); + return manifest; +} + +export function sha256Text(value) { + return createHash("sha256").update(String(value), "utf8").digest("hex"); +} + +function shellQuote(value) { + return `'${String(value).replaceAll("'", "'\\''")}'`; +} + +function assertSafeContractValue(name, value, pattern) { + if (!pattern.test(String(value ?? ""))) fail(`${name}_INVALID`); +} + +function gitText(args) { + const result = spawnSync("git", args, { + cwd: ROOT_DIR, + encoding: "utf8", + stdio: ["ignore", "pipe", "pipe"], + }); + if (result.status !== 0) fail("CANDIDATE_COMMIT_UNAVAILABLE"); + return result.stdout.trim(); +} + +function reportRef(reportDir) { + const relative = path.relative(ROOT_DIR, reportDir).replaceAll(path.sep, "/"); + return relative && !relative.startsWith("..") ? relative : "controlled-production-preflight-report"; +} + +function extractPrismaStatus(stdout) { + const beginMarker = "[production-preflight][PRISMA_STATUS_BEGIN]"; + const endMarker = "[production-preflight][PRISMA_STATUS_END]"; + const begin = stdout.indexOf(beginMarker); + const end = stdout.indexOf(endMarker, begin + beginMarker.length); + if (begin < 0 || end < 0) return null; + const bodyStart = stdout.indexOf("\n", begin); + const body = stdout.slice(bodyStart >= 0 ? bodyStart + 1 : begin + beginMarker.length, end); + return body.replace(/^\s+|\s+$/gu, ""); +} + +function stableRemoteSummary(stdout) { + const lines = stdout.split(/\r?\n/u); + let inRawStatus = false; + const summary = []; + for (const line of lines) { + if (line === "[production-preflight][PRISMA_STATUS_BEGIN]") { + inRawStatus = true; + continue; + } + if (line.startsWith("[production-preflight][PRISMA_STATUS_END]")) { + inRawStatus = false; + continue; + } + if (!inRawStatus && line.trim()) summary.push(line); + } + return summary.join("\n"); +} + +function printStableRemoteSummary(stdout) { + const summary = stableRemoteSummary(stdout); + if (summary) process.stdout.write(`${summary}\n`); +} + +function sanitizePrismaStatus(status, sensitiveValues = []) { + return status + .split(/\r?\n/u) + .map((line) => { + if (/^\s*Datasource\b/iu.test(line)) return "[redacted datasource line]"; + let sanitized = line.replace(/postgres(?:ql)?:\/\/[^\s]+/giu, ""); + for (const value of sensitiveValues) { + if (value) sanitized = sanitized.split(value).join(""); + } + return sanitized; + }) + .join("\n") + .replace(/^\s+|\s+$/gu, ""); +} + +export function validatePrismaStatusText(status) { + if (!status || !PRISMA_STATUS_KNOWN_PATTERN.test(status)) { + fail("PRISMA_STATUS_OUTPUT_UNREADABLE"); + } + return status; +} + +function main() { + try { + const deployServer = String(process.env.DEPLOY_SERVER ?? "").trim(); + const remoteDir = String(process.env.REMOTE_DIR ?? "").trim(); + const remoteEnvFile = String(process.env.REMOTE_ENV_FILE ?? "").trim(); + const composeFile = String(process.env.COMPOSE_FILE ?? "").trim(); + const composeProjectName = String(process.env.COMPOSE_PROJECT_NAME ?? "").trim(); + const postgresDataDir = String(process.env.POSTGRES_DATA_DIR ?? "").trim(); + const storageHostDir = String(process.env.STORAGE_HOST_DIR ?? "").trim(); + const expectedOriginRaw = process.env.EXPECTED_PRODUCTION_ORIGIN; + const expectedDbNameRaw = process.env.EXPECTED_PRODUCTION_DB_NAME; + const expectedPendingRaw = process.env.EXPECTED_PENDING_MIGRATIONS; + if (expectedOriginRaw == null) fail("EXPECTED_PRODUCTION_ORIGIN_MISSING"); + if (expectedDbNameRaw == null) fail("EXPECTED_PRODUCTION_DB_NAME_MISSING"); + if (expectedPendingRaw == null) fail("EXPECTED_PENDING_MIGRATIONS_MISSING"); + + const expectedOrigin = validateProductionOrigin(expectedOriginRaw); + const expectedDbName = validateProductionDbName(expectedDbNameRaw); + const expectedPending = parseMigrationList(expectedPendingRaw, { + field: "EXPECTED_PENDING_MIGRATIONS", + }); + assertSafeContractValue("DEPLOY_SERVER", deployServer, SSH_TARGET_PATTERN); + assertSafeContractValue("REMOTE_DIR", remoteDir, SAFE_ABSOLUTE_PATH_PATTERN); + assertSafeContractValue("REMOTE_ENV_FILE", remoteEnvFile, SAFE_ABSOLUTE_PATH_PATTERN); + assertSafeContractValue("POSTGRES_DATA_DIR", postgresDataDir, SAFE_ABSOLUTE_PATH_PATTERN); + assertSafeContractValue("STORAGE_HOST_DIR", storageHostDir, SAFE_ABSOLUTE_PATH_PATTERN); + assertSafeContractValue("COMPOSE_FILE", composeFile, SAFE_COMPOSE_FILE_PATTERN); + assertSafeContractValue("COMPOSE_PROJECT_NAME", composeProjectName, SAFE_PROJECT_PATTERN); + + const candidateCommit = String( + process.env.CANDIDATE_COMMIT ?? gitText(["rev-parse", "--verify", "HEAD"]), + ).trim(); + if (!/^[0-9a-f]{40,64}$/iu.test(candidateCommit)) fail("CANDIDATE_COMMIT_INVALID"); + const candidateManifest = collectMigrationManifest(ROOT_DIR, candidateCommit); + const candidateSet = new Set(candidateManifest); + if (expectedPending.some((migration) => !candidateSet.has(migration))) { + fail("EXPECTED_PENDING_MIGRATION_NOT_IN_CANDIDATE"); + } + + const reportDir = path.resolve( + ROOT_DIR, + process.env.PRODUCTION_PREFLIGHT_REPORT_DIR || `artifacts/production-preflight-${Date.now()}`, + ); + mkdirSync(reportDir, { recursive: true, mode: 0o700 }); + for (const filename of ["prisma-migrate-status.txt", "preflight-output.txt"]) { + try { + unlinkSync(path.join(reportDir, filename)); + } catch (error) { + if (error?.code !== "ENOENT") fail("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH"); + } + } + + const remoteCommand = [ + `REMOTE_DIR=${shellQuote(remoteDir)}`, + `REMOTE_ENV_FILE=${shellQuote(remoteEnvFile)}`, + `COMPOSE_FILE=${shellQuote(composeFile)}`, + `COMPOSE_PROJECT_NAME=${shellQuote(composeProjectName)}`, + `POSTGRES_DATA_DIR=${shellQuote(postgresDataDir)}`, + `STORAGE_HOST_DIR=${shellQuote(storageHostDir)}`, + `EXPECTED_PRODUCTION_ORIGIN_SHA256=${shellQuote(sha256Text(expectedOrigin))}`, + `EXPECTED_PRODUCTION_DB_NAME_SHA256=${shellQuote(sha256Text(expectedDbName))}`, + `CANDIDATE_MIGRATIONS_CSV=${shellQuote(candidateManifest.join(","))}`, + `EXPECTED_PENDING_MIGRATIONS_CSV=${shellQuote(expectedPending.join(","))}`, + "bash -s", + ].join(" "); + + const remoteScript = readFileSync(REMOTE_SCRIPT, "utf8"); + const result = spawnSync("ssh", [deployServer, remoteCommand], { + cwd: ROOT_DIR, + input: remoteScript, + encoding: "utf8", + stdio: ["pipe", "pipe", "pipe"], + }); + const stdout = result.stdout || ""; + const rawStatus = extractPrismaStatus(stdout); + if (rawStatus != null) { + validatePrismaStatusText(rawStatus); + const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin, expectedDbName]); + writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, { + encoding: "utf8", + mode: 0o600, + }); + } + const stableSummary = stableRemoteSummary(stdout); + writeFileSync(path.join(reportDir, "preflight-output.txt"), `${stableSummary}\n`, { + encoding: "utf8", + mode: 0o600, + }); + + printStableRemoteSummary(stdout); + console.log(`[production-preflight] reportRef=${reportRef(reportDir)}`); + if (rawStatus == null) fail("PRISMA_STATUS_OUTPUT_MISSING"); + if (result.status !== 0) { + const remoteCode = `${stdout}\n${result.stderr || ""}`.match(/code=([A-Z0-9_]+)/u)?.[1]; + fail(remoteCode || "PRODUCTION_PREFLIGHT_REMOTE_FAILED"); + } + console.log("[production-preflight][PASS] 生产目标与迁移只读门禁通过"); + } catch (error) { + const code = error instanceof ProductionPreflightError + ? error.code + : "PRODUCTION_PREFLIGHT_UNEXPECTED_ERROR"; + console.error(`[production-preflight][PAUSED] code=${code}`); + process.exitCode = 1; + } +} + +const isMain = process.argv[1] && import.meta.url === pathToFileURL(path.resolve(process.argv[1])).href; +if (isMain) main(); diff --git a/deploy/trialrun/validate-env-contract.js b/deploy/trialrun/validate-env-contract.js index 6f96204f..9861f06c 100755 --- a/deploy/trialrun/validate-env-contract.js +++ b/deploy/trialrun/validate-env-contract.js @@ -38,6 +38,11 @@ const deploymentRefreshHelperPath = path.join( 'scripts/refresh-deployment-contract.sh', ); const remotePreflightScriptPath = path.join(rootDir, 'deploy/trialrun/preflight-remote.sh'); +const productionPreflightScriptPath = path.join(rootDir, 'deploy/trialrun/production-preflight.mjs'); +const productionPreflightRemoteScriptPath = path.join( + rootDir, + 'deploy/trialrun/production-preflight-remote.sh', +); const dockerfilePath = path.join(rootDir, 'deploy/trialrun/Dockerfile'); const timerScriptPath = path.resolve( process.env.ENV_CONTRACT_TIMER_SCRIPT_PATH @@ -272,6 +277,8 @@ const deploymentPreflightScriptContent = readFile(deploymentPreflightScriptPath) const deploymentRefreshInstallerContent = readFile(deploymentRefreshInstallerPath); const deploymentRefreshHelperContent = readFile(deploymentRefreshHelperPath); const remotePreflightScriptContent = readFile(remotePreflightScriptPath); +const productionPreflightScriptContent = readFile(productionPreflightScriptPath); +const productionPreflightRemoteScriptContent = readFile(productionPreflightRemoteScriptPath); const dockerfileContent = readFile(dockerfilePath); const timerScriptContent = readFile(timerScriptPath); const automaticBackupTimerScriptContent = readFile(automaticBackupTimerScriptPath); @@ -582,17 +589,33 @@ for (const requiredFragment of [ 'check-existing-deployment.sh', 'deployment_contract_load', 'DEPLOY_DEFER_FINAL_SUCCESS=1', + 'DEPLOY_STATUS_OUTPUT_MODE=quiet', 'ssh "$DEPLOY_REFRESH_SERVER" refresh', 'DEPLOYED_BUT_CONTRACT_REFRESH_FAILED', 'DEPLOY_DATA_MODE=existing', 'SYNC_ENV=0', '路径与标识不回显', + '生产部署验证不接受未进入上游的提交', ]) { if (!deployExistingScriptContent.includes(requiredFragment)) { errors.push(`deploy-existing.sh 缺少既有部署合同: ${requiredFragment}`); } } +for (const requiredFragment of [ + 'EXPECTED_PRODUCTION_ORIGIN', + 'EXPECTED_PRODUCTION_DB_NAME', + 'EXPECTED_PENDING_MIGRATIONS', + 'production-preflight.mjs', + 'CANDIDATE_COMMIT', + 'PRODUCTION_PREFLIGHT_REPORT_DIR', + '未进入 deploy.sh', +]) { + if (!deployExistingScriptContent.includes(requiredFragment)) { + errors.push(`deploy-existing.sh 缺少生产迁移前置门禁: ${requiredFragment}`); + } +} + for (const requiredFragment of [ 'systemctl show', 'DEPLOY_CONTRACT_KEYS', @@ -669,6 +692,68 @@ if (deployExistingScriptContent.indexOf('ssh "$DEPLOY_REFRESH_SERVER" refresh') errors.push('deploy-existing.sh 必须在应用部署成功后刷新备份合同'); } +for (const requiredFragment of [ + 'validateProductionOrigin', + 'validateProductionDbName', + 'collectMigrationManifest', + 'EXPECTED_PENDING_MIGRATIONS', + 'PRISMA_STATUS_OUTPUT_MISSING', + 'PRISMA_STATUS_OUTPUT_UNREADABLE', + 'sanitizePrismaStatus', + 'stableRemoteSummary', + 'PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH', + 'prisma-migrate-status.txt', + 'PRODUCTION_PREFLIGHT_REPORT_DIR', +]) { + if (!productionPreflightScriptContent.includes(requiredFragment)) { + errors.push(`production-preflight.mjs 缺少生产目标/候选迁移合同: ${requiredFragment}`); + } +} + +for (const requiredFragment of [ + 'EXPECTED_PRODUCTION_ORIGIN_SHA256', + 'EXPECTED_PRODUCTION_DB_NAME_SHA256', + 'PRISMA_STATUS_BEGIN', + 'FROM "_prisma_migrations"', + 'logged_failure', + 'PROD_MIGRATION_STATUS_UNKNOWN_PENDING', + 'PROD_DATABASE_URL_HOST_NON_PRODUCTION_IDENTITY', + 'PROD_MIGRATION_PENDING_ALLOWLIST_MISMATCH', + 'rollbackWindow=OPEN', + 'PROD_TARGET_ORIGIN_MISMATCH', +]) { + if (!productionPreflightRemoteScriptContent.includes(requiredFragment)) { + errors.push(`production-preflight-remote.sh 缺少只读生产门禁合同: ${requiredFragment}`); + } +} +if (productionPreflightRemoteScriptContent.includes('migrate reset') + || productionPreflightRemoteScriptContent.includes('db push') + || productionPreflightRemoteScriptContent.includes('migrate resolve')) { + errors.push('production-preflight-remote.sh 不得包含 reset/db push/resolve 写入路径'); +} +if (deployExistingScriptContent.indexOf('node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT"') + > deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) { + errors.push('deploy-existing.sh 必须在 deploy.sh 前执行生产目标与迁移 preflight'); +} +if (!deployScriptContent.includes('DEPLOY_STATUS_OUTPUT_MODE')) { + errors.push('deploy.sh 必须支持受控发布 wrapper 的 quiet migration status 输出模式'); +} +if (deployScriptContent.indexOf("DEPLOY_STATUS_OUTPUT_MODE='${DEPLOY_STATUS_OUTPUT_MODE}'") === -1) { + errors.push('deploy.sh 必须将 DEPLOY_STATUS_OUTPUT_MODE 传入远端部署 shell'); +} +if (deployExistingScriptContent.indexOf('EXPECTED_PENDING_MIGRATIONS=""') + < deployExistingScriptContent.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')) { + errors.push('deploy-existing.sh 必须在 deploy.sh 后执行空 pending 的 post-status 复核'); +} +if (productionPreflightRemoteScriptContent.indexOf('database_url_identity=') + > productionPreflightRemoteScriptContent.indexOf('prisma_status_output=')) { + errors.push('production-preflight-remote.sh 必须先确认 DATABASE_URL host/db 身份,再执行 migration status'); +} +if (deployExistingScriptContent.indexOf('ssh "$DEPLOY_REFRESH_SERVER" refresh') + < deployExistingScriptContent.indexOf('EXPECTED_PENDING_MIGRATIONS=""')) { + errors.push('deploy-existing.sh 必须在 post-status 复核后刷新备份合同'); +} + for (const requiredFragment of [ 'REMOTE_DIR 是现存仓库根目录', 'SYNC_ENV=0 要求同步前已有普通远端 .env 文件', diff --git a/server/tests/unit/scripts/deployment-safety-contract.test.js b/server/tests/unit/scripts/deployment-safety-contract.test.js index cda2fa50..dd05555f 100644 --- a/server/tests/unit/scripts/deployment-safety-contract.test.js +++ b/server/tests/unit/scripts/deployment-safety-contract.test.js @@ -1,7 +1,9 @@ const { execFile } = require("node:child_process"); -const { mkdtemp, mkdir, rm, symlink, writeFile } = require("node:fs/promises"); +const { createHash } = require("node:crypto"); +const { chmod, mkdtemp, mkdir, rm, symlink, writeFile, readFile } = require("node:fs/promises"); const os = require("node:os"); const path = require("node:path"); +const { pathToFileURL } = require("node:url"); const { promisify } = require("node:util"); const execFileAsync = promisify(execFile); @@ -10,6 +12,8 @@ const PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/preflight-remote.sh"); const AGGREGATE_PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/check-existing-deployment.sh"); const REFRESH_HELPER = path.join(ROOT_DIR, "scripts/refresh-deployment-contract.sh"); const REFRESH_INSTALLER = path.join(ROOT_DIR, "deploy/trialrun/install-deployment-contract-refresh.sh"); +const PRODUCTION_PREFLIGHT = path.join(ROOT_DIR, "deploy/trialrun/production-preflight.mjs"); +const PRODUCTION_PREFLIGHT_REMOTE = path.join(ROOT_DIR, "deploy/trialrun/production-preflight-remote.sh"); describe("deployment safety contract", () => { let fixtureRoot; @@ -87,10 +91,215 @@ describe("deployment safety contract", () => { expect(wrapper).toContain("DEPLOY_DATA_MODE=existing"); expect(wrapper).toContain("SYNC_ENV=0"); expect(wrapper).toContain("DEPLOY_DEFER_FINAL_SUCCESS=1"); + expect(wrapper).toContain("DEPLOY_STATUS_OUTPUT_MODE=quiet"); + expect(deploy).toContain("DEPLOY_STATUS_OUTPUT_MODE"); + expect(deploy).toContain("DEPLOY_STATUS_OUTPUT_MODE='${DEPLOY_STATUS_OUTPUT_MODE}'"); + expect(wrapper).toContain("生产部署验证不接受未进入上游的提交"); + expect(wrapper).toContain("部署后 migration status 与目标只读复核"); + expect(wrapper).toContain('EXPECTED_PENDING_MIGRATIONS=""'); + expect(wrapper.indexOf('EXPECTED_PENDING_MIGRATIONS=""')).toBeGreaterThan( + wrapper.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"'), + ); expect(wrapper.indexOf('ssh "$DEPLOY_REFRESH_SERVER" refresh')).toBeGreaterThan(wrapper.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"')); expect(wrapper).toContain("DEPLOYED_BUT_CONTRACT_REFRESH_FAILED"); }); + it("requires production target identity and pending migration allowlist before deploy.sh", async () => { + const wrapper = await readFile(path.join(ROOT_DIR, "deploy/trialrun/deploy-existing.sh"), "utf8"); + const preflight = await readFile(PRODUCTION_PREFLIGHT, "utf8"); + const remote = await readFile(PRODUCTION_PREFLIGHT_REMOTE, "utf8"); + + expect(wrapper).toContain("EXPECTED_PRODUCTION_ORIGIN"); + expect(wrapper).toContain("EXPECTED_PRODUCTION_DB_NAME"); + expect(wrapper).toContain("EXPECTED_PENDING_MIGRATIONS"); + expect(wrapper.indexOf('node "$ROOT_DIR/$PRODUCTION_PREFLIGHT_SCRIPT"')).toBeLessThan( + wrapper.indexOf('bash "$ROOT_DIR/deploy/trialrun/deploy.sh"'), + ); + expect(preflight).toContain("CANDIDATE_MIGRATIONS_CSV"); + expect(preflight).toContain("PRISMA_STATUS_OUTPUT_MISSING"); + expect(preflight).toContain("PRISMA_STATUS_OUTPUT_UNREADABLE"); + expect(preflight).toContain("redacted datasource line"); + expect(preflight).toContain("PRODUCTION_PREFLIGHT_REPORT_NOT_FRESH"); + expect(remote).toContain("PRISMA_STATUS_BEGIN"); + expect(remote).toContain("PROD_MIGRATION_STATUS_UNKNOWN_PENDING"); + expect(remote).toContain("logged_failure"); + expect(remote).toContain("PROD_DATABASE_URL_HOST_NON_PRODUCTION_IDENTITY"); + expect(remote).toContain('FROM "_prisma_migrations"'); + expect(remote).toContain("rollbackWindow=OPEN"); + expect(remote).not.toMatch(/\b(prisma migrate reset|prisma db push|prisma migrate resolve)\b/); + expect(remote.indexOf("PRISMA_STATUS_BEGIN")).toBeLessThan(remote.indexOf("_prisma_migrations")); + }); + + it("rejects loopback/staging production identities without invoking SSH", async () => { + const target = await import(pathToFileURL(PRODUCTION_PREFLIGHT).href); + expect(() => target.validateProductionOrigin("https://127.0.0.1")).toThrowError( + expect.objectContaining({ code: "PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY" }), + ); + expect(() => target.validateProductionOrigin("https://staging.example.com")).toThrowError( + expect.objectContaining({ code: "PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY" }), + ); + expect(() => target.validateProductionOrigin("https://0.0.0.0")).toThrowError( + expect.objectContaining({ code: "PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY" }), + ); + expect(() => target.validateProductionOrigin("https://[::ffff:127.0.0.1]")).toThrowError( + expect.objectContaining({ code: "PROD_TARGET_ORIGIN_NON_PRODUCTION_IDENTITY" }), + ); + expect(() => target.validateProductionDbName("hyb_oa_regression")).toThrowError( + expect.objectContaining({ code: "PROD_TARGET_DB_NON_PRODUCTION_IDENTITY" }), + ); + expect(() => target.validatePrismaStatusText("")).toThrowError( + expect.objectContaining({ code: "PRISMA_STATUS_OUTPUT_UNREADABLE" }), + ); + expect(() => target.validatePrismaStatusText("unexpected output")).toThrowError( + expect.objectContaining({ code: "PRISMA_STATUS_OUTPUT_UNREADABLE" }), + ); + }); + + it("captures a redacted pre-status evidence copy from a controlled SSH fixture", async () => { + const fakeBin = path.join(fixtureRoot, "bin"); + const reportDir = path.join(fixtureRoot, "report"); + const sshCapture = path.join(fixtureRoot, "ssh-command.txt"); + await mkdir(fakeBin, { recursive: true }); + await mkdir(reportDir, { recursive: true }); + await writeFile(path.join(reportDir, "prisma-migrate-status.txt"), "stale PASS\n"); + const fakeSsh = path.join(fakeBin, "ssh"); + await writeFile( + fakeSsh, + [ + "#!/usr/bin/env node", + "require('node:fs').writeFileSync(process.env.FAKE_SSH_CAPTURE, process.argv.slice(2).join(' '));", + "process.stdout.write('[production-preflight][PASS] target_identity\\n');", + "process.stdout.write('[production-preflight][PRISMA_STATUS_BEGIN]\\n');", + "process.stdout.write('Datasource \\\"db\\\": PostgreSQL database \\\"hyb_oa\\\" at \\\"db.internal:5432\\\"\\n');", + "process.stdout.write('Database schema is up to date!\\n');", + "process.stdout.write('[production-preflight][PRISMA_STATUS_END] exit=0\\n');", + "process.stdout.write('[production-preflight][PASS] migration_reconciliation pending=none\\n');", + "process.stdout.write('[production-preflight][PASS] rollbackWindow=OPEN terminatedRows=0\\n');", + ].join("\n"), + { mode: 0o700 }, + ); + await chmod(fakeSsh, 0o700); + + const candidateCommit = require("node:child_process") + .execFileSync("git", ["rev-parse", "HEAD"], { cwd: ROOT_DIR, encoding: "utf8" }) + .trim(); + const result = await execFileAsync("node", [PRODUCTION_PREFLIGHT], { + cwd: ROOT_DIR, + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, + DEPLOY_SERVER: "deploy.example.invalid", + REMOTE_DIR: remoteDir, + REMOTE_ENV_FILE: envFile, + COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", + COMPOSE_PROJECT_NAME: "production_app", + POSTGRES_DATA_DIR: postgresDir, + STORAGE_HOST_DIR: storageDir, + EXPECTED_PRODUCTION_ORIGIN: "https://oa.hyinda.com", + EXPECTED_PRODUCTION_DB_NAME: "hyb_oa", + EXPECTED_PENDING_MIGRATIONS: "", + CANDIDATE_COMMIT: candidateCommit, + PRODUCTION_PREFLIGHT_REPORT_DIR: reportDir, + FAKE_SSH_CAPTURE: sshCapture, + }, + }); + const statusEvidence = await readFile(path.join(reportDir, "prisma-migrate-status.txt"), "utf8"); + const sshCommand = await readFile(sshCapture, "utf8"); + expect(result.stdout).toContain("[production-preflight][PASS]"); + expect(statusEvidence).toContain("Database schema is up to date!"); + expect(statusEvidence).toContain("[redacted datasource line]"); + expect(statusEvidence).not.toContain("hyb_oa"); + expect(statusEvidence).not.toContain("db.internal:5432"); + expect(result.stdout).not.toContain("oa.hyinda.com"); + expect(result.stdout).not.toContain("hyb_oa"); + expect(sshCommand).not.toContain("oa.hyinda.com"); + expect(sshCommand).not.toContain("hyb_oa"); + expect(sshCommand).toContain("EXPECTED_PRODUCTION_ORIGIN_SHA256"); + expect(sshCommand).toContain("EXPECTED_PRODUCTION_DB_NAME_SHA256"); + }); + + it("runs the remote gate against synthetic status and migration history", async () => { + const fakeBin = path.join(fixtureRoot, "remote-bin"); + await mkdir(fakeBin, { recursive: true }); + await writeFile(path.join(remoteDir, "deploy/trialrun/.env"), [ + "APP_ORIGIN=https://oa.example.com", + "POSTGRES_DB=production_db", + "POSTGRES_USER=synthetic_user", + "NODE_ENV=production", + "COOKIE_SECURE=true", + "", + ].join("\n"), { mode: 0o600 }); + await writeFile(path.join(fakeBin, "docker"), [ + "#!/usr/bin/env bash", + "set -euo pipefail", + "joined=\"$*\"", + "mode=\"${FAKE_REMOTE_MODE:-happy}\"", + "if [[ \"$1\" == compose && \"${2:-}\" == version ]]; then exit 0; fi", + "if [[ \"$1\" == ps ]]; then", + " if [[ \"$joined\" == *service=server* ]]; then printf 'server1\\n'; else printf 'db1\\n'; fi", + " exit 0", + "fi", + "if [[ \"$1\" == inspect ]]; then printf 'NODE_ENV=production\\nCOOKIE_SECURE=true\\nAPP_ORIGIN=https://oa.example.com\\n'; exit 0; fi", + "if [[ \"$joined\" == *' config'* ]]; then exit 0; fi", + "if [[ \"$joined\" == *' exec '* && \"$joined\" == *' sh -lc '* ]]; then", + " case \"$mode\" in", + " pending) printf 'Following migration have not yet been applied:\\n20260807193001_followup_migration\\n'; exit 1 ;;", + " unknown) printf 'Following migration have not yet been applied:\\n20260807193002_unknown_migration\\n'; exit 1 ;;", + " *) printf 'Database schema is up to date!\\n'; exit 0 ;;", + " esac", + "fi", + "if [[ \"$joined\" == *' exec '* && \"$joined\" == *' node -e '* ]]; then", + " printf 'db.internal\\nproduction_db\\n'; exit 0", + "fi", + "if [[ \"$joined\" == *'SELECT current_database()'* ]]; then printf 'production_db\\n'; exit 0; fi", + "if [[ \"$joined\" == *'_prisma_migrations'* ]]; then", + " case \"$mode\" in", + " pending) printf '20260807193000_contract_registry_termination_workspace|applied\\n';;", + " logs) printf '20260807193000_contract_registry_termination_workspace|logged_failure\\n';;", + " *) printf '20260807193000_contract_registry_termination_workspace|applied\\n20260807193001_followup_migration|applied\\n';;", + " esac", + " exit 0", + "fi", + "if [[ \"$joined\" == *'contract_registry'* ]]; then printf '0\\n'; exit 0; fi", + "printf 'unexpected fake docker call: %s\\n' \"$joined\" >&2", + "exit 97", + "", + ].join("\n"), { mode: 0o700 }); + await chmod(path.join(fakeBin, "docker"), 0o700); + + const hash = (value) => createHash("sha256").update(value, "utf8").digest("hex"); + const runRemote = (mode, expectedPending = "") => execFileAsync("bash", [PRODUCTION_PREFLIGHT_REMOTE], { + cwd: remoteDir, + env: { + ...process.env, + PATH: `${fakeBin}${path.delimiter}${process.env.PATH}`, + FAKE_REMOTE_MODE: mode, + REMOTE_DIR: remoteDir, + REMOTE_ENV_FILE: envFile, + COMPOSE_FILE: "deploy/trialrun/docker-compose.yml", + COMPOSE_PROJECT_NAME: "production_app", + POSTGRES_DATA_DIR: postgresDir, + STORAGE_HOST_DIR: storageDir, + EXPECTED_PRODUCTION_ORIGIN_SHA256: hash("https://oa.example.com"), + EXPECTED_PRODUCTION_DB_NAME_SHA256: hash("production_db"), + CANDIDATE_MIGRATIONS_CSV: "20260807193000_contract_registry_termination_workspace,20260807193001_followup_migration", + EXPECTED_PENDING_MIGRATIONS_CSV: expectedPending, + }, + }); + const result = await runRemote("happy"); + expect(result.stdout).toContain("target_identity"); + expect(result.stdout).toContain("migration_reconciliation pending=none"); + expect(result.stdout).toContain("rollbackWindow=OPEN"); + const pendingResult = await runRemote("pending", "20260807193001_followup_migration"); + expect(pendingResult.stdout).toContain("migration_reconciliation pending=20260807193001_followup_migration"); + await expect(runRemote("unknown")).rejects.toMatchObject({ + stderr: expect.stringContaining("PROD_MIGRATION_STATUS_UNKNOWN_PENDING"), + }); + await expect(runRemote("logs")).rejects.toMatchObject({ + stderr: expect.stringContaining("PROD_MIGRATION_FAILED_OR_ROLLED_BACK"), + }); + }); + it("aggregates preflight checks and keeps the root helper self-contained", async () => { const aggregate = await require("node:fs/promises").readFile(AGGREGATE_PREFLIGHT, "utf8"); const helper = await require("node:fs/promises").readFile(REFRESH_HELPER, "utf8");