文件
hyb-oa/deploy/trialrun/production-preflight.mjs
T

317 行
12 KiB
JavaScript
可执行文件

#!/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();