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 } 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 }); 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({ 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_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", "server requires ssl", "before secure tls connection", "tls handshake" ].some((pattern) => message.includes(pattern)); } 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 } }; }