fix: add DEV runtime migration automation
This commit is contained in:
@@ -0,0 +1,12 @@
|
||||
#!/usr/bin/env node
|
||||
import {
|
||||
formatDevRuntimeMigrationFailure,
|
||||
runDevRuntimeMigrationCli
|
||||
} from "./src/dev-runtime-migration.mjs";
|
||||
|
||||
try {
|
||||
process.exitCode = await runDevRuntimeMigrationCli(process.argv.slice(2));
|
||||
} catch (error) {
|
||||
process.stdout.write(`${JSON.stringify(formatDevRuntimeMigrationFailure(error), null, 2)}\n`);
|
||||
process.exitCode = 1;
|
||||
}
|
||||
@@ -0,0 +1,646 @@
|
||||
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
|
||||
} 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";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
||||
const migrationPath = "internal/db/migrations/0001_cloud_core_skeleton.sql";
|
||||
const defaultReportPath = "reports/dev-gate/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 === "--write-report") args.writeReport = 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");
|
||||
} else if (arg.startsWith("--report=")) {
|
||||
args.reportPath = requireArgValue(arg.slice("--report=".length), "--report");
|
||||
} 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
|
||||
});
|
||||
const runtime = await runtimeStore.readiness();
|
||||
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 pool = await createPgPool(env, options);
|
||||
client = pool;
|
||||
closeClient = async () => {
|
||||
await pool.end();
|
||||
};
|
||||
}
|
||||
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 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({
|
||||
connectionString: env.HWLAB_CLOUD_DB_URL,
|
||||
ssl: env.HWLAB_CLOUD_DB_SSL_MODE === "disable" ? false : { rejectUnauthorized: false },
|
||||
connectionTimeoutMillis: 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_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 (["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."
|
||||
};
|
||||
}
|
||||
|
||||
async function writeReport(report, reportPath, root) {
|
||||
const absolute = path.resolve(root, reportPath);
|
||||
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) {
|
||||
return {
|
||||
issue,
|
||||
conclusion: {
|
||||
status: "blocked",
|
||||
summary: "DEV runtime migration command failed before producing a report."
|
||||
},
|
||||
error: error instanceof Error ? error.message : String(error),
|
||||
safety: {
|
||||
devOnly: true,
|
||||
prodAllowed: false,
|
||||
secretValuesPrinted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,155 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
buildDevRuntimeMigrationReport,
|
||||
parseArgs
|
||||
} from "./dev-runtime-migration.mjs";
|
||||
import {
|
||||
CLOUD_CORE_MIGRATION_ID,
|
||||
CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION,
|
||||
CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS
|
||||
} from "../../internal/db/schema.mjs";
|
||||
|
||||
test("source check is non-secret and does not attempt live DB access", async () => {
|
||||
const report = await buildDevRuntimeMigrationReport(parseArgs(["--check"]), {
|
||||
env: {
|
||||
HWLAB_CLOUD_DB_URL: "postgresql://hwlab:secret-password@db.example.test:5432/hwlab",
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "require"
|
||||
},
|
||||
now: () => "2026-05-22T00:00:00.000Z"
|
||||
});
|
||||
|
||||
assert.equal(report.conclusion.status, "ready");
|
||||
assert.equal(report.actions.liveDbReadAttempted, false);
|
||||
assert.equal(report.actions.liveDbWriteAttempted, false);
|
||||
assert.equal(report.gates.auth.status, "not_checked");
|
||||
assert.equal(report.gates.schema.status, "ready");
|
||||
assert.equal(report.gates.migration.status, "ready");
|
||||
assert.equal(report.gates.readiness.status, "not_checked");
|
||||
assert.equal(report.safety.sourceStoresSecretValues, false);
|
||||
assert.equal(report.safety.printsSecretValues, false);
|
||||
assert.equal(JSON.stringify(report).includes("secret-password"), false);
|
||||
assert.equal(JSON.stringify(report).includes("db.example.test"), false);
|
||||
});
|
||||
|
||||
test("apply mode refuses missing DEV confirmation before touching query client", async () => {
|
||||
const queryClient = createFakeQueryClient({ migrationReady: false });
|
||||
const report = await buildDevRuntimeMigrationReport(parseArgs(["--apply"]), {
|
||||
env: {
|
||||
HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab",
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "require"
|
||||
},
|
||||
queryClient,
|
||||
now: () => "2026-05-22T00:00:00.000Z"
|
||||
});
|
||||
|
||||
assert.equal(report.conclusion.status, "blocked");
|
||||
assert.equal(report.safetyRefusal, true);
|
||||
assert.equal(report.actions.liveDbWriteAttempted, false);
|
||||
assert.equal(queryClient.calls.length, 0);
|
||||
});
|
||||
|
||||
test("live dry-run separates migration ledger blocker from auth and schema", async () => {
|
||||
const report = await buildDevRuntimeMigrationReport(
|
||||
parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]),
|
||||
{
|
||||
env: {
|
||||
HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab",
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "require"
|
||||
},
|
||||
queryClient: createFakeQueryClient({ migrationReady: false }),
|
||||
now: () => "2026-05-22T00:00:00.000Z"
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(report.conclusion.status, "blocked");
|
||||
assert.equal(report.actions.liveDbReadAttempted, true);
|
||||
assert.equal(report.actions.liveDbWriteAttempted, false);
|
||||
assert.equal(report.gates.auth.status, "ready");
|
||||
assert.equal(report.gates.schema.status, "ready");
|
||||
assert.equal(report.gates.migration.status, "blocked");
|
||||
assert.equal(report.gates.readiness.status, "not_checked");
|
||||
assert.equal(report.blockers[0].scope, "runtime-migration-ledger");
|
||||
assert.equal(report.runtime.migration.missing, true);
|
||||
});
|
||||
|
||||
test("apply mode records ledger and verifies durable runtime readiness", async () => {
|
||||
const queryClient = createFakeQueryClient({ migrationReady: false });
|
||||
const report = await buildDevRuntimeMigrationReport(
|
||||
parseArgs(["--apply", "--confirm-dev", "--confirmed-non-production"]),
|
||||
{
|
||||
env: {
|
||||
HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab",
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "require"
|
||||
},
|
||||
queryClient,
|
||||
now: () => "2026-05-22T00:00:00.000Z"
|
||||
}
|
||||
);
|
||||
|
||||
assert.equal(report.conclusion.status, "ready");
|
||||
assert.equal(report.actions.liveDbWriteAttempted, true);
|
||||
assert.equal(report.actions.migrationApplied, true);
|
||||
assert.equal(report.actions.readinessVerified, true);
|
||||
assert.equal(report.gates.auth.status, "ready");
|
||||
assert.equal(report.gates.schema.status, "ready");
|
||||
assert.equal(report.gates.migration.status, "ready");
|
||||
assert.equal(report.gates.readiness.status, "ready");
|
||||
assert.equal(report.runtime.migration.appliedMigrationId, CLOUD_CORE_MIGRATION_ID);
|
||||
assert.equal(report.runtime.migration.appliedSchemaVersion, CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION);
|
||||
assert.ok(queryClient.calls.some((call) => call.sql === "BEGIN"));
|
||||
assert.ok(queryClient.calls.some((call) => call.sql.includes("INSERT INTO hwlab_schema_migrations")));
|
||||
assert.ok(queryClient.calls.some((call) => call.sql === "COMMIT"));
|
||||
assert.equal(JSON.stringify(report).includes("redacted@example.invalid"), false);
|
||||
});
|
||||
|
||||
function createFakeQueryClient({ migrationReady }) {
|
||||
const state = {
|
||||
migrationReady,
|
||||
calls: []
|
||||
};
|
||||
return {
|
||||
get calls() {
|
||||
return state.calls;
|
||||
},
|
||||
async query(sql, params = []) {
|
||||
state.calls.push({ sql, params });
|
||||
if (sql === "BEGIN" || sql === "COMMIT" || sql === "ROLLBACK") {
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes("INSERT INTO hwlab_schema_migrations")) {
|
||||
state.migrationReady = true;
|
||||
return { rows: [] };
|
||||
}
|
||||
if (sql.includes("information_schema.columns")) {
|
||||
return { rows: schemaRows() };
|
||||
}
|
||||
if (sql.startsWith("SELECT id, schema_version FROM hwlab_schema_migrations")) {
|
||||
return {
|
||||
rows: state.migrationReady
|
||||
? [
|
||||
{
|
||||
id: CLOUD_CORE_MIGRATION_ID,
|
||||
schema_version: CLOUD_RUNTIME_DURABLE_ADAPTER_SCHEMA_VERSION
|
||||
}
|
||||
]
|
||||
: []
|
||||
};
|
||||
}
|
||||
if (sql.startsWith("SELECT COUNT(*)::int AS count FROM ")) {
|
||||
return { rows: [{ count: 0 }] };
|
||||
}
|
||||
throw new Error(`unexpected query: ${sql}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function schemaRows() {
|
||||
return Object.entries(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS).flatMap(([table, columns]) =>
|
||||
columns.map((column) => ({
|
||||
table_name: table,
|
||||
column_name: column
|
||||
}))
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user