From 63d8f7b5a63e966d858b946ccdde52c26d0a5e6d Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Fri, 22 May 2026 23:57:09 +0000 Subject: [PATCH 1/2] fix: add DEV runtime migration automation --- docs/dev-deploy-apply.md | 39 ++ docs/dev-gate-preflight.md | 7 + docs/reference/dev-runtime-boundary.md | 44 ++ package.json | 5 +- scripts/dev-runtime-migration.mjs | 12 + scripts/src/dev-runtime-migration.mjs | 646 +++++++++++++++++++++ scripts/src/dev-runtime-migration.test.mjs | 155 +++++ 7 files changed, 906 insertions(+), 2 deletions(-) create mode 100644 scripts/dev-runtime-migration.mjs create mode 100644 scripts/src/dev-runtime-migration.mjs create mode 100644 scripts/src/dev-runtime-migration.test.mjs diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index b6e2dd11..b9836c8b 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -31,7 +31,10 @@ Run a preflight that is expected to report blockers on the current skeleton: ```sh node --check scripts/dev-deploy-apply.mjs node --check scripts/src/dev-deploy-apply.mjs +node --check scripts/dev-runtime-migration.mjs +node --check scripts/src/dev-runtime-migration.mjs node scripts/deploy-desired-state-plan.mjs --check +node scripts/dev-runtime-migration.mjs --check node scripts/dev-deploy-apply.mjs --dry-run --expect-blocked --write-report node scripts/validate-dev-gate-report.mjs ``` @@ -53,6 +56,35 @@ The script refuses PROD flags, secret-read flags, heavyweight e2e flags, and force-push flags. It also refuses to apply if the DEV artifact catalog still contains skeleton-only unpublished images. +Runtime DB migration is a separate DEV-only apply boundary. The source-only +check is safe for normal PR validation: + +```sh +node scripts/dev-runtime-migration.mjs --check +node scripts/dev-runtime-migration.mjs --dry-run --write-report +``` + +Read-only live DB verification requires explicit DEV confirmation and an +already injected `HWLAB_CLOUD_DB_URL`; the report redacts the value and keeps +auth, schema, migration ledger, and readiness blockers separate: + +```sh +node scripts/dev-runtime-migration.mjs --dry-run --allow-live-db-read --confirm-dev --write-report +``` + +A real migration write is not part of default deploy validation. It requires a +separately authorized DEV operation: + +```sh +node scripts/dev-runtime-migration.mjs --apply --confirm-dev --confirmed-non-production --write-report +``` + +That command may only apply +`internal/db/migrations/0001_cloud_core_skeleton.sql` to the DEV Postgres target +provided by `HWLAB_CLOUD_DB_URL`. It must not read Kubernetes Secret resources, +print the DB URL/password/token, mutate PROD, restart services, or claim +M3/M4/M5 acceptance. + `deploy-desired-state-plan.mjs` is not an apply preflight substitute. It is a read-only source planner and does not prove registry image existence, DEV cluster pullability, DEV apply, service restart, or M3 DEV-LIVE evidence. @@ -234,6 +266,13 @@ not a readiness gate or TCP probe target, until this repo owns its Service/Endpoint rollout contract. The local health gate reports DB runtime config without printing the connection string. +Durable runtime migration now has a repo-owned path: +`scripts/dev-runtime-migration.mjs --check` proves the source migration and +ledger contract without DB access; `--dry-run --allow-live-db-read +--confirm-dev` verifies live auth/schema/migration/readiness without writes; +`--apply --confirm-dev --confirmed-non-production` is the only DEV DB write +mode and remains outside default PR validation. + No PROD resource, secret value, UniDesk runtime substitute, browser e2e, heavyweight e2e, or force push is part of this flow. diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index 17b5301b..00b2b671 100644 --- a/docs/dev-gate-preflight.md +++ b/docs/dev-gate-preflight.md @@ -18,8 +18,11 @@ node --check scripts/src/dev-gate-preflight.mjs node --check scripts/src/registry-capabilities.mjs node --check scripts/deploy-desired-state-plan.mjs node --check scripts/src/deploy-desired-state-plan.mjs +node --check scripts/dev-runtime-migration.mjs +node --check scripts/src/dev-runtime-migration.mjs node --check scripts/refresh-artifact-catalog.mjs node scripts/deploy-desired-state-plan.mjs --check +node scripts/dev-runtime-migration.mjs --check node scripts/dev-gate-preflight.mjs ``` @@ -71,6 +74,10 @@ The preflight checks: parsed from that Secret URL. `cloud-api-db` is an optional desired alias only until this repo owns Service plus Endpoint or EndpointSlice manifests and rollout/apply contract. +- `scripts/dev-runtime-migration.mjs --check` validates the runtime migration + source and ledger contract without live DB access. Live read verification and + migration apply are separate DEV-only modes requiring explicit flags; default + preflight does not read DB secrets or write the DB. - The local cloud-api health payload reports DB env presence/missing status with redacted values only. This is a readiness gate, not live DB evidence. - `deploy/frp` and `deploy/master-edge` describe the D601-to-master DEV route diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index b3651ea5..9817bf26 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -177,6 +177,50 @@ non-durable until the schema, migration ledger, and read readiness gates are proven through the configured adapter. Documenting or testing this contract must not mutate runtime state or PROD. +## Runtime Migration Automation + +The repo-owned runtime migration entrypoint is: + +```sh +node scripts/dev-runtime-migration.mjs --check +node scripts/dev-runtime-migration.mjs --dry-run --write-report +``` + +Those default paths are source-only. They validate +`internal/db/migrations/0001_cloud_core_skeleton.sql`, the required +`hwlab_schema_migrations` ledger row, and the durable runtime table/column +contract without connecting to Postgres, reading Kubernetes Secrets, or writing +DEV/PROD state. + +An authorized operator may run a read-only live verification only after +injecting the DEV DB URL through the existing Secret/env path: + +```sh +node scripts/dev-runtime-migration.mjs --dry-run --allow-live-db-read --confirm-dev --write-report +``` + +A real DEV migration apply is intentionally separate and requires all explicit +write flags: + +```sh +node scripts/dev-runtime-migration.mjs --apply --confirm-dev --confirmed-non-production --write-report +``` + +The apply command is scoped to DEV Postgres schema objects declared by +`internal/db/migrations/0001_cloud_core_skeleton.sql` and the +`hwlab_schema_migrations` ledger row. It must not be run for PROD, must not +read Kubernetes Secret resources, must not print `HWLAB_CLOUD_DB_URL` or any +password/token value, and must not be described as M3/M4/M5 acceptance. + +Runtime migration reports separate the blockers as: + +| Gate | Meaning | +| --- | --- | +| Auth | DB driver/auth/authorization can run the readiness query. | +| Schema | Required durable runtime tables and columns are present. | +| Migration | The required source migration is recorded in the ledger. | +| Readiness | Durable runtime read readiness passed after schema and ledger checks. | + ## Runtime Substitution Ban UniDesk services, provider-gateway, backend-core, microservice proxies, local diff --git a/package.json b/package.json index e1f3aa4e..c91d9387 100644 --- a/package.json +++ b/package.json @@ -4,8 +4,8 @@ "private": true, "type": "module", "scripts": { - "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "validate": "node scripts/validate-contract.mjs && node scripts/deploy-contract-plan.mjs --check && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/agent/runtime.mjs && node --check internal/audit/index.mjs && node --check internal/db/runtime-store.mjs && node --check internal/db/runtime-store.test.mjs && node --check internal/mvp-gate/summary.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/code-agent-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/code-agent-chat.mjs && node --check internal/cloud/server.mjs && node --check internal/sim/model.mjs && node --check internal/sim/http.mjs && node --check internal/sim/l2-runtime.mjs && node --check internal/patchpanel/model.mjs && node --check internal/patchpanel/runtime.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check cmd/hwlab-agent-mgr/main.mjs && node --check cmd/hwlab-agent-worker/main.mjs && node --check cmd/hwlab-box-simu/main.mjs && node --check cmd/hwlab-gateway-simu/main.mjs && node --check scripts/cloud-api-runtime-smoke.mjs && node --check scripts/code-agent-chat-smoke.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/deploy-desired-state-plan.mjs && node --check scripts/src/deploy-desired-state-plan.mjs && node --check scripts/report-lifecycle.mjs && node --check scripts/report-lifecycle.test.mjs && node --check scripts/validate-dev-gate-report.mjs && node --check scripts/dev-m3-hardware-loop-smoke.test.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/validate-artifact-catalog.mjs && node --check scripts/refresh-artifact-catalog.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/src/dev-artifact-services.mjs && node --check scripts/src/registry-capabilities.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/src/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node --check scripts/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.mjs && node --check scripts/src/dev-runtime-migration.test.mjs && node --check scripts/l2-runtime-contract-smoke.mjs && node --check scripts/patch-panel-runtime-smoke.mjs && node --check scripts/export-web-gate-summary.mjs && node --check scripts/l6-cli-web-smoke.mjs && node --check tools/hwlab-cli/bin/hwlab-cli.mjs && node --check tools/hwlab-cli/lib/cli.mjs && node --check web/hwlab-cloud-web/app.mjs && node --check web/hwlab-cloud-web/gate-summary.mjs && node --check web/hwlab-cloud-web/scripts/check.mjs && node --check web/hwlab-cloud-web/scripts/build.mjs && node --check web/hwlab-cloud-web/scripts/dist-contract.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-gate-report.mjs && node scripts/report-lifecycle.test.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/validate-artifact-catalog.mjs && node scripts/deploy-desired-state-plan.mjs --check && node scripts/dev-runtime-migration.mjs --check && node scripts/dev-evidence-blocker-aggregator.mjs --check && node scripts/l2-runtime-contract-smoke.mjs && node scripts/l6-cli-web-smoke.mjs && node web/hwlab-cloud-web/scripts/check.mjs && node scripts/code-agent-chat-smoke.mjs && node --test scripts/dev-m3-hardware-loop-smoke.test.mjs scripts/deploy-desired-state-plan.test.mjs scripts/src/dev-deploy-apply.test.mjs scripts/src/dev-runtime-migration.test.mjs internal/agent/index.test.mjs internal/audit/index.test.mjs internal/db/schema.test.mjs internal/db/runtime-store.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs internal/patchpanel/model.test.mjs internal/patchpanel/runtime.test.mjs && node scripts/cloud-api-runtime-smoke.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "cloud-api:smoke": "node scripts/cloud-api-runtime-smoke.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", @@ -29,6 +29,7 @@ "artifact-catalog:refresh-blocked": "node scripts/refresh-artifact-catalog.mjs --target-ref origin/main --blocked", "dev-artifact:preflight": "node scripts/dev-artifact-publish.mjs --preflight", "dev-artifact:publish": "node scripts/dev-artifact-publish.mjs --publish", + "dev-runtime:migration": "node scripts/dev-runtime-migration.mjs", "docs:validate:m3-rollout": "node scripts/validate-m3-rollout-runbook.mjs", "d601:k3s:readonly": "node scripts/d601-k3s-readonly-observability.mjs", "runner:issue-visibility:preflight": "node scripts/runner-issue-visibility-preflight.mjs", diff --git a/scripts/dev-runtime-migration.mjs b/scripts/dev-runtime-migration.mjs new file mode 100644 index 00000000..aa48edb8 --- /dev/null +++ b/scripts/dev-runtime-migration.mjs @@ -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; +} diff --git a/scripts/src/dev-runtime-migration.mjs b/scripts/src/dev-runtime-migration.mjs new file mode 100644 index 00000000..b770500a --- /dev/null +++ b/scripts/src/dev-runtime-migration.mjs @@ -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 + } + }; +} diff --git a/scripts/src/dev-runtime-migration.test.mjs b/scripts/src/dev-runtime-migration.test.mjs new file mode 100644 index 00000000..cf2f3575 --- /dev/null +++ b/scripts/src/dev-runtime-migration.test.mjs @@ -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 + })) + ); +} From 852ec3dbc70341a39f7c761843a529c250bc99c0 Mon Sep 17 00:00:00 2001 From: Code Queue Review Date: Sat, 23 May 2026 00:14:13 +0000 Subject: [PATCH 2/2] fix: harden DEV runtime migration transactions --- scripts/src/dev-runtime-migration.mjs | 38 ++++++++-- scripts/src/dev-runtime-migration.test.mjs | 83 ++++++++++++++++++++++ 2 files changed, 115 insertions(+), 6 deletions(-) diff --git a/scripts/src/dev-runtime-migration.mjs b/scripts/src/dev-runtime-migration.mjs index b770500a..7696da44 100644 --- a/scripts/src/dev-runtime-migration.mjs +++ b/scripts/src/dev-runtime-migration.mjs @@ -250,7 +250,14 @@ async function verifyRuntimeReadiness(report, env, options) { queryClient: options.queryClient, pgModuleLoader: options.pgModuleLoader }); - const runtime = await runtimeStore.readiness(); + 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); @@ -277,11 +284,9 @@ async function applyMigration(report, sql, env, options) { if (options.queryClient) { client = options.queryClient; } else { - const pool = await createPgPool(env, options); - client = pool; - closeClient = async () => { - await pool.end(); - }; + const migrationClient = await createMigrationClient(env, options); + client = migrationClient.client; + closeClient = migrationClient.close; } await client.query("BEGIN"); await client.query(sql); @@ -300,6 +305,27 @@ async function applyMigration(report, sql, env, options) { } } +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 { diff --git a/scripts/src/dev-runtime-migration.test.mjs b/scripts/src/dev-runtime-migration.test.mjs index cf2f3575..f4a006da 100644 --- a/scripts/src/dev-runtime-migration.test.mjs +++ b/scripts/src/dev-runtime-migration.test.mjs @@ -104,6 +104,55 @@ test("apply mode records ledger and verifies durable runtime readiness", async ( assert.equal(JSON.stringify(report).includes("redacted@example.invalid"), false); }); +test("live dry-run closes pg pool after readiness verification", async () => { + const backingClient = createFakeQueryClient({ migrationReady: true }); + const pools = []; + 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" + }, + pgModuleLoader: async () => createFakePgModule({ backingClient, pools }), + now: () => "2026-05-22T00:00:00.000Z" + } + ); + + assert.equal(report.conclusion.status, "ready"); + assert.equal(pools.length, 1); + assert.ok(pools[0].calls.some((call) => call.target === "pool" && call.sql.includes("information_schema.columns"))); + assert.ok(pools[0].calls.some((call) => call.type === "end")); +}); + +test("apply mode uses a dedicated pg client transaction", async () => { + const backingClient = createFakeQueryClient({ migrationReady: false }); + const pools = []; + 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" + }, + pgModuleLoader: async () => createFakePgModule({ backingClient, pools }), + now: () => "2026-05-22T00:00:00.000Z" + } + ); + + assert.equal(report.conclusion.status, "ready"); + assert.equal(pools.length, 2); + assert.ok(pools[0].calls.some((call) => call.type === "connect")); + assert.ok(pools[0].calls.some((call) => call.target === "client" && call.sql === "BEGIN")); + assert.ok(pools[0].calls.some((call) => call.target === "client" && call.sql.includes("INSERT INTO hwlab_schema_migrations"))); + assert.ok(pools[0].calls.some((call) => call.target === "client" && call.sql === "COMMIT")); + assert.equal(pools[0].calls.some((call) => call.target === "pool"), false); + assert.ok(pools[0].calls.some((call) => call.type === "release")); + assert.ok(pools[0].calls.some((call) => call.type === "end")); + assert.ok(pools[1].calls.some((call) => call.target === "pool" && call.sql.includes("information_schema.columns"))); + assert.ok(pools[1].calls.some((call) => call.type === "end")); +}); + function createFakeQueryClient({ migrationReady }) { const state = { migrationReady, @@ -145,6 +194,40 @@ function createFakeQueryClient({ migrationReady }) { }; } +function createFakePgModule({ backingClient, pools }) { + return { + Pool: class FakePool { + constructor(config) { + this.config = config; + this.calls = []; + pools.push(this); + } + + async connect() { + this.calls.push({ type: "connect" }); + return { + query: async (sql, params = []) => { + this.calls.push({ target: "client", sql, params }); + return backingClient.query(sql, params); + }, + release: () => { + this.calls.push({ type: "release" }); + } + }; + } + + async query(sql, params = []) { + this.calls.push({ target: "pool", sql, params }); + return backingClient.query(sql, params); + } + + async end() { + this.calls.push({ type: "end" }); + } + } + }; +} + function schemaRows() { return Object.entries(CLOUD_RUNTIME_DURABLE_TABLE_COLUMNS).flatMap(([table, columns]) => columns.map((column) => ({