726 lines
24 KiB
JavaScript
726 lines
24 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { mkdir, readFile, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import {
|
|
CLOUD_CORE_MIGRATION_ID,
|
|
CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
|
CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
|
CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE_COLUMNS,
|
|
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS
|
|
} from "../../internal/db/schema.mjs";
|
|
import {
|
|
PostgresCloudRuntimeStore,
|
|
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING,
|
|
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
|
buildPostgresPoolConfig
|
|
} from "../../internal/db/runtime-store.mjs";
|
|
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
|
|
import { ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
|
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const migrationPath = "internal/db/migrations/0001_cloud_core_skeleton.sql";
|
|
const defaultReportPath = tempReportPath("dev-runtime-migration-report.json");
|
|
const issue = "pikasTech/HWLAB#164";
|
|
|
|
export async function runDevRuntimeMigrationCli(argv = process.argv.slice(2), options = {}) {
|
|
const args = parseArgs(argv);
|
|
if (args.help) {
|
|
process.stdout.write(`${usage()}\n`);
|
|
return 0;
|
|
}
|
|
|
|
const report = await buildDevRuntimeMigrationReport(args, options);
|
|
if (args.writeReport) {
|
|
await writeReport(report, args.reportPath, options.repoRoot ?? repoRoot);
|
|
}
|
|
|
|
const printable = args.pretty || args.writeReport ? report : summarizeReport(report);
|
|
process.stdout.write(`${JSON.stringify(printable, null, 2)}\n`);
|
|
|
|
if (report.safetyRefusal || (args.failOnBlocked && report.conclusion.status !== "ready")) {
|
|
return 1;
|
|
}
|
|
return 0;
|
|
}
|
|
|
|
export async function buildDevRuntimeMigrationReport(args = {}, options = {}) {
|
|
const parsed = normalizeArgs(args);
|
|
const env = options.env ?? process.env;
|
|
const now = options.now ?? (() => new Date().toISOString());
|
|
const root = options.repoRoot ?? repoRoot;
|
|
const sql = await readFile(path.join(root, migrationPath), "utf8");
|
|
const sourceCheck = validateMigrationSource(sql);
|
|
const db = summarizeDbEnv(env);
|
|
const report = {
|
|
issue,
|
|
mode: parsed.mode,
|
|
generatedAt: now(),
|
|
target: {
|
|
environment: ENVIRONMENT_DEV,
|
|
namespace: DEV_DB_ENV_CONTRACT.dns.namespace,
|
|
prodAllowed: false,
|
|
dbUrlEnv: "HWLAB_CLOUD_DB_URL",
|
|
dbUrlSecretRef: DEV_DB_ENV_CONTRACT.secretRefs[0],
|
|
dbEndpointAuthority: DEV_DB_ENV_CONTRACT.endpointAuthority.source
|
|
},
|
|
migration: {
|
|
id: CLOUD_CORE_MIGRATION_ID,
|
|
path: migrationPath,
|
|
sha256: sha256(sql),
|
|
schemaVersion: CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
|
ledgerTable: CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
|
runtimeAdapter: "postgres"
|
|
},
|
|
db,
|
|
sourceCheck,
|
|
applyBoundary: buildApplyBoundary(parsed),
|
|
safety: buildSafety(parsed),
|
|
actions: {
|
|
sourceValidated: sourceCheck.ready,
|
|
liveDbReadAttempted: false,
|
|
liveDbWriteAttempted: false,
|
|
migrationApplied: false,
|
|
readinessVerified: false
|
|
},
|
|
gates: sourceOnlyGates(sourceCheck),
|
|
blockers: [],
|
|
safetyRefusal: false
|
|
};
|
|
|
|
if (!sourceCheck.ready) {
|
|
addBlocker(report, "contract_blocker", "runtime-migration-source", "Runtime migration source does not match the durable runtime schema contract.", {
|
|
missing: sourceCheck.missing
|
|
});
|
|
}
|
|
|
|
if (parsed.mode === "check" || (parsed.mode === "dry-run" && !parsed.allowLiveDbRead)) {
|
|
return finalizeReport(report);
|
|
}
|
|
|
|
if (parsed.mode === "dry-run") {
|
|
if (!parsed.confirmDev) {
|
|
addSafetyRefusal(report, "dry-run live DB verification requires --confirm-dev.");
|
|
return finalizeReport(report);
|
|
}
|
|
if (!db.urlPresent && !options.queryClient) {
|
|
addBlocker(report, "runtime_blocker", "runtime-migration-auth", "Live DB read verification requires HWLAB_CLOUD_DB_URL from the DEV Secret reference.", {
|
|
env: "HWLAB_CLOUD_DB_URL",
|
|
valueRedacted: true
|
|
});
|
|
return finalizeReport(report);
|
|
}
|
|
await verifyRuntimeReadiness(report, env, options);
|
|
return finalizeReport(report);
|
|
}
|
|
|
|
if (parsed.mode === "apply") {
|
|
if (!parsed.confirmDev || !parsed.confirmedNonProduction) {
|
|
addSafetyRefusal(report, "DEV runtime migration apply requires --confirm-dev and --confirmed-non-production.");
|
|
return finalizeReport(report);
|
|
}
|
|
if (!db.urlPresent && !options.queryClient) {
|
|
addBlocker(report, "runtime_blocker", "runtime-migration-auth", "Runtime migration apply requires HWLAB_CLOUD_DB_URL from the DEV Secret reference.", {
|
|
env: "HWLAB_CLOUD_DB_URL",
|
|
valueRedacted: true
|
|
});
|
|
return finalizeReport(report);
|
|
}
|
|
if (!sourceCheck.ready) {
|
|
addSafetyRefusal(report, "Refusing to apply because source migration validation is blocked.");
|
|
return finalizeReport(report);
|
|
}
|
|
await applyMigration(report, sql, env, options);
|
|
if (!report.safetyRefusal && report.actions.migrationApplied) {
|
|
await verifyRuntimeReadiness(report, env, options);
|
|
}
|
|
return finalizeReport(report);
|
|
}
|
|
|
|
addSafetyRefusal(report, `Unsupported runtime migration mode: ${parsed.mode}`);
|
|
return finalizeReport(report);
|
|
}
|
|
|
|
export function parseArgs(argv = []) {
|
|
const args = {
|
|
mode: "check",
|
|
allowLiveDbRead: false,
|
|
confirmDev: false,
|
|
confirmedNonProduction: false,
|
|
writeReport: false,
|
|
reportPath: defaultReportPath,
|
|
pretty: false,
|
|
failOnBlocked: false,
|
|
help: false
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--check") args.mode = "check";
|
|
else if (arg === "--dry-run") args.mode = "dry-run";
|
|
else if (arg === "--apply") args.mode = "apply";
|
|
else if (arg === "--allow-live-db-read") args.allowLiveDbRead = true;
|
|
else if (arg === "--confirm-dev") args.confirmDev = true;
|
|
else if (arg === "--confirmed-non-production") args.confirmedNonProduction = true;
|
|
else if (arg === "--pretty") args.pretty = true;
|
|
else if (arg === "--fail-on-blocked") args.failOnBlocked = true;
|
|
else if (arg === "--help" || arg === "-h") args.help = true;
|
|
else if (arg === "--report") {
|
|
index += 1;
|
|
args.reportPath = requireArgValue(argv[index], "--report");
|
|
args.writeReport = true;
|
|
} else if (arg.startsWith("--report=")) {
|
|
args.reportPath = requireArgValue(arg.slice("--report=".length), "--report");
|
|
args.writeReport = true;
|
|
} else {
|
|
throw new Error(`unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
export function validateMigrationSource(sql) {
|
|
const missing = [];
|
|
for (const [table, columns] of Object.entries(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS)) {
|
|
const tableMatch = sql.match(new RegExp(`CREATE TABLE IF NOT EXISTS ${table}\\s*\\(([\\s\\S]*?)\\);`, "u"));
|
|
if (!tableMatch) {
|
|
missing.push(`table:${table}`);
|
|
continue;
|
|
}
|
|
for (const column of columns) {
|
|
if (!new RegExp(`\\b${column}\\b`, "u").test(tableMatch[1])) {
|
|
missing.push(`column:${table}.${column}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
const ledgerMatch = sql.match(
|
|
new RegExp(`CREATE TABLE IF NOT EXISTS ${CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE}\\s*\\(([\\s\\S]*?)\\);`, "u")
|
|
);
|
|
if (!ledgerMatch) {
|
|
missing.push(`table:${CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE}`);
|
|
} else {
|
|
for (const column of CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE_COLUMNS) {
|
|
if (!new RegExp(`\\b${column}\\b`, "u").test(ledgerMatch[1])) {
|
|
missing.push(`column:${CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE}.${column}`);
|
|
}
|
|
}
|
|
}
|
|
|
|
if (!new RegExp(`'${CLOUD_CORE_MIGRATION_ID}'`, "u").test(sql)) {
|
|
missing.push(`ledger-id:${CLOUD_CORE_MIGRATION_ID}`);
|
|
}
|
|
if (!new RegExp(`'${CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION}'`, "u").test(sql)) {
|
|
missing.push(`schema-version:${CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION}`);
|
|
}
|
|
if (!/ON CONFLICT\s*\(id\)\s*DO UPDATE/iu.test(sql)) {
|
|
missing.push("ledger-upsert:on-conflict-id");
|
|
}
|
|
|
|
return {
|
|
ready: missing.length === 0,
|
|
checked: true,
|
|
migrationPath,
|
|
requiredMigrationId: CLOUD_CORE_MIGRATION_ID,
|
|
requiredSchemaVersion: CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
|
requiredTables: Object.keys(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS),
|
|
requiredLedgerTable: CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
|
missing,
|
|
connectsToDatabase: false,
|
|
fixtureEvidence: false
|
|
};
|
|
}
|
|
|
|
function normalizeArgs(args) {
|
|
return {
|
|
...parseArgs([]),
|
|
...args
|
|
};
|
|
}
|
|
|
|
async function verifyRuntimeReadiness(report, env, options) {
|
|
report.actions.liveDbReadAttempted = true;
|
|
const runtimeStore = new PostgresCloudRuntimeStore({
|
|
env,
|
|
dbUrl: env?.HWLAB_CLOUD_DB_URL,
|
|
sslMode: env?.HWLAB_CLOUD_DB_SSL_MODE,
|
|
queryClient: options.queryClient,
|
|
pgModuleLoader: options.pgModuleLoader
|
|
});
|
|
let runtime;
|
|
try {
|
|
runtime = await runtimeStore.readiness();
|
|
} finally {
|
|
if (!options.queryClient && typeof runtimeStore.pool?.end === "function") {
|
|
await runtimeStore.pool.end();
|
|
}
|
|
}
|
|
report.runtime = summarizeRuntime(runtime);
|
|
report.actions.readinessVerified = runtime.ready === true;
|
|
report.gates = gatesFromRuntime(runtime);
|
|
|
|
if (runtime.ready !== true) {
|
|
addBlocker(report, "runtime_blocker", blockerScope(runtime.blocker), runtime.reason ?? "Runtime durable readiness is blocked.", {
|
|
blocker: runtime.blocker ?? "runtime_durable_adapter_blocked",
|
|
adapter: runtime.adapter,
|
|
durable: Boolean(runtime.durable),
|
|
schemaReady: Boolean(runtime.schema?.ready),
|
|
migrationReady: Boolean(runtime.migration?.ready),
|
|
readinessReady: runtime.ready === true,
|
|
queryResult: runtime.connection?.queryResult ?? "unknown",
|
|
secretValuesPrinted: false
|
|
});
|
|
}
|
|
}
|
|
|
|
async function applyMigration(report, sql, env, options) {
|
|
report.actions.liveDbWriteAttempted = true;
|
|
let client;
|
|
let closeClient = async () => {};
|
|
try {
|
|
if (options.queryClient) {
|
|
client = options.queryClient;
|
|
} else {
|
|
const migrationClient = await createMigrationClient(env, options);
|
|
client = migrationClient.client;
|
|
closeClient = migrationClient.close;
|
|
}
|
|
await client.query("BEGIN");
|
|
await client.query(sql);
|
|
await client.query("COMMIT");
|
|
report.actions.migrationApplied = true;
|
|
} catch (error) {
|
|
await rollbackQuietly(client);
|
|
const classified = classifyMigrationError(error);
|
|
addBlocker(report, "runtime_blocker", blockerScope(classified.blocker), classified.summary, {
|
|
blocker: classified.blocker,
|
|
errorCode: classified.errorCode,
|
|
secretValuesPrinted: false
|
|
});
|
|
} finally {
|
|
await closeClient();
|
|
}
|
|
}
|
|
|
|
async function createMigrationClient(env, options) {
|
|
const pool = await createPgPool(env, options);
|
|
let client;
|
|
try {
|
|
client = await pool.connect();
|
|
} catch (error) {
|
|
await pool.end();
|
|
throw error;
|
|
}
|
|
return {
|
|
client,
|
|
close: async () => {
|
|
try {
|
|
client.release();
|
|
} finally {
|
|
await pool.end();
|
|
}
|
|
}
|
|
};
|
|
}
|
|
|
|
async function createPgPool(env, options) {
|
|
let pg;
|
|
try {
|
|
pg = await (options.pgModuleLoader ? options.pgModuleLoader() : import("pg"));
|
|
} catch (error) {
|
|
if (error?.code === "ERR_MODULE_NOT_FOUND") {
|
|
const driverError = new Error("Postgres migration apply requires the pg package");
|
|
driverError.code = "HWLAB_PG_DRIVER_MISSING";
|
|
throw driverError;
|
|
}
|
|
throw error;
|
|
}
|
|
const Pool = pg.Pool ?? pg.default?.Pool;
|
|
if (typeof Pool !== "function") {
|
|
const driverError = new Error("Postgres migration apply could not load pg.Pool");
|
|
driverError.code = "HWLAB_PG_DRIVER_MISSING";
|
|
throw driverError;
|
|
}
|
|
return new Pool(buildPostgresPoolConfig({
|
|
dbUrl: env.HWLAB_CLOUD_DB_URL,
|
|
sslMode: env.HWLAB_CLOUD_DB_SSL_MODE,
|
|
timeoutMs: normalizeTimeoutMs(env.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS)
|
|
}));
|
|
}
|
|
|
|
async function rollbackQuietly(client) {
|
|
if (!client) return;
|
|
try {
|
|
await client.query("ROLLBACK");
|
|
} catch {
|
|
// The original migration failure is reported without leaking connection details.
|
|
}
|
|
}
|
|
|
|
function gatesFromRuntime(runtime = {}) {
|
|
const gates = runtime.gates ?? {};
|
|
return {
|
|
auth: normalizeGate(gates.auth),
|
|
schema: normalizeGate(gates.schema),
|
|
migration: normalizeGate(gates.migration),
|
|
readiness: normalizeGate(gates.durability)
|
|
};
|
|
}
|
|
|
|
function sourceOnlyGates(sourceCheck) {
|
|
return {
|
|
auth: notCheckedGate("source-only validation does not read DB credentials or connect to Postgres"),
|
|
schema: sourceCheck.ready
|
|
? readyGate("source migration declares required durable runtime tables and columns")
|
|
: blockedGate("runtime_durable_schema_source_blocked"),
|
|
migration: sourceCheck.ready
|
|
? readyGate("source migration declares the required ledger upsert")
|
|
: blockedGate("runtime_durable_migration_source_blocked"),
|
|
readiness: notCheckedGate("runtime readiness requires a separate authorized DEV DB read or apply")
|
|
};
|
|
}
|
|
|
|
function normalizeGate(gate = {}) {
|
|
if (gate.ready === true) return readyGate();
|
|
if (gate.status === "blocked") return blockedGate(gate.blocker ?? "runtime_durable_adapter_blocked");
|
|
return notCheckedGate();
|
|
}
|
|
|
|
function readyGate(summary = "ready") {
|
|
return {
|
|
checked: true,
|
|
ready: true,
|
|
status: "ready",
|
|
blocker: null,
|
|
summary
|
|
};
|
|
}
|
|
|
|
function blockedGate(blocker, summary = "blocked") {
|
|
return {
|
|
checked: true,
|
|
ready: false,
|
|
status: "blocked",
|
|
blocker,
|
|
summary
|
|
};
|
|
}
|
|
|
|
function notCheckedGate(summary = "not checked") {
|
|
return {
|
|
checked: false,
|
|
ready: false,
|
|
status: "not_checked",
|
|
blocker: null,
|
|
summary
|
|
};
|
|
}
|
|
|
|
function summarizeRuntime(runtime = {}) {
|
|
return {
|
|
adapter: runtime.adapter ?? "unknown",
|
|
durable: Boolean(runtime.durable),
|
|
durableRequested: Boolean(runtime.durableRequested),
|
|
ready: runtime.ready === true,
|
|
status: runtime.status ?? "unknown",
|
|
blocker: runtime.blocker ?? null,
|
|
liveRuntimeEvidence: Boolean(runtime.liveRuntimeEvidence),
|
|
fixtureEvidence: Boolean(runtime.fixtureEvidence),
|
|
connection: {
|
|
queryAttempted: Boolean(runtime.connection?.queryAttempted),
|
|
queryResult: runtime.connection?.queryResult ?? "unknown",
|
|
endpointRedacted: true,
|
|
valueRedacted: true,
|
|
errorCode: runtime.connection?.errorCode ?? null
|
|
},
|
|
schema: summarizeRuntimeSchema(runtime.schema),
|
|
migration: summarizeRuntimeMigration(runtime.migration)
|
|
};
|
|
}
|
|
|
|
function summarizeRuntimeSchema(schema = {}) {
|
|
return {
|
|
checked: Boolean(schema.checked),
|
|
ready: Boolean(schema.ready),
|
|
requiredTables: Array.isArray(schema.requiredTables) ? [...schema.requiredTables] : Object.keys(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS),
|
|
missingTables: Array.isArray(schema.missingTables) ? [...schema.missingTables] : [],
|
|
missingColumns: Array.isArray(schema.missingColumns) ? [...schema.missingColumns] : []
|
|
};
|
|
}
|
|
|
|
function summarizeRuntimeMigration(migration = {}) {
|
|
return {
|
|
checked: Boolean(migration.checked),
|
|
ready: Boolean(migration.ready),
|
|
table: migration.table ?? CLOUD_RUNTIME_DURABLE_MIGRATION_TABLE,
|
|
requiredMigrationId: migration.requiredMigrationId ?? CLOUD_CORE_MIGRATION_ID,
|
|
requiredSchemaVersion: migration.requiredSchemaVersion ?? CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
|
appliedMigrationId: migration.appliedMigrationId ?? null,
|
|
appliedSchemaVersion: migration.appliedSchemaVersion ?? null,
|
|
missing: migration.missing !== false
|
|
};
|
|
}
|
|
|
|
function summarizeDbEnv(env = {}) {
|
|
const urlPresent = typeof env.HWLAB_CLOUD_DB_URL === "string" && env.HWLAB_CLOUD_DB_URL.trim().length > 0;
|
|
const sslMode = env.HWLAB_CLOUD_DB_SSL_MODE || DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE;
|
|
return {
|
|
requiredEnv: DEV_DB_ENV_CONTRACT.requiredEnv.map((name) => ({
|
|
name,
|
|
present: name === "HWLAB_CLOUD_DB_URL" ? urlPresent : typeof env[name] === "string" && env[name].trim().length > 0,
|
|
redacted: name === "HWLAB_CLOUD_DB_URL",
|
|
source: name === "HWLAB_CLOUD_DB_URL" ? "k8s-secret-ref" : "runtime-env"
|
|
})),
|
|
urlPresent,
|
|
urlValueRedacted: true,
|
|
sslMode,
|
|
sslModeSecret: false,
|
|
secretRef: DEV_DB_ENV_CONTRACT.secretRefs[0],
|
|
endpointAuthority: DEV_DB_ENV_CONTRACT.endpointAuthority.source,
|
|
optionalPublicDnsAliasRequired: DEV_DB_ENV_CONTRACT.dns.requiredForReadiness
|
|
};
|
|
}
|
|
|
|
function buildApplyBoundary(args) {
|
|
return {
|
|
defaultMode: "check",
|
|
mode: args.mode,
|
|
liveDbReads: args.mode === "apply" || (args.mode === "dry-run" && args.allowLiveDbRead),
|
|
liveDbWrites: args.mode === "apply",
|
|
requiresForLiveDbRead: ["--confirm-dev", "HWLAB_CLOUD_DB_URL from hwlab-cloud-api-dev-db/database-url"],
|
|
requiresForApply: ["--apply", "--confirm-dev", "--confirmed-non-production", "HWLAB_CLOUD_DB_URL from hwlab-cloud-api-dev-db/database-url"],
|
|
writeScope: args.mode === "apply"
|
|
? [
|
|
"DEV Postgres schema objects declared by internal/db/migrations/0001_cloud_core_skeleton.sql",
|
|
"DEV hwlab_schema_migrations ledger row 0001_cloud_core_skeleton"
|
|
]
|
|
: [],
|
|
noWriteScope: [
|
|
"Kubernetes Secret resources",
|
|
"PROD",
|
|
"service restarts",
|
|
"M3/M4/M5 acceptance"
|
|
],
|
|
forbidden: [
|
|
"printing DB URLs, passwords, tokens, Secret values, or kubeconfig material",
|
|
"using fixture output as DEV-LIVE runtime evidence",
|
|
"mutating PROD"
|
|
]
|
|
};
|
|
}
|
|
|
|
function buildSafety(args) {
|
|
return {
|
|
devOnly: true,
|
|
environment: ENVIRONMENT_DEV,
|
|
targetNamespace: DEV_DB_ENV_CONTRACT.dns.namespace,
|
|
prodAllowed: false,
|
|
sourceStoresSecretValues: false,
|
|
printsSecretValues: false,
|
|
dbUrlValueRedacted: true,
|
|
endpointRedacted: true,
|
|
readsKubernetesSecrets: false,
|
|
writesKubernetesSecrets: false,
|
|
liveDbWrites: args.mode === "apply",
|
|
fixtureEvidenceAllowed: false
|
|
};
|
|
}
|
|
|
|
function addBlocker(report, type, scope, summary, evidence = {}) {
|
|
report.blockers.push({
|
|
type,
|
|
scope,
|
|
status: "open",
|
|
summary,
|
|
sourceIssue: scope.includes("auth") ? "pikasTech/HWLAB#49" : issue,
|
|
evidence
|
|
});
|
|
}
|
|
|
|
function addSafetyRefusal(report, summary) {
|
|
report.safetyRefusal = true;
|
|
addBlocker(report, "safety_refusal", "runtime-migration-apply-boundary", summary, {
|
|
devOnly: true,
|
|
prodAllowed: false,
|
|
secretValuesPrinted: false
|
|
});
|
|
}
|
|
|
|
function finalizeReport(report) {
|
|
const openBlockers = report.blockers.filter((blocker) => blocker.status === "open");
|
|
report.conclusion = {
|
|
status: openBlockers.length === 0 ? "ready" : "blocked",
|
|
summary: openBlockers.length === 0
|
|
? conclusionReadySummary(report)
|
|
: "DEV runtime migration automation is blocked; see separated auth/schema/migration/readiness blockers.",
|
|
blockerCount: openBlockers.length
|
|
};
|
|
return report;
|
|
}
|
|
|
|
function conclusionReadySummary(report) {
|
|
if (report.mode === "apply") {
|
|
return "DEV runtime migration applied and durable runtime readiness was verified without printing secret values.";
|
|
}
|
|
if (report.mode === "dry-run" && report.actions.liveDbReadAttempted) {
|
|
return "DEV runtime migration read-only verification passed without DB writes or secret output.";
|
|
}
|
|
return "Runtime migration source contract is ready; no DB connection or live write was attempted.";
|
|
}
|
|
|
|
function blockerScope(blocker) {
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) return "runtime-migration-ssl";
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) return "runtime-migration-auth";
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) return "runtime-migration-schema";
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED) return "runtime-migration-ledger";
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING) return "runtime-migration-driver";
|
|
return "runtime-migration-readiness";
|
|
}
|
|
|
|
function classifyMigrationError(error) {
|
|
const errorCode = typeof error?.code === "string" ? error.code : "UNKNOWN";
|
|
if (errorCode === "HWLAB_PG_DRIVER_MISSING") {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING,
|
|
errorCode,
|
|
summary: "Postgres driver is unavailable for DEV runtime migration apply."
|
|
};
|
|
}
|
|
if (isMigrationSslError(error)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
|
errorCode,
|
|
summary: "DEV runtime migration reached Postgres, but SSL negotiation or SSL mode compatibility blocked apply."
|
|
};
|
|
}
|
|
if (["28P01", "28000", "42501"].includes(errorCode)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
|
errorCode,
|
|
summary: "DEV runtime migration reached Postgres but auth or authorization blocked apply."
|
|
};
|
|
}
|
|
if (["42P01", "42703", "3F000"].includes(errorCode)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
|
errorCode,
|
|
summary: "DEV runtime migration apply was blocked by schema/search_path state."
|
|
};
|
|
}
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
|
errorCode,
|
|
summary: "DEV runtime migration apply could not complete."
|
|
};
|
|
}
|
|
|
|
function isMigrationSslError(error) {
|
|
const errorCode = typeof error?.code === "string" ? error.code : "";
|
|
if (errorCode.startsWith("ERR_SSL") || errorCode.startsWith("ERR_TLS")) {
|
|
return true;
|
|
}
|
|
if ([
|
|
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
|
"SELF_SIGNED_CERT_IN_CHAIN",
|
|
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
|
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
|
"CERT_HAS_EXPIRED"
|
|
].includes(errorCode)) {
|
|
return true;
|
|
}
|
|
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
|
return [
|
|
"does not support ssl",
|
|
"ssl is not enabled",
|
|
"ssl negotiation",
|
|
"ssl off",
|
|
"ssl required",
|
|
"requires ssl",
|
|
"no ssl encryption",
|
|
"no encryption",
|
|
"hostssl",
|
|
"server requires ssl",
|
|
"before secure tls connection",
|
|
"tls handshake"
|
|
].some((pattern) => message.includes(pattern));
|
|
}
|
|
|
|
async function writeReport(report, reportPath, root) {
|
|
const absolute = ensureNotRepoReportsPath(root, reportPath, "--report");
|
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
await writeFile(absolute, `${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
|
|
function summarizeReport(report) {
|
|
return {
|
|
issue: report.issue,
|
|
mode: report.mode,
|
|
conclusion: report.conclusion,
|
|
migration: report.migration,
|
|
actions: report.actions,
|
|
gates: report.gates,
|
|
blockers: report.blockers,
|
|
safety: report.safety
|
|
};
|
|
}
|
|
|
|
function normalizeTimeoutMs(value) {
|
|
const parsed = Number.parseInt(value ?? "", 10);
|
|
return Number.isInteger(parsed) && parsed > 0 ? parsed : 1200;
|
|
}
|
|
|
|
function requireArgValue(value, flag) {
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
throw new Error(`${flag} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function sha256(value) {
|
|
return createHash("sha256").update(value).digest("hex");
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
"Usage: node scripts/dev-runtime-migration.mjs [--check|--dry-run|--apply]",
|
|
"",
|
|
"Default --check validates the source migration contract only and never connects to DB.",
|
|
"--dry-run is source-only unless --allow-live-db-read --confirm-dev is provided.",
|
|
"--apply requires --confirm-dev --confirmed-non-production and HWLAB_CLOUD_DB_URL.",
|
|
"Reports never print DB URLs, passwords, tokens, Secret values, or kubeconfig material."
|
|
].join("\n");
|
|
}
|
|
|
|
export function formatDevRuntimeMigrationFailure(error) {
|
|
const message = redactFailureText(error instanceof Error ? error.message : String(error));
|
|
return {
|
|
issue,
|
|
conclusion: {
|
|
status: "blocked",
|
|
summary: "DEV runtime migration command failed before producing a report."
|
|
},
|
|
error: message,
|
|
traceId: sha256(message).slice(0, 16),
|
|
safety: {
|
|
devOnly: true,
|
|
prodAllowed: false,
|
|
secretValuesPrinted: false
|
|
}
|
|
};
|
|
}
|
|
|
|
function redactFailureText(value) {
|
|
return String(value)
|
|
.replace(/postgres(?:ql)?:\/\/[^\s"'<>]+/giu, "[redacted-postgres-url]")
|
|
.replace(/(password\s*[=:]\s*)[^\s,;]+/giu, "$1[redacted]")
|
|
.replace(/(token\s*[=:]\s*)[^\s,;]+/giu, "$1[redacted]")
|
|
.replace(/(kubeconfig\s*[=:]\s*)[^\s,;]+/giu, "$1[redacted]");
|
|
}
|