197 lines
5.8 KiB
JavaScript
197 lines
5.8 KiB
JavaScript
#!/usr/bin/env node
|
|
import { spawn } from "node:child_process";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
export const MIGRATE_STATUS_COMMAND = Object.freeze({
|
|
command: "pnpm",
|
|
args: ["--filter", "@toolctrl/db", "prisma:migrate:status"],
|
|
});
|
|
|
|
const MAX_EXCERPT_LENGTH = 1200;
|
|
const ALLOW_UNREACHABLE_ENV = "DB_MIGRATE_STATUS_ALLOW_UNREACHABLE";
|
|
const DATABASE_URL_ASSIGNMENT_PATTERN = /\bDATABASE_URL\s*=\s*([^\s'\"]+|'[^']*'|\"[^\"]*\")/g;
|
|
const DATABASE_URL_JSON_PATTERN = /("DATABASE_URL"\s*:\s*")([^"]+)(")/g;
|
|
const DATABASE_URL_TEXT_PATTERN = /(DATABASE_URL(?:\s+(?:is|was|value|set to|configured as))?[:=]?\s+)([^\s]+)/gi;
|
|
const URL_WITH_CREDENTIALS_PATTERN = /\b([a-z][a-z0-9+.-]*:\/\/)([^\s/@:]+)(?::([^\s/@]*))?@/gi;
|
|
const UNREACHABLE_DATABASE_PATTERN = /\bP1001\b|Can't reach database server/i;
|
|
|
|
export function buildMigrateStatusCommand() {
|
|
return {
|
|
command: MIGRATE_STATUS_COMMAND.command,
|
|
args: [...MIGRATE_STATUS_COMMAND.args],
|
|
display: [MIGRATE_STATUS_COMMAND.command, ...MIGRATE_STATUS_COMMAND.args].join(" "),
|
|
};
|
|
}
|
|
|
|
function redactUrlCredentials(value) {
|
|
return value.replace(URL_WITH_CREDENTIALS_PATTERN, (_match, protocol) => `${protocol}<redacted>@`);
|
|
}
|
|
|
|
export function redactSecrets(value, env = {}) {
|
|
let redacted = String(value ?? "");
|
|
const databaseUrl = env.DATABASE_URL;
|
|
|
|
if (databaseUrl) {
|
|
redacted = redacted.split(databaseUrl).join("<redacted:DATABASE_URL>");
|
|
}
|
|
|
|
redacted = redacted
|
|
.replace(DATABASE_URL_JSON_PATTERN, `$1<redacted:DATABASE_URL>$3`)
|
|
.replace(DATABASE_URL_ASSIGNMENT_PATTERN, "DATABASE_URL=<redacted:DATABASE_URL>")
|
|
.replace(DATABASE_URL_TEXT_PATTERN, (_match, prefix) => `${prefix}<redacted:DATABASE_URL>`);
|
|
|
|
return redactUrlCredentials(redacted);
|
|
}
|
|
|
|
export function createOutputExcerpt(value, env = {}, maxLength = MAX_EXCERPT_LENGTH) {
|
|
const sanitized = redactSecrets(value, env).trim();
|
|
|
|
if (!sanitized) {
|
|
return "<empty>";
|
|
}
|
|
|
|
if (sanitized.length <= maxLength) {
|
|
return sanitized;
|
|
}
|
|
|
|
return `${sanitized.slice(0, maxLength)}… <truncated ${sanitized.length - maxLength} chars>`;
|
|
}
|
|
|
|
export function formatMigrateStatusResult(result) {
|
|
const status = result.ok ? "PASS" : "FAIL";
|
|
const lines = [
|
|
`${status} database migrate status verification`,
|
|
`phase: ${result.phase}`,
|
|
`command: ${result.commandDisplay}`,
|
|
`exitCode: ${result.exitCode ?? "n/a"}`,
|
|
`status: ${result.status}`,
|
|
`stdout: ${result.stdoutExcerpt}`,
|
|
`stderr: ${result.stderrExcerpt}`,
|
|
];
|
|
|
|
if (result.errorMessage) {
|
|
lines.push(`error: ${result.errorMessage}`);
|
|
}
|
|
|
|
lines.push("");
|
|
return lines.join("\n");
|
|
}
|
|
|
|
function allowsUnreachableDatabase(env = {}) {
|
|
return env[ALLOW_UNREACHABLE_ENV] === "1" || env[ALLOW_UNREACHABLE_ENV] === "true";
|
|
}
|
|
|
|
function isUnreachableDatabaseResult({ stdout = "", stderr = "" }) {
|
|
return UNREACHABLE_DATABASE_PATTERN.test(`${stdout}\n${stderr}`);
|
|
}
|
|
|
|
function runSpawn({ command, args, env, cwd, spawnImpl }) {
|
|
return new Promise((resolve) => {
|
|
let child;
|
|
|
|
try {
|
|
child = spawnImpl(command, args, {
|
|
cwd,
|
|
env: { ...env, CI: "1" },
|
|
stdio: ["ignore", "pipe", "pipe"],
|
|
});
|
|
} catch (error) {
|
|
resolve({ exitCode: null, stdout: "", stderr: "", error });
|
|
return;
|
|
}
|
|
|
|
let stdout = "";
|
|
let stderr = "";
|
|
let settled = false;
|
|
|
|
const settle = (payload) => {
|
|
if (!settled) {
|
|
settled = true;
|
|
resolve(payload);
|
|
}
|
|
};
|
|
|
|
child.stdout?.on?.("data", (chunk) => {
|
|
stdout += chunk.toString();
|
|
});
|
|
child.stderr?.on?.("data", (chunk) => {
|
|
stderr += chunk.toString();
|
|
});
|
|
child.on?.("error", (error) => {
|
|
settle({ exitCode: null, stdout, stderr, error });
|
|
});
|
|
child.on?.("close", (exitCode) => {
|
|
settle({ exitCode, stdout, stderr, error: null });
|
|
});
|
|
});
|
|
}
|
|
|
|
export async function verifyDbMigrateStatus({ env = process.env, spawnImpl = spawn, cwd = process.cwd() } = {}) {
|
|
const command = buildMigrateStatusCommand();
|
|
|
|
if (!env.DATABASE_URL) {
|
|
return {
|
|
ok: false,
|
|
phase: "preflight",
|
|
commandDisplay: command.display,
|
|
exitCode: null,
|
|
status: "missing DATABASE_URL",
|
|
stdoutExcerpt: "<empty>",
|
|
stderrExcerpt: "DATABASE_URL is required for Prisma migrate status and was not provided.",
|
|
};
|
|
}
|
|
|
|
const spawnResult = await runSpawn({
|
|
command: command.command,
|
|
args: command.args,
|
|
env,
|
|
cwd,
|
|
spawnImpl,
|
|
});
|
|
|
|
if (spawnResult.error) {
|
|
return {
|
|
ok: false,
|
|
phase: "spawn",
|
|
commandDisplay: command.display,
|
|
exitCode: spawnResult.exitCode,
|
|
status: "spawn error",
|
|
stdoutExcerpt: createOutputExcerpt(spawnResult.stdout, env),
|
|
stderrExcerpt: createOutputExcerpt(spawnResult.stderr, env),
|
|
errorMessage: createOutputExcerpt(spawnResult.error.message, env),
|
|
};
|
|
}
|
|
|
|
const unreachableDatabaseAllowed =
|
|
spawnResult.exitCode !== 0 && allowsUnreachableDatabase(env) && isUnreachableDatabaseResult(spawnResult);
|
|
const ok = spawnResult.exitCode === 0 || unreachableDatabaseAllowed;
|
|
return {
|
|
ok,
|
|
phase: "prisma:migrate:status",
|
|
commandDisplay: command.display,
|
|
exitCode: spawnResult.exitCode,
|
|
status: unreachableDatabaseAllowed
|
|
? `database unreachable; skipped by ${ALLOW_UNREACHABLE_ENV}`
|
|
: ok
|
|
? "migrations status clean"
|
|
: "prisma migrate status failed",
|
|
stdoutExcerpt: createOutputExcerpt(spawnResult.stdout, env),
|
|
stderrExcerpt: createOutputExcerpt(spawnResult.stderr, env),
|
|
};
|
|
}
|
|
|
|
const isCli = process.argv[1] && fileURLToPath(import.meta.url) === path.resolve(process.argv[1]);
|
|
|
|
if (isCli) {
|
|
const result = await verifyDbMigrateStatus();
|
|
const output = formatMigrateStatusResult(result);
|
|
|
|
if (result.ok) {
|
|
process.stdout.write(output);
|
|
} else {
|
|
process.stderr.write(output);
|
|
process.exitCode = 1;
|
|
}
|
|
}
|