fix: add DEV runtime migration automation

This commit is contained in:
Code Queue Review
2026-05-22 23:57:09 +00:00
parent 7de6edd2c4
commit 63d8f7b5a6
7 changed files with 906 additions and 2 deletions
+39
View File
@@ -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.
+7
View File
@@ -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
+44
View File
@@ -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
+3 -2
View File
@@ -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",
+12
View File
@@ -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;
}
+646
View File
@@ -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
}
};
}
+155
View File
@@ -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
}))
);
}