#!/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 TERMINATION_MIGRATION_ID="20260807193000_contract_registry_termination_workspace" 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 BUILD_SHA VITE_BUILD_SHA \ 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" [[ "$BUILD_SHA" =~ ^[0-9a-f]{40,64}$ ]] \ || fail "PROD_TARGET_BUILD_SHA_INVALID" [[ "$VITE_BUILD_SHA" =~ ^[0-9a-f]{12}$ ]] \ || fail "PROD_TARGET_VITE_BUILD_SHA_INVALID" [[ "${BUILD_SHA:0:12}" == "$VITE_BUILD_SHA" ]] \ || fail "PROD_TARGET_BUILD_SHA_MISMATCH" 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" != *"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}" [[ -n "${candidate_set[$TERMINATION_MIGRATION_ID]:-}" ]] \ || fail "PROD_TERMINATION_MIGRATION_NOT_IN_CANDIDATE" if [[ -z "${applied_set[$TERMINATION_MIGRATION_ID]:-}" ]]; then pass "rollbackWindow=OPEN terminatedRows=0 basis=migration_not_applied" else 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 basis=terminated_status_count" else pass "rollbackWindow=CLOSED terminatedRows=${terminated_count} basis=terminated_status_count" fi fi pass "migration_status_and_target_checks_complete"