文件
hyb-oa/deploy/trialrun/production-preflight-remote.sh

306 行
12 KiB
Bash
可执行文件

#!/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 2>/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 2>/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' </dev/null 2>&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"
status_matches() {
local status
if grep -Ei -- "$1" <<<"$prisma_status_output" >/dev/null; then
return 0
else
status=$?
fi
[[ "$status" -eq 1 ]] || fail "PROD_MIGRATION_STATUS_UNREADABLE"
return 1
}
if status_matches '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 ! status_matches '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 ]] \
&& ! status_matches 'have not yet been applied'; then
fail "PROD_MIGRATION_STATUS_UNREADABLE"
fi
declare -a status_pending_ids=()
if status_matches '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"