931 lines
32 KiB
JavaScript
931 lines
32 KiB
JavaScript
import { createHash } from "node:crypto";
|
|
import { mkdir, writeFile } from "node:fs/promises";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
|
|
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.ts";
|
|
import {
|
|
PostgresCloudRuntimeStore,
|
|
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING,
|
|
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
|
buildPostgresPoolConfig
|
|
} from "../../internal/db/runtime-store.ts";
|
|
import { ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
|
import { ensureNotRepoReportsPath, tempReportPath } from "./report-paths.mjs";
|
|
|
|
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../..");
|
|
const defaultReportPath = tempReportPath("dev-runtime-provisioning-report.json");
|
|
const issue = "pikasTech/HWLAB#311";
|
|
|
|
export const DEV_DB_ADMIN_SECRET_REF = Object.freeze({
|
|
env: "HWLAB_CLOUD_DB_ADMIN_URL",
|
|
secretName: "hwlab-cloud-api-dev-db-admin",
|
|
secretKey: "admin-url"
|
|
});
|
|
|
|
const appSecretRef = DEV_DB_ENV_CONTRACT.secretRefs[0];
|
|
const systemDatabases = new Set(["postgres", "template0", "template1"]);
|
|
|
|
export async function runDevRuntimeProvisioningCli(argv = process.argv.slice(2), options = {}) {
|
|
const args = parseArgs(argv);
|
|
if (args.help) {
|
|
process.stdout.write(`${usage()}\n`);
|
|
return 0;
|
|
}
|
|
|
|
const report = await buildDevRuntimeProvisioningReport(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 buildDevRuntimeProvisioningReport(args = {}, options = {}) {
|
|
const parsed = normalizeArgs(args);
|
|
const env = options.env ?? process.env;
|
|
const now = options.now ?? (() => new Date().toISOString());
|
|
const target = parseProvisioningTarget(env.HWLAB_CLOUD_DB_URL);
|
|
const admin = summarizeAdminEnv(env);
|
|
const report = {
|
|
issue,
|
|
mode: parsed.mode,
|
|
generatedAt: now(),
|
|
target: {
|
|
environment: ENVIRONMENT_DEV,
|
|
namespace: DEV_DB_ENV_CONTRACT.dns.namespace,
|
|
prodAllowed: false,
|
|
dbUrlEnv: appSecretRef.env,
|
|
dbUrlSecretRef: appSecretRef,
|
|
adminUrlEnv: DEV_DB_ADMIN_SECRET_REF.env,
|
|
adminUrlSecretRef: DEV_DB_ADMIN_SECRET_REF,
|
|
dbEndpointAuthority: DEV_DB_ENV_CONTRACT.endpointAuthority.source
|
|
},
|
|
db: summarizeDbEnv(env, target, admin),
|
|
applyBoundary: buildApplyBoundary(parsed),
|
|
safety: buildSafety(parsed),
|
|
actions: {
|
|
liveDbReadAttempted: false,
|
|
liveDbWriteAttempted: false,
|
|
adminInspectionAttempted: false,
|
|
adminWriteAttempted: false,
|
|
appRuntimeReadinessAttempted: false,
|
|
roleCreated: false,
|
|
roleExisted: false,
|
|
rolePasswordSynchronized: false,
|
|
databaseCreated: false,
|
|
databaseExisted: false,
|
|
databaseConnectGranted: false,
|
|
schemaPrivilegesGranted: false,
|
|
readinessVerified: false
|
|
},
|
|
provisioning: {
|
|
targetRole: emptyObjectState(target.roleNamePresent),
|
|
targetDatabase: emptyObjectState(target.databaseNamePresent),
|
|
privileges: {
|
|
checked: false,
|
|
ready: false,
|
|
databaseConnectGranted: false,
|
|
schemaPrivilegesGranted: false
|
|
},
|
|
ready: false
|
|
},
|
|
runtime: null,
|
|
gates: sourceOnlyGates(target),
|
|
downstreamRuntimeGates: null,
|
|
blockers: [],
|
|
safetyRefusal: false
|
|
};
|
|
|
|
validateSourceContract(report, target, parsed);
|
|
|
|
if (parsed.mode === "check" || (parsed.mode === "dry-run" && !parsed.allowLiveDbRead)) {
|
|
return finalizeReport(report);
|
|
}
|
|
|
|
if (parsed.mode === "dry-run") {
|
|
if (!parsed.confirmDev) {
|
|
addSafetyRefusal(report, "DEV DB provisioning live dry-run requires --confirm-dev.");
|
|
return finalizeReport(report);
|
|
}
|
|
if (!target.ok) {
|
|
return finalizeReport(report);
|
|
}
|
|
await verifyRuntimeReadiness(report, env, options);
|
|
if (admin.present || options.adminClient) {
|
|
await inspectAdminProvisioning(report, env, target, options);
|
|
} else {
|
|
inferProvisioningFromRuntime(report);
|
|
if (!report.provisioning.ready) {
|
|
addBlocker(report, "runtime_blocker", "runtime-provisioning-admin", "Role/database inspection requires the DEV admin SecretRef env when runtime readiness is not already green.", {
|
|
adminUrlEnv: DEV_DB_ADMIN_SECRET_REF.env,
|
|
adminUrlSecretRef: DEV_DB_ADMIN_SECRET_REF,
|
|
valueRedacted: true,
|
|
secretValuesPrinted: false
|
|
});
|
|
}
|
|
}
|
|
addRuntimeProvisioningBlocker(report);
|
|
return finalizeReport(report);
|
|
}
|
|
|
|
if (parsed.mode === "apply") {
|
|
if (!parsed.confirmDev || !parsed.confirmedNonProduction) {
|
|
addSafetyRefusal(report, "DEV DB provisioning apply requires --confirm-dev and --confirmed-non-production.");
|
|
return finalizeReport(report);
|
|
}
|
|
if (!target.ok) {
|
|
return finalizeReport(report);
|
|
}
|
|
if (!admin.present && !options.adminClient) {
|
|
addBlocker(report, "runtime_blocker", "runtime-provisioning-admin", "DEV DB provisioning apply requires the admin DB URL from its SecretRef env.", {
|
|
adminUrlEnv: DEV_DB_ADMIN_SECRET_REF.env,
|
|
adminUrlSecretRef: DEV_DB_ADMIN_SECRET_REF,
|
|
valueRedacted: true,
|
|
secretValuesPrinted: false
|
|
});
|
|
return finalizeReport(report);
|
|
}
|
|
await applyProvisioning(report, env, target, options);
|
|
if (report.actions.adminWriteAttempted && report.blockers.length === 0) {
|
|
await verifyRuntimeReadiness(report, env, options);
|
|
inferProvisioningFromRuntime(report);
|
|
addRuntimeProvisioningBlocker(report);
|
|
}
|
|
return finalizeReport(report);
|
|
}
|
|
|
|
addSafetyRefusal(report, `Unsupported DEV DB provisioning mode: ${parsed.mode}`);
|
|
return finalizeReport(report);
|
|
}
|
|
|
|
export function parseArgs(argv = []) {
|
|
const args = {
|
|
mode: "check",
|
|
allowLiveDbRead: false,
|
|
confirmDev: false,
|
|
confirmedNonProduction: false,
|
|
writeReport: false,
|
|
reportPath: defaultReportPath,
|
|
pretty: false,
|
|
failOnBlocked: false,
|
|
help: false
|
|
};
|
|
|
|
for (let index = 0; index < argv.length; index += 1) {
|
|
const arg = argv[index];
|
|
if (arg === "--check") args.mode = "check";
|
|
else if (arg === "--dry-run") args.mode = "dry-run";
|
|
else if (arg === "--apply") args.mode = "apply";
|
|
else if (arg === "--allow-live-db-read") args.allowLiveDbRead = true;
|
|
else if (arg === "--confirm-dev") args.confirmDev = true;
|
|
else if (arg === "--confirmed-non-production") args.confirmedNonProduction = true;
|
|
else if (arg === "--pretty") args.pretty = true;
|
|
else if (arg === "--fail-on-blocked") args.failOnBlocked = true;
|
|
else if (arg === "--help" || arg === "-h") args.help = true;
|
|
else if (arg === "--report") {
|
|
index += 1;
|
|
args.reportPath = requireArgValue(argv[index], "--report");
|
|
args.writeReport = true;
|
|
} else if (arg.startsWith("--report=")) {
|
|
args.reportPath = requireArgValue(arg.slice("--report=".length), "--report");
|
|
args.writeReport = true;
|
|
} else {
|
|
throw new Error(`unknown argument: ${arg}`);
|
|
}
|
|
}
|
|
|
|
return args;
|
|
}
|
|
|
|
export function parseProvisioningTarget(rawUrl) {
|
|
const present = typeof rawUrl === "string" && rawUrl.trim().length > 0;
|
|
if (!present) {
|
|
return {
|
|
ok: false,
|
|
present: false,
|
|
parseStatus: "missing",
|
|
errorCode: "TARGET_DB_URL_MISSING",
|
|
roleNamePresent: false,
|
|
databaseNamePresent: false,
|
|
passwordPresent: false
|
|
};
|
|
}
|
|
|
|
let url;
|
|
try {
|
|
url = new URL(rawUrl);
|
|
} catch {
|
|
return {
|
|
ok: false,
|
|
present: true,
|
|
parseStatus: "invalid_url",
|
|
errorCode: "TARGET_DB_URL_INVALID",
|
|
roleNamePresent: false,
|
|
databaseNamePresent: false,
|
|
passwordPresent: false
|
|
};
|
|
}
|
|
|
|
const protocolReady = ["postgres:", "postgresql:"].includes(url.protocol);
|
|
const roleName = decodeURIComponent(url.username || "");
|
|
const password = decodeURIComponent(url.password || "");
|
|
const databaseName = decodeURIComponent(url.pathname.replace(/^\/+/u, ""));
|
|
const databaseAllowed = Boolean(databaseName) && !systemDatabases.has(databaseName.toLowerCase());
|
|
const ok = protocolReady && Boolean(roleName) && Boolean(password) && databaseAllowed;
|
|
return {
|
|
ok,
|
|
present: true,
|
|
parseStatus: ok ? "ready" : "blocked",
|
|
errorCode: ok ? null : targetParseErrorCode({ protocolReady, roleName, password, databaseName, databaseAllowed }),
|
|
roleNamePresent: Boolean(roleName),
|
|
databaseNamePresent: Boolean(databaseName),
|
|
databaseAllowed,
|
|
passwordPresent: Boolean(password),
|
|
endpointClass: DEV_DB_ENV_CONTRACT.endpointAuthority.source,
|
|
roleName,
|
|
databaseName,
|
|
password
|
|
};
|
|
}
|
|
|
|
function normalizeArgs(args) {
|
|
return {
|
|
...parseArgs([]),
|
|
...args
|
|
};
|
|
}
|
|
|
|
function targetParseErrorCode({ protocolReady, roleName, password, databaseName, databaseAllowed }) {
|
|
if (!protocolReady) return "TARGET_DB_URL_PROTOCOL_BLOCKED";
|
|
if (!roleName) return "TARGET_DB_URL_ROLE_MISSING";
|
|
if (!password) return "TARGET_DB_URL_PASSWORD_MISSING";
|
|
if (!databaseName) return "TARGET_DB_URL_DATABASE_MISSING";
|
|
if (!databaseAllowed) return "TARGET_DB_URL_DATABASE_FORBIDDEN";
|
|
return "TARGET_DB_URL_BLOCKED";
|
|
}
|
|
|
|
function summarizeAdminEnv(env) {
|
|
return {
|
|
present: typeof env?.[DEV_DB_ADMIN_SECRET_REF.env] === "string" && env[DEV_DB_ADMIN_SECRET_REF.env].trim().length > 0,
|
|
env: DEV_DB_ADMIN_SECRET_REF.env,
|
|
secretRef: DEV_DB_ADMIN_SECRET_REF,
|
|
valueRedacted: true
|
|
};
|
|
}
|
|
|
|
function summarizeDbEnv(env, target, admin) {
|
|
return {
|
|
targetUrlPresent: target.present,
|
|
targetUrlValueRedacted: true,
|
|
targetEndpointRedacted: true,
|
|
targetEndpointClass: target.endpointClass ?? DEV_DB_ENV_CONTRACT.endpointAuthority.source,
|
|
targetParseStatus: target.parseStatus,
|
|
targetErrorCode: target.errorCode,
|
|
targetRoleNamePresent: target.roleNamePresent,
|
|
targetDatabaseNamePresent: target.databaseNamePresent,
|
|
targetDatabaseAllowed: target.databaseAllowed === true,
|
|
targetPasswordPresent: target.passwordPresent,
|
|
sslMode: env?.HWLAB_CLOUD_DB_SSL_MODE || DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE,
|
|
sslModeSecret: false,
|
|
adminUrlPresent: admin.present,
|
|
adminUrlValueRedacted: true,
|
|
adminEndpointRedacted: true,
|
|
dbUrlSecretRef: appSecretRef,
|
|
adminUrlSecretRef: DEV_DB_ADMIN_SECRET_REF,
|
|
endpointAuthority: DEV_DB_ENV_CONTRACT.endpointAuthority.source
|
|
};
|
|
}
|
|
|
|
function validateSourceContract(report, target, args) {
|
|
if (target.ok) return;
|
|
if (args.mode === "check" && target.present === false) return;
|
|
addBlocker(report, "runtime_blocker", "runtime-provisioning-target", "DEV DB provisioning requires a valid target DB URL from the cloud-api SecretRef env.", {
|
|
env: appSecretRef.env,
|
|
secretRef: appSecretRef,
|
|
errorCode: target.errorCode,
|
|
valueRedacted: true,
|
|
endpointRedacted: true,
|
|
secretValuesPrinted: false
|
|
});
|
|
}
|
|
|
|
async function verifyRuntimeReadiness(report, env, options) {
|
|
report.actions.liveDbReadAttempted = true;
|
|
report.actions.appRuntimeReadinessAttempted = 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.downstreamRuntimeGates = report.runtime.gates;
|
|
report.gates = {
|
|
...report.gates,
|
|
ssl: normalizeGate(runtime.gates?.ssl),
|
|
auth: normalizeGate(runtime.gates?.auth),
|
|
schema: normalizeGate(runtime.gates?.schema),
|
|
migration: normalizeGate(runtime.gates?.migration),
|
|
durability: normalizeGate(runtime.gates?.durability)
|
|
};
|
|
}
|
|
|
|
async function inspectAdminProvisioning(report, env, target, options) {
|
|
report.actions.liveDbReadAttempted = true;
|
|
report.actions.adminInspectionAttempted = true;
|
|
const { client, close } = await openAdminClient(env, options);
|
|
try {
|
|
const [role, database] = await Promise.all([
|
|
objectExists(client, "SELECT 1 AS present FROM pg_catalog.pg_roles WHERE rolname = $1 LIMIT 1", [target.roleName]),
|
|
objectExists(client, "SELECT 1 AS present FROM pg_catalog.pg_database WHERE datname = $1 LIMIT 1", [target.databaseName])
|
|
]);
|
|
report.provisioning.targetRole = {
|
|
checked: true,
|
|
exists: role,
|
|
created: false,
|
|
ready: role,
|
|
targetNamePresent: target.roleNamePresent
|
|
};
|
|
report.provisioning.targetDatabase = {
|
|
checked: true,
|
|
exists: database,
|
|
created: false,
|
|
ready: database,
|
|
targetNamePresent: target.databaseNamePresent
|
|
};
|
|
report.actions.roleExisted = role;
|
|
report.actions.databaseExisted = database;
|
|
report.provisioning.ready = role && database && runtimeAuthSatisfied(report.runtime);
|
|
if (!role) addMissingObjectBlocker(report, "role");
|
|
if (!database) addMissingObjectBlocker(report, "database");
|
|
} catch (error) {
|
|
addClassifiedAdminBlocker(report, error, "DEV DB provisioning inspection could not verify the target role/database.");
|
|
} finally {
|
|
await close();
|
|
}
|
|
}
|
|
|
|
async function applyProvisioning(report, env, target, options) {
|
|
report.actions.liveDbWriteAttempted = true;
|
|
report.actions.adminWriteAttempted = true;
|
|
let admin;
|
|
try {
|
|
admin = await openAdminClient(env, options);
|
|
const client = admin.client;
|
|
const roleExists = await objectExists(client, "SELECT 1 AS present FROM pg_catalog.pg_roles WHERE rolname = $1 LIMIT 1", [target.roleName]);
|
|
const databaseExists = await objectExists(client, "SELECT 1 AS present FROM pg_catalog.pg_database WHERE datname = $1 LIMIT 1", [target.databaseName]);
|
|
report.actions.roleExisted = roleExists;
|
|
report.actions.databaseExisted = databaseExists;
|
|
|
|
if (!roleExists) {
|
|
await client.query(`CREATE ROLE ${quotePgIdent(target.roleName)} LOGIN PASSWORD ${quotePgLiteral(target.password)}`);
|
|
report.actions.roleCreated = true;
|
|
} else {
|
|
await client.query(`ALTER ROLE ${quotePgIdent(target.roleName)} WITH LOGIN PASSWORD ${quotePgLiteral(target.password)}`);
|
|
}
|
|
report.actions.rolePasswordSynchronized = true;
|
|
|
|
if (!databaseExists) {
|
|
await client.query(`CREATE DATABASE ${quotePgIdent(target.databaseName)} OWNER ${quotePgIdent(target.roleName)}`);
|
|
report.actions.databaseCreated = true;
|
|
}
|
|
await client.query(`GRANT CONNECT ON DATABASE ${quotePgIdent(target.databaseName)} TO ${quotePgIdent(target.roleName)}`);
|
|
report.actions.databaseConnectGranted = true;
|
|
} catch (error) {
|
|
addClassifiedAdminBlocker(report, error, "DEV DB provisioning apply could not complete role/database provisioning.");
|
|
return;
|
|
} finally {
|
|
if (admin) await admin.close();
|
|
}
|
|
|
|
let targetAdmin;
|
|
try {
|
|
targetAdmin = await openAdminClient(env, {
|
|
...options,
|
|
adminDbUrlOverride: withDatabaseInPostgresUrl(env[DEV_DB_ADMIN_SECRET_REF.env], target.databaseName)
|
|
});
|
|
await targetAdmin.client.query(`GRANT USAGE, CREATE ON SCHEMA public TO ${quotePgIdent(target.roleName)}`);
|
|
report.actions.schemaPrivilegesGranted = true;
|
|
} catch (error) {
|
|
addClassifiedAdminBlocker(report, error, "DEV DB provisioning apply could not grant target database schema privileges.");
|
|
return;
|
|
} finally {
|
|
if (targetAdmin) await targetAdmin.close();
|
|
}
|
|
|
|
report.provisioning.targetRole = {
|
|
checked: true,
|
|
exists: true,
|
|
created: report.actions.roleCreated,
|
|
ready: true,
|
|
targetNamePresent: true
|
|
};
|
|
report.provisioning.targetDatabase = {
|
|
checked: true,
|
|
exists: true,
|
|
created: report.actions.databaseCreated,
|
|
ready: true,
|
|
targetNamePresent: true
|
|
};
|
|
report.provisioning.privileges = {
|
|
checked: true,
|
|
ready: report.actions.databaseConnectGranted && report.actions.schemaPrivilegesGranted,
|
|
databaseConnectGranted: report.actions.databaseConnectGranted,
|
|
schemaPrivilegesGranted: report.actions.schemaPrivilegesGranted
|
|
};
|
|
report.provisioning.ready = report.provisioning.privileges.ready;
|
|
}
|
|
|
|
async function openAdminClient(env, options) {
|
|
if (options.adminClient) {
|
|
return {
|
|
client: options.adminClient,
|
|
close: async () => {}
|
|
};
|
|
}
|
|
|
|
const pool = await createPgPool({
|
|
dbUrl: options.adminDbUrlOverride ?? env[DEV_DB_ADMIN_SECRET_REF.env],
|
|
sslMode: env.HWLAB_CLOUD_DB_SSL_MODE,
|
|
timeoutMs: env.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS,
|
|
pgModuleLoader: options.pgModuleLoader
|
|
});
|
|
return {
|
|
client: pool,
|
|
close: async () => {
|
|
await pool.end();
|
|
}
|
|
};
|
|
}
|
|
|
|
async function createPgPool({ dbUrl, sslMode, timeoutMs, pgModuleLoader }) {
|
|
let pg;
|
|
try {
|
|
pg = await (pgModuleLoader ? pgModuleLoader() : import("pg"));
|
|
} catch (error) {
|
|
if (error?.code === "ERR_MODULE_NOT_FOUND") {
|
|
const driverError = new Error("Postgres provisioning 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 provisioning could not load pg.Pool");
|
|
driverError.code = "HWLAB_PG_DRIVER_MISSING";
|
|
throw driverError;
|
|
}
|
|
return new Pool(buildPostgresPoolConfig({
|
|
dbUrl,
|
|
sslMode,
|
|
timeoutMs
|
|
}));
|
|
}
|
|
|
|
function inferProvisioningFromRuntime(report) {
|
|
const runtimeReady = report.runtime?.ready === true && report.runtime?.durable === true;
|
|
const authReady = runtimeAuthSatisfied(report.runtime);
|
|
if (!runtimeReady && !authReady) return;
|
|
report.provisioning.targetRole = {
|
|
...report.provisioning.targetRole,
|
|
checked: report.provisioning.targetRole.checked || false,
|
|
exists: true,
|
|
ready: true,
|
|
inferredFromRuntime: true
|
|
};
|
|
report.provisioning.targetDatabase = {
|
|
...report.provisioning.targetDatabase,
|
|
checked: report.provisioning.targetDatabase.checked || false,
|
|
exists: true,
|
|
ready: true,
|
|
inferredFromRuntime: true
|
|
};
|
|
report.provisioning.ready = true;
|
|
}
|
|
|
|
function addRuntimeProvisioningBlocker(report) {
|
|
const blocker = report.runtime?.blocker;
|
|
if (!blocker) return;
|
|
if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) {
|
|
addBlocker(report, "runtime_blocker", "runtime-provisioning-ssl", "DEV DB provisioning reached the target runtime adapter, but SSL mode blocked connection.", runtimeEvidence(report.runtime));
|
|
} else if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) {
|
|
addBlocker(report, "runtime_blocker", "runtime-provisioning-auth", "DEV DB target role/database exists check did not prove application authentication readiness.", runtimeEvidence(report.runtime));
|
|
} else if (blocker === RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING) {
|
|
addBlocker(report, "runtime_blocker", "runtime-provisioning-driver", "Postgres driver is unavailable for DEV DB provisioning verification.", runtimeEvidence(report.runtime));
|
|
}
|
|
}
|
|
|
|
function addMissingObjectBlocker(report, kind) {
|
|
const scope = kind === "role" ? "runtime-provisioning-role" : "runtime-provisioning-database";
|
|
addBlocker(report, "runtime_blocker", scope, `DEV DB target ${kind} is missing according to admin catalog inspection.`, {
|
|
exists: false,
|
|
created: false,
|
|
valueRedacted: true,
|
|
endpointRedacted: true,
|
|
affectedRuntimeBlocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
|
secretValuesPrinted: false
|
|
});
|
|
}
|
|
|
|
function addClassifiedAdminBlocker(report, error, summary) {
|
|
const classified = classifyProvisioningError(error);
|
|
addBlocker(report, "runtime_blocker", classified.scope, summary, {
|
|
blocker: classified.blocker,
|
|
errorCode: classified.errorCode,
|
|
valueRedacted: true,
|
|
endpointRedacted: true,
|
|
secretValuesPrinted: false
|
|
});
|
|
}
|
|
|
|
function classifyProvisioningError(error) {
|
|
const errorCode = typeof error?.code === "string" ? error.code : "UNKNOWN";
|
|
if (errorCode === "HWLAB_PG_DRIVER_MISSING") {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING,
|
|
scope: "runtime-provisioning-driver",
|
|
errorCode
|
|
};
|
|
}
|
|
if (isSslError(error)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
|
scope: "runtime-provisioning-ssl",
|
|
errorCode
|
|
};
|
|
}
|
|
if (["28P01", "28000", "42501"].includes(errorCode)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
|
scope: "runtime-provisioning-auth",
|
|
errorCode
|
|
};
|
|
}
|
|
if (["3D000"].includes(errorCode)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
|
scope: "runtime-provisioning-database",
|
|
errorCode
|
|
};
|
|
}
|
|
if (["42P01", "42703", "3F000"].includes(errorCode)) {
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
|
scope: "runtime-provisioning-schema",
|
|
errorCode
|
|
};
|
|
}
|
|
return {
|
|
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
|
scope: "runtime-provisioning-query",
|
|
errorCode
|
|
};
|
|
}
|
|
|
|
function runtimeEvidence(runtime) {
|
|
return {
|
|
blocker: runtime.blocker,
|
|
queryResult: runtime.connection?.queryResult ?? "unknown",
|
|
errorCode: runtime.connection?.errorCode ?? null,
|
|
schemaReady: runtime.schema?.ready === true,
|
|
migrationReady: runtime.migration?.ready === true,
|
|
durableReady: runtime.ready === true && runtime.durable === true,
|
|
secretValuesPrinted: false
|
|
};
|
|
}
|
|
|
|
async function objectExists(client, sql, params) {
|
|
const result = await client.query(sql, params);
|
|
return Array.isArray(result?.rows) && result.rows.length > 0;
|
|
}
|
|
|
|
function sourceOnlyGates(target) {
|
|
const sourceReady = target.ok;
|
|
const targetMissingInSourceOnly = target.present === false;
|
|
return {
|
|
ssl: notCheckedGate("source-only validation does not connect to Postgres"),
|
|
auth: sourceReady || targetMissingInSourceOnly ? notCheckedGate("auth requires live target runtime verification") : blockedGate("runtime_provisioning_target_blocked"),
|
|
role: sourceReady || targetMissingInSourceOnly ? notCheckedGate("role existence requires DEV admin catalog inspection") : blockedGate("runtime_provisioning_target_blocked"),
|
|
database: sourceReady || targetMissingInSourceOnly ? notCheckedGate("database existence requires DEV admin catalog inspection") : blockedGate("runtime_provisioning_target_blocked"),
|
|
schema: notCheckedGate("schema is verified by runtime migration/readiness after role/database provisioning"),
|
|
migration: notCheckedGate("migration ledger is verified by dev-runtime-migration"),
|
|
durability: notCheckedGate("durability is verified by post-provisioning runtime readiness and postflight")
|
|
};
|
|
}
|
|
|
|
function normalizeGate(gate = {}) {
|
|
if (gate.ready === true || gate.status === "ready") 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 emptyObjectState(targetNamePresent) {
|
|
return {
|
|
checked: false,
|
|
exists: false,
|
|
created: false,
|
|
ready: false,
|
|
targetNamePresent
|
|
};
|
|
}
|
|
|
|
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: {
|
|
checked: Boolean(runtime.schema?.checked),
|
|
ready: Boolean(runtime.schema?.ready),
|
|
missingTables: Array.isArray(runtime.schema?.missingTables) ? [...runtime.schema.missingTables] : [],
|
|
missingColumns: Array.isArray(runtime.schema?.missingColumns) ? [...runtime.schema.missingColumns] : []
|
|
},
|
|
migration: {
|
|
checked: Boolean(runtime.migration?.checked),
|
|
ready: Boolean(runtime.migration?.ready),
|
|
requiredMigrationId: runtime.migration?.requiredMigrationId ?? null,
|
|
appliedMigrationId: runtime.migration?.appliedMigrationId ?? null,
|
|
missing: runtime.migration?.missing !== false
|
|
},
|
|
gates: runtime.gates ?? {}
|
|
};
|
|
}
|
|
|
|
function runtimeAuthSatisfied(runtime = {}) {
|
|
if (runtime.ready === true && runtime.durable === true) return true;
|
|
const blocker = runtime.blocker;
|
|
return [
|
|
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
|
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED
|
|
].includes(blocker) && runtime.connection?.queryAttempted === true;
|
|
}
|
|
|
|
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",
|
|
`${appSecretRef.env} from ${appSecretRef.secretName}/${appSecretRef.secretKey}`,
|
|
`${DEV_DB_ADMIN_SECRET_REF.env} from ${DEV_DB_ADMIN_SECRET_REF.secretName}/${DEV_DB_ADMIN_SECRET_REF.secretKey} unless runtime readiness already proves auth`
|
|
],
|
|
requiresForApply: [
|
|
"--apply",
|
|
"--confirm-dev",
|
|
"--confirmed-non-production",
|
|
`${appSecretRef.env} from ${appSecretRef.secretName}/${appSecretRef.secretKey}`,
|
|
`${DEV_DB_ADMIN_SECRET_REF.env} from ${DEV_DB_ADMIN_SECRET_REF.secretName}/${DEV_DB_ADMIN_SECRET_REF.secretKey}`
|
|
],
|
|
writeScope: args.mode === "apply"
|
|
? [
|
|
"DEV Postgres target role named by the redacted cloud-api DB URL",
|
|
"DEV Postgres target database named by the redacted cloud-api DB URL",
|
|
"DEV target DB CONNECT and public schema CREATE/USAGE grants for that role"
|
|
]
|
|
: [],
|
|
noWriteScope: [
|
|
"Kubernetes Secret resources",
|
|
"PROD",
|
|
"service restarts",
|
|
"schema migrations",
|
|
"M3/M4/M5 acceptance"
|
|
],
|
|
forbidden: [
|
|
"printing DB URLs, passwords, tokens, Secret values, or kubeconfig material",
|
|
"manual psql or node stdin database writes",
|
|
"mutating PROD"
|
|
]
|
|
};
|
|
}
|
|
|
|
function buildSafety(args) {
|
|
return {
|
|
devOnly: true,
|
|
environment: ENVIRONMENT_DEV,
|
|
targetNamespace: DEV_DB_ENV_CONTRACT.dns.namespace,
|
|
prodAllowed: false,
|
|
sourceStoresSecretValues: false,
|
|
printsSecretValues: false,
|
|
secretValuesPrinted: false,
|
|
dbUrlValueRedacted: true,
|
|
adminUrlValueRedacted: 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#307" : issue,
|
|
evidence
|
|
});
|
|
}
|
|
|
|
function addSafetyRefusal(report, summary) {
|
|
report.safetyRefusal = true;
|
|
addBlocker(report, "safety_refusal", "runtime-provisioning-apply-boundary", summary, {
|
|
devOnly: true,
|
|
prodAllowed: false,
|
|
secretValuesPrinted: false
|
|
});
|
|
}
|
|
|
|
function finalizeReport(report) {
|
|
const openBlockers = report.blockers.filter((blocker) => blocker.status === "open");
|
|
report.provisioning.ready = report.provisioning.ready || (
|
|
report.provisioning.targetRole.ready === true &&
|
|
report.provisioning.targetDatabase.ready === true &&
|
|
(report.provisioning.privileges.ready === true || report.mode !== "apply")
|
|
);
|
|
report.conclusion = {
|
|
status: openBlockers.length === 0 ? "ready" : "blocked",
|
|
summary: openBlockers.length === 0
|
|
? conclusionReadySummary(report)
|
|
: "DEV DB provisioning is blocked; see separated role/database/ssl/auth blockers.",
|
|
blockerCount: openBlockers.length
|
|
};
|
|
return report;
|
|
}
|
|
|
|
function conclusionReadySummary(report) {
|
|
if (report.mode === "apply") {
|
|
return "DEV DB role/database provisioning completed without printing secret values.";
|
|
}
|
|
if (report.mode === "dry-run" && report.actions.liveDbReadAttempted) {
|
|
return "DEV DB role/database provisioning read-only verification passed without DB writes or secret output.";
|
|
}
|
|
return "DEV DB provisioning source contract is ready; no DB connection or live write was attempted.";
|
|
}
|
|
|
|
function summarizeReport(report) {
|
|
return {
|
|
issue: report.issue,
|
|
mode: report.mode,
|
|
conclusion: report.conclusion,
|
|
db: report.db,
|
|
actions: report.actions,
|
|
provisioning: report.provisioning,
|
|
gates: report.gates,
|
|
downstreamRuntimeGates: report.downstreamRuntimeGates,
|
|
blockers: report.blockers,
|
|
safety: report.safety
|
|
};
|
|
}
|
|
|
|
function quotePgIdent(value) {
|
|
return `"${String(value).replaceAll('"', '""')}"`;
|
|
}
|
|
|
|
function quotePgLiteral(value) {
|
|
return `'${String(value).replaceAll("'", "''")}'`;
|
|
}
|
|
|
|
function withDatabaseInPostgresUrl(rawUrl, databaseName) {
|
|
const url = new URL(rawUrl);
|
|
url.pathname = `/${encodeURIComponent(databaseName)}`;
|
|
return url.toString();
|
|
}
|
|
|
|
function isSslError(error) {
|
|
const code = typeof error?.code === "string" ? error.code : "";
|
|
if (code.startsWith("ERR_SSL") || code.startsWith("ERR_TLS")) return true;
|
|
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
|
return [
|
|
"does not support ssl",
|
|
"ssl is not enabled",
|
|
"ssl negotiation",
|
|
"ssl off",
|
|
"ssl required",
|
|
"requires ssl",
|
|
"no ssl encryption",
|
|
"no encryption",
|
|
"hostssl",
|
|
"server requires ssl",
|
|
"tls handshake"
|
|
].some((pattern) => message.includes(pattern));
|
|
}
|
|
|
|
async function writeReport(report, reportPath, root) {
|
|
const absolute = ensureNotRepoReportsPath(root, reportPath, "--report");
|
|
await mkdir(path.dirname(absolute), { recursive: true });
|
|
await writeFile(absolute, `${JSON.stringify(report, null, 2)}\n`);
|
|
}
|
|
|
|
function requireArgValue(value, flag) {
|
|
if (typeof value !== "string" || value.trim() === "") {
|
|
throw new Error(`${flag} requires a value`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function usage() {
|
|
return [
|
|
"Usage: node scripts/dev-runtime-provisioning.mjs [--check|--dry-run|--apply]",
|
|
"",
|
|
"Default --check validates redacted DEV DB provisioning inputs 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, HWLAB_CLOUD_DB_URL, and HWLAB_CLOUD_DB_ADMIN_URL.",
|
|
"Reports never print DB URLs, passwords, tokens, Secret values, or kubeconfig material."
|
|
].join("\n");
|
|
}
|
|
|
|
export function formatDevRuntimeProvisioningFailure(error) {
|
|
const message = redactFailureText(error instanceof Error ? error.message : String(error));
|
|
return {
|
|
issue,
|
|
conclusion: {
|
|
status: "blocked",
|
|
summary: "DEV DB provisioning command failed before producing a report."
|
|
},
|
|
error: message,
|
|
safety: {
|
|
devOnly: true,
|
|
prodAllowed: false,
|
|
secretValuesPrinted: false
|
|
},
|
|
trace: createHash("sha256").update(message).digest("hex").slice(0, 12)
|
|
};
|
|
}
|
|
|
|
function redactFailureText(value) {
|
|
return String(value ?? "")
|
|
.replace(/postgres(?:ql)?:\/\/[^\s"'<>]+/giu, "[redacted-postgres-url]")
|
|
.replace(/(password\s*[=:]\s*)[^\s,;]+/giu, "$1[redacted]")
|
|
.replace(/(token\s*[=:]\s*)[^\s,;]+/giu, "$1[redacted]")
|
|
.replace(/(kubeconfig\s*[=:]\s*)[^\s,;]+/giu, "$1[redacted]");
|
|
}
|