362 行
14 KiB
JavaScript
可执行文件
362 行
14 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";
|
|
import { deriveCandidateBuildEnv } from "./check-source-provenance.mjs";
|
|
|
|
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, "<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;
|
|
}
|
|
|
|
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();
|
|
let candidateBuildEnv;
|
|
try {
|
|
candidateBuildEnv = deriveCandidateBuildEnv(candidateCommit);
|
|
} catch {
|
|
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(expectedDbNameHash)}`,
|
|
...Object.entries(candidateBuildEnv).map(
|
|
([key, value]) => `${key}=${shellQuote(value)}`,
|
|
),
|
|
`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);
|
|
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,
|
|
});
|
|
validatePrismaStatusText(rawStatus);
|
|
const sanitizedStatus = sanitizePrismaStatus(rawStatus, [expectedOrigin]);
|
|
writeFileSync(path.join(reportDir, "prisma-migrate-status.txt"), `${sanitizedStatus}\n`, {
|
|
encoding: "utf8",
|
|
mode: 0o600,
|
|
});
|
|
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();
|