306 行
12 KiB
Bash
可执行文件
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"
|