feat(08-08-production-deployment-validation): 增加生产目标与迁移只读门禁
这个提交包含在:
@@ -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
|
||||
|
||||
+13
-2
@@ -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; then
|
||||
if [[ "${DEPLOY_STATUS_OUTPUT_MODE:-verbose}" == "quiet" ]]; then
|
||||
if ! "${compose_cmd[@]}" exec -T server sh -lc 'cd /app/server && npx prisma migrate status' </dev/null >/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' </dev/null; then
|
||||
echo "[ERROR] Prisma migration status 未通过,停止部署" >&2
|
||||
"${compose_cmd[@]}" logs --tail=200 server || true
|
||||
exit 1
|
||||
|
||||
@@ -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 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"
|
||||
|
||||
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"
|
||||
+316
@@ -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, "<redacted database url>");
|
||||
for (const value of sensitiveValues) {
|
||||
if (value) sanitized = sanitized.split(value).join("<redacted>");
|
||||
}
|
||||
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();
|
||||
@@ -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 文件',
|
||||
|
||||
@@ -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");
|
||||
|
||||
在新工单中引用
屏蔽一个用户