#!/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|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 assertProductionIdentityValue(name, value) { const normalized = String(value ?? "").trim().toLowerCase(); if (!normalized || normalized.includes("<") || normalized.includes(">") || FORBIDDEN_IDENTITY_TOKEN_PATTERN.test(normalized)) { fail(`${name}_NON_PRODUCTION_IDENTITY`); } } function assertExactIdentity(name, actual, expected) { if (expected == null || !String(expected).trim()) fail(`EXPECTED_${name}_MISSING`); if (actual !== String(expected).trim()) fail(`${name}_MISMATCH`); } export function validateExactProductionIdentity({ deployServer, remoteDir, composeProjectName, expectedDeployServer, expectedRemoteDir, expectedComposeProjectName, }) { assertProductionIdentityValue("DEPLOY_SERVER", deployServer); assertProductionIdentityValue("REMOTE_DIR", remoteDir); assertProductionIdentityValue("COMPOSE_PROJECT_NAME", composeProjectName); assertExactIdentity("DEPLOY_SERVER", deployServer, expectedDeployServer); assertExactIdentity("REMOTE_DIR", remoteDir, expectedRemoteDir); assertExactIdentity("COMPOSE_PROJECT_NAME", composeProjectName, expectedComposeProjectName); } 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; } export function assertRemotePreflightResult({ status, stdout, stderr, rawStatus }) { if (status !== 0) { const remoteCode = `${stdout}\n${stderr || ""}`.match(/code=([A-Z0-9_]+)/u)?.[1]; fail(remoteCode || "PRODUCTION_PREFLIGHT_REMOTE_FAILED"); } if (rawStatus == null) fail("PRISMA_STATUS_OUTPUT_MISSING"); } 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 expectedDbNameHashRaw = process.env.EXPECTED_PRODUCTION_DB_NAME_SHA256; const expectedPendingRaw = process.env.EXPECTED_PENDING_MIGRATIONS; const expectedDeployServer = process.env.EXPECTED_DEPLOY_SERVER; const expectedRemoteDir = process.env.EXPECTED_REMOTE_DIR; const expectedComposeProjectName = process.env.EXPECTED_COMPOSE_PROJECT_NAME; if (expectedOriginRaw == null) fail("EXPECTED_PRODUCTION_ORIGIN_MISSING"); if (expectedDbNameHashRaw == null) fail("EXPECTED_PRODUCTION_DB_NAME_SHA256_MISSING"); if (expectedPendingRaw == null) fail("EXPECTED_PENDING_MIGRATIONS_MISSING"); const expectedOrigin = validateProductionOrigin(expectedOriginRaw); const expectedDbNameHash = String(expectedDbNameHashRaw).trim(); if (!/^[0-9a-f]{64}$/u.test(expectedDbNameHash)) fail("EXPECTED_PRODUCTION_DB_NAME_SHA256_INVALID"); 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); validateExactProductionIdentity({ deployServer, remoteDir, composeProjectName, expectedDeployServer, expectedRemoteDir, expectedComposeProjectName, }); if (process.env.CANDIDATE_COMMIT == null) fail("CANDIDATE_COMMIT_MISSING"); const candidateCommit = String(process.env.CANDIDATE_COMMIT).trim(); if (!/^[0-9a-f]{40,64}$/iu.test(candidateCommit)) fail("CANDIDATE_COMMIT_INVALID"); const candidateShortCommit = candidateCommit.slice(0, 12); 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(expectedDbNameHash)}`, `BUILD_SHA=${shellQuote(candidateCommit)}`, `VITE_BUILD_SHA=${shellQuote(candidateShortCommit)}`, `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]); 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)}`); assertRemotePreflightResult({ status: result.status, stdout, stderr: result.stderr, rawStatus, }); 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();