fix: clarify durable Postgres blocker diagnostics
Host commander merge after PR workflow closeout. Reviewed PR #230 against current main; D601 closeout task codex_1779506316145_1 recommended MERGE. Verification recorded: GitHub CLEAN/MERGEABLE; merge-tree clean over current main; focused PR tests node --check plus node --test internal/db/runtime-store.test.mjs internal/cloud/server.test.mjs scripts/src/dev-runtime-migration.test.mjs passed 33/33; synthetic merge including scripts/artifact-runtime-readiness-guard.test.mjs passed 40/40. This merge does not claim live DEV deployment or M3/M4/M5 acceptance; runtime durability remains blocked until deployed and separately verified.
This commit is contained in:
@@ -59,10 +59,10 @@ checks:
|
||||
- read readiness against the runtime tables used by audit and evidence
|
||||
queries.
|
||||
|
||||
If DB auth, schema, migration, or read readiness is missing, `/health/live`
|
||||
If DB SSL negotiation, auth, schema, migration, or read readiness is missing, `/health/live`
|
||||
stays degraded with `runtime.durable: false`, `runtime.durableRequested: true`,
|
||||
and a runtime blocker such as `runtime_durable_adapter_auth_blocked`,
|
||||
`runtime_durable_adapter_schema_blocked`, or
|
||||
and a runtime blocker such as `runtime_durable_adapter_ssl_blocked`,
|
||||
`runtime_durable_adapter_auth_blocked`, `runtime_durable_adapter_schema_blocked`, or
|
||||
`runtime_durable_adapter_migration_blocked`. Fixture data and env presence are
|
||||
never promoted to `runtime.liveRuntimeEvidence`.
|
||||
|
||||
|
||||
@@ -180,7 +180,7 @@ proven through the configured adapter.
|
||||
`dbLiveEvidenceObserved=true` with
|
||||
`dbLiveEvidenceIsDurabilityEvidence=false` means the DB TCP/live layer is
|
||||
separate from runtime persistence. If runtime durability is blocked,
|
||||
`blockedLayer` names the current adapter/auth/schema/migration/durability query
|
||||
`blockedLayer` names the current adapter/ssl/auth/schema/migration/durability query
|
||||
layer, and `requiredEvidence` remains the durable adapter schema, migration,
|
||||
and read query contract.
|
||||
|
||||
|
||||
@@ -242,6 +242,12 @@ function buildRedactionSafety(connection) {
|
||||
|
||||
async function probeDbConnection(env, options) {
|
||||
const timeoutMs = normalizeTimeoutMs(options.timeoutMs ?? env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS);
|
||||
if (typeof options.probe === "function") {
|
||||
return options.probe({
|
||||
endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source,
|
||||
timeoutMs
|
||||
});
|
||||
}
|
||||
const parsed = parseDbUrlContract(env?.HWLAB_CLOUD_DB_URL);
|
||||
if (!parsed.ok) {
|
||||
return {
|
||||
|
||||
@@ -5,6 +5,7 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_UNCONFIGURED,
|
||||
RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
|
||||
RUNTIME_STORE_KIND_POSTGRES
|
||||
@@ -190,6 +191,9 @@ function classifyRuntimeDurabilityLayer(runtime = {}) {
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) {
|
||||
return "auth";
|
||||
}
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) {
|
||||
return "ssl";
|
||||
}
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) {
|
||||
return "schema";
|
||||
}
|
||||
|
||||
@@ -5,8 +5,11 @@ import test from "node:test";
|
||||
|
||||
import { createCloudApiServer } from "./server.mjs";
|
||||
import {
|
||||
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED
|
||||
} from "../db/runtime-store.mjs";
|
||||
|
||||
test("cloud api exposes /health, /health/live, and /live probes", async () => {
|
||||
@@ -399,6 +402,142 @@ test("cloud api health keeps DB live evidence separate when durable adapter quer
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health contract distinguishes durable SSL, auth, schema, migration, and query blockers", async () => {
|
||||
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
|
||||
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
process.env.HWLAB_CLOUD_DB_URL = "redacted";
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = "require";
|
||||
|
||||
const cases = [
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
blockedLayer: "ssl",
|
||||
queryResult: "ssl_negotiation_blocked",
|
||||
gates: {
|
||||
ssl: "blocked",
|
||||
auth: "not_checked",
|
||||
schema: "not_checked",
|
||||
migration: "not_checked",
|
||||
durability: "blocked"
|
||||
}
|
||||
},
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
blockedLayer: "auth",
|
||||
queryResult: "auth_blocked",
|
||||
gates: {
|
||||
ssl: "ready",
|
||||
auth: "blocked",
|
||||
schema: "not_checked",
|
||||
migration: "not_checked",
|
||||
durability: "blocked"
|
||||
}
|
||||
},
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
blockedLayer: "schema",
|
||||
queryResult: "schema_blocked",
|
||||
gates: {
|
||||
ssl: "ready",
|
||||
auth: "ready",
|
||||
schema: "blocked",
|
||||
migration: "not_checked",
|
||||
durability: "blocked"
|
||||
}
|
||||
},
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
||||
blockedLayer: "migration",
|
||||
queryResult: "migration_blocked",
|
||||
gates: {
|
||||
ssl: "ready",
|
||||
auth: "ready",
|
||||
schema: "ready",
|
||||
migration: "blocked",
|
||||
durability: "blocked"
|
||||
}
|
||||
},
|
||||
{
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
||||
blockedLayer: "durability_query",
|
||||
queryResult: "query_blocked",
|
||||
gates: {
|
||||
ssl: "ready",
|
||||
auth: "ready",
|
||||
schema: "ready",
|
||||
migration: "ready",
|
||||
durability: "blocked"
|
||||
}
|
||||
}
|
||||
];
|
||||
|
||||
try {
|
||||
for (const testCase of cases) {
|
||||
const server = createCloudApiServer({
|
||||
dbProbe: {
|
||||
probe: async ({ endpointSource, timeoutMs }) => ({
|
||||
attempted: true,
|
||||
networkAttempted: true,
|
||||
endpointSource,
|
||||
probeType: "tcp-connect",
|
||||
endpointRedacted: true,
|
||||
valueRedacted: true,
|
||||
timeoutMs,
|
||||
durationMs: 0,
|
||||
result: "connected",
|
||||
classification: "tcp_connected",
|
||||
errorCode: null,
|
||||
missingEnv: []
|
||||
})
|
||||
},
|
||||
runtimeStore: {
|
||||
async readiness() {
|
||||
return durableBlockedRuntime(testCase);
|
||||
}
|
||||
}
|
||||
});
|
||||
await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve));
|
||||
|
||||
try {
|
||||
const { port } = server.address();
|
||||
const response = await fetch(`http://127.0.0.1:${port}/health/live`);
|
||||
const payload = await response.json();
|
||||
assert.equal(response.status, 200);
|
||||
assert.equal(payload.status, "degraded");
|
||||
assert.equal(payload.db.ready, true);
|
||||
assert.equal(payload.db.liveDbEvidence, true);
|
||||
assert.equal(payload.runtime.blocker, testCase.blocker);
|
||||
assert.equal(payload.runtime.connection.queryResult, testCase.queryResult);
|
||||
assert.equal(payload.runtime.gates.ssl.status, testCase.gates.ssl);
|
||||
assert.equal(payload.runtime.gates.auth.status, testCase.gates.auth);
|
||||
assert.equal(payload.runtime.gates.schema.status, testCase.gates.schema);
|
||||
assert.equal(payload.runtime.gates.migration.status, testCase.gates.migration);
|
||||
assert.equal(payload.runtime.gates.durability.status, testCase.gates.durability);
|
||||
assert.equal(payload.runtime.durabilityContract.blockedLayer, testCase.blockedLayer);
|
||||
assert.equal(payload.readiness.durability.blockedLayer, testCase.blockedLayer);
|
||||
assert.equal(payload.readiness.durability.queryResult, testCase.queryResult);
|
||||
assert.equal(payload.readiness.durability.secretMaterialRead, false);
|
||||
assert.ok(payload.blockerCodes.includes(testCase.blocker));
|
||||
} finally {
|
||||
await new Promise((resolve, reject) => {
|
||||
server.close((error) => (error ? reject(error) : resolve()));
|
||||
});
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
if (originalUrl === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_URL;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_URL = originalUrl;
|
||||
}
|
||||
if (originalSslMode === undefined) {
|
||||
delete process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
} else {
|
||||
process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test("cloud api health does not treat DB env presence-only as live readiness", async () => {
|
||||
const originalUrl = process.env.HWLAB_CLOUD_DB_URL;
|
||||
const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;
|
||||
@@ -453,6 +592,65 @@ test("cloud api health does not treat DB env presence-only as live readiness", a
|
||||
}
|
||||
});
|
||||
|
||||
function durableBlockedRuntime({ blocker, blockedLayer, queryResult, gates }) {
|
||||
return {
|
||||
adapter: "postgres",
|
||||
durable: false,
|
||||
durableRequested: true,
|
||||
durableCapable: false,
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
blocker,
|
||||
reason: `test durable runtime blocker ${blocker}`,
|
||||
liveRuntimeEvidence: false,
|
||||
fixtureEvidence: false,
|
||||
connection: {
|
||||
queryAttempted: true,
|
||||
queryResult,
|
||||
endpointRedacted: true,
|
||||
valueRedacted: true,
|
||||
errorCode: "TEST_BLOCKER"
|
||||
},
|
||||
schema: {
|
||||
checked: gates.schema !== "not_checked",
|
||||
ready: gates.schema === "ready",
|
||||
missingTables: gates.schema === "blocked" ? ["gateway_sessions"] : [],
|
||||
missingColumns: gates.schema === "blocked" ? ["gateway_sessions.gateway_session_json"] : []
|
||||
},
|
||||
migration: {
|
||||
checked: gates.migration !== "not_checked",
|
||||
ready: gates.migration === "ready",
|
||||
missing: gates.migration !== "ready"
|
||||
},
|
||||
gates: Object.fromEntries(Object.entries(gates).map(([name, status]) => [
|
||||
name,
|
||||
{
|
||||
checked: status !== "not_checked",
|
||||
ready: status === "ready",
|
||||
status,
|
||||
blocker: status === "blocked" ? blocker : null
|
||||
}
|
||||
])),
|
||||
durabilityContract: {
|
||||
ready: false,
|
||||
status: "blocked",
|
||||
requiredEvidence: "runtime_adapter_schema_migration_read_query",
|
||||
dbLiveEvidenceIsDurabilityEvidence: false,
|
||||
liveRuntimeEvidence: false,
|
||||
adapterQueryRequired: true,
|
||||
blockedLayer,
|
||||
blocker,
|
||||
secretMaterialRead: false
|
||||
},
|
||||
safety: {
|
||||
secretMaterialRead: false,
|
||||
valuesRedacted: true,
|
||||
endpointRedacted: true
|
||||
},
|
||||
counts: {}
|
||||
};
|
||||
}
|
||||
|
||||
test("cloud api /v1 describes Code Agent provider blocker without leaking secret values", async () => {
|
||||
const server = createCloudApiServer({
|
||||
env: {
|
||||
|
||||
+129
-17
@@ -23,6 +23,7 @@ export const RUNTIME_STORE_KIND_POSTGRES = "postgres";
|
||||
export const RUNTIME_DURABLE_ADAPTER_MISSING = "runtime_durable_adapter_missing";
|
||||
export const RUNTIME_DURABLE_ADAPTER_UNCONFIGURED = "runtime_durable_adapter_unconfigured";
|
||||
export const RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING = "runtime_durable_adapter_driver_missing";
|
||||
export const RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED = "runtime_durable_adapter_ssl_blocked";
|
||||
export const RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED = "runtime_durable_adapter_auth_blocked";
|
||||
export const RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED = "runtime_durable_adapter_schema_blocked";
|
||||
export const RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED = "runtime_durable_adapter_migration_blocked";
|
||||
@@ -629,6 +630,7 @@ export class PostgresCloudRuntimeStore {
|
||||
queryResult: "schema_blocked"
|
||||
},
|
||||
gates: runtimeGates({
|
||||
ssl: readyGate(),
|
||||
auth: readyGate(),
|
||||
schema: blockedGate({ checked: true, blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED }),
|
||||
migration: notCheckedGate(),
|
||||
@@ -643,32 +645,41 @@ export class PostgresCloudRuntimeStore {
|
||||
migration = await this.readMigrationReadiness();
|
||||
} catch (error) {
|
||||
const classified = classifyRuntimeDbError(error);
|
||||
const migrationReadBlocker = [
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED
|
||||
].includes(classified.blocker)
|
||||
? classified.blocker
|
||||
: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED;
|
||||
this.lastReadiness = this.blockedSummary({
|
||||
blocker: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED
|
||||
? classified.blocker
|
||||
: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
||||
reason: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED
|
||||
blocker: migrationReadBlocker,
|
||||
reason: migrationReadBlocker === classified.blocker
|
||||
? classified.reason
|
||||
: "Postgres runtime schema is present, but the durable runtime migration ledger could not be proven",
|
||||
schema,
|
||||
migration: blockedRuntimeMigration({ errorCode: classified.connection?.errorCode }),
|
||||
connection: {
|
||||
queryAttempted: true,
|
||||
queryResult: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED ? "auth_blocked" : "migration_blocked",
|
||||
queryResult: migrationReadBlocker === classified.blocker
|
||||
? classified.connection?.queryResult
|
||||
: "migration_blocked",
|
||||
errorCode: classified.connection?.errorCode ?? null
|
||||
},
|
||||
gates: runtimeGates({
|
||||
ssl: classified.blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED
|
||||
? blockedGate({ checked: true, blocker: classified.blocker })
|
||||
: readyGate(),
|
||||
auth: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED
|
||||
? blockedGate({ checked: true, blocker: classified.blocker })
|
||||
: classified.blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED
|
||||
? notCheckedGate()
|
||||
: readyGate(),
|
||||
schema: readyGate(),
|
||||
migration: blockedGate({
|
||||
checked: true,
|
||||
blocker: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED
|
||||
? classified.blocker
|
||||
: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED
|
||||
blocker: migrationReadBlocker
|
||||
}),
|
||||
durability: blockedGate({ checked: false, blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED })
|
||||
durability: blockedGate({ checked: false, blocker: migrationReadBlocker })
|
||||
})
|
||||
});
|
||||
return this.summary();
|
||||
@@ -685,6 +696,7 @@ export class PostgresCloudRuntimeStore {
|
||||
queryResult: "migration_blocked"
|
||||
},
|
||||
gates: runtimeGates({
|
||||
ssl: readyGate(),
|
||||
auth: readyGate(),
|
||||
schema: readyGate(),
|
||||
migration: blockedGate({ checked: true, blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED }),
|
||||
@@ -709,9 +721,14 @@ export class PostgresCloudRuntimeStore {
|
||||
errorCode: classified.connection?.errorCode ?? null
|
||||
},
|
||||
gates: runtimeGates({
|
||||
auth: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED
|
||||
ssl: classified.blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED
|
||||
? blockedGate({ checked: true, blocker: classified.blocker })
|
||||
: readyGate(),
|
||||
auth: classified.blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED
|
||||
? blockedGate({ checked: true, blocker: classified.blocker })
|
||||
: classified.blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED
|
||||
? notCheckedGate()
|
||||
: readyGate(),
|
||||
schema: readyGate(),
|
||||
migration: readyGate(),
|
||||
durability: blockedGate({ checked: true, blocker: classified.blocker })
|
||||
@@ -740,6 +757,7 @@ export class PostgresCloudRuntimeStore {
|
||||
schema,
|
||||
migration,
|
||||
gates: runtimeGates({
|
||||
ssl: readyGate(),
|
||||
auth: readyGate(),
|
||||
schema: readyGate(),
|
||||
migration: readyGate(),
|
||||
@@ -1158,13 +1176,9 @@ export class PostgresCloudRuntimeStore {
|
||||
schema: blockedSchema,
|
||||
migration: blockedMigration,
|
||||
gates: gates ?? runtimeGates({
|
||||
auth: connection?.queryAttempted ? blockedGate({ checked: true, blocker }) : notCheckedGate(),
|
||||
schema: blockedSchema.checked
|
||||
? gateFromReadiness(blockedSchema.ready, { blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED })
|
||||
: notCheckedGate(),
|
||||
migration: blockedMigration.checked
|
||||
? gateFromReadiness(blockedMigration.ready, { blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED })
|
||||
: notCheckedGate(),
|
||||
...defaultRuntimeGates({ blocker, connection, schema: blockedSchema, migration: blockedMigration }),
|
||||
schema: defaultSchemaGate({ blocker, schema: blockedSchema }),
|
||||
migration: defaultMigrationGate({ blocker, migration: blockedMigration }),
|
||||
durability: blockedGate({ checked: false, blocker })
|
||||
}),
|
||||
safety: runtimeSafety(),
|
||||
@@ -1335,6 +1349,9 @@ function durabilityBlockedLayer(summary = {}) {
|
||||
) {
|
||||
return "adapter";
|
||||
}
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) {
|
||||
return "ssl";
|
||||
}
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) {
|
||||
return "auth";
|
||||
}
|
||||
@@ -1373,6 +1390,17 @@ function classifyRuntimeDbError(error) {
|
||||
}
|
||||
};
|
||||
}
|
||||
if (isRuntimeDbSslError(error)) {
|
||||
return {
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
reason: "Postgres runtime adapter reached the DB driver but SSL negotiation or SSL mode compatibility blocked the schema query",
|
||||
connection: {
|
||||
queryAttempted: true,
|
||||
queryResult: "ssl_negotiation_blocked",
|
||||
errorCode: code
|
||||
}
|
||||
};
|
||||
}
|
||||
if (["28P01", "28000", "42501"].includes(code)) {
|
||||
return {
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
@@ -1447,6 +1475,37 @@ function durableRuntimeReadBlockedData(method, readiness = {}) {
|
||||
});
|
||||
}
|
||||
|
||||
function isRuntimeDbSslError(error) {
|
||||
const code = typeof error?.code === "string" ? error.code : "";
|
||||
if (code.startsWith("ERR_SSL") || code.startsWith("ERR_TLS")) {
|
||||
return true;
|
||||
}
|
||||
if ([
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"SELF_SIGNED_CERT_IN_CHAIN",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
||||
"CERT_HAS_EXPIRED"
|
||||
].includes(code)) {
|
||||
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",
|
||||
"before secure tls connection",
|
||||
"tls handshake"
|
||||
].some((pattern) => message.includes(pattern));
|
||||
}
|
||||
|
||||
function summarizeRuntimeSchema(rows) {
|
||||
const columnsByTable = new Map();
|
||||
for (const row of rows) {
|
||||
@@ -1506,12 +1565,14 @@ function blockedRuntimeMigration({ errorCode = null } = {}) {
|
||||
}
|
||||
|
||||
function runtimeGates({
|
||||
ssl = notCheckedGate(),
|
||||
auth = notCheckedGate(),
|
||||
schema = notCheckedGate(),
|
||||
migration = notCheckedGate(),
|
||||
durability = notCheckedGate()
|
||||
} = {}) {
|
||||
return {
|
||||
ssl,
|
||||
auth,
|
||||
schema,
|
||||
migration,
|
||||
@@ -1519,6 +1580,57 @@ function runtimeGates({
|
||||
};
|
||||
}
|
||||
|
||||
function defaultRuntimeGates({ blocker, connection, schema, migration } = {}) {
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) {
|
||||
return {
|
||||
ssl: blockedGate({ checked: true, blocker }),
|
||||
auth: notCheckedGate()
|
||||
};
|
||||
}
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) {
|
||||
return {
|
||||
ssl: readyGate(),
|
||||
auth: blockedGate({ checked: true, blocker })
|
||||
};
|
||||
}
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) {
|
||||
return {
|
||||
ssl: connection?.queryAttempted ? readyGate() : notCheckedGate(),
|
||||
auth: connection?.queryAttempted ? readyGate() : notCheckedGate()
|
||||
};
|
||||
}
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED && schema?.checked && migration?.checked) {
|
||||
return {
|
||||
ssl: readyGate(),
|
||||
auth: readyGate()
|
||||
};
|
||||
}
|
||||
return {
|
||||
ssl: notCheckedGate(),
|
||||
auth: notCheckedGate()
|
||||
};
|
||||
}
|
||||
|
||||
function defaultSchemaGate({ blocker, schema } = {}) {
|
||||
if (schema?.checked) {
|
||||
return gateFromReadiness(schema.ready, { blocker: RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED });
|
||||
}
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) {
|
||||
return blockedGate({ checked: true, blocker });
|
||||
}
|
||||
return notCheckedGate();
|
||||
}
|
||||
|
||||
function defaultMigrationGate({ blocker, migration } = {}) {
|
||||
if (migration?.checked) {
|
||||
return gateFromReadiness(migration.ready, { blocker: RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED });
|
||||
}
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED) {
|
||||
return blockedGate({ checked: true, blocker });
|
||||
}
|
||||
return notCheckedGate();
|
||||
}
|
||||
|
||||
function readyGate() {
|
||||
return {
|
||||
checked: true,
|
||||
|
||||
@@ -9,6 +9,7 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
||||
RUNTIME_DURABILITY_REQUIRED_EVIDENCE,
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
RUNTIME_STORE_KIND_POSTGRES,
|
||||
createCloudRuntimeStore,
|
||||
createConfiguredCloudRuntimeStore
|
||||
@@ -69,6 +70,97 @@ test("configured postgres runtime classifies auth blocker before schema and migr
|
||||
assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false);
|
||||
});
|
||||
|
||||
test("configured postgres runtime classifies schema driver errors separately from query failures", async () => {
|
||||
const store = createConfiguredCloudRuntimeStore({
|
||||
env: {
|
||||
HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres",
|
||||
HWLAB_CLOUD_RUNTIME_DURABLE: "true"
|
||||
},
|
||||
queryClient: {
|
||||
async query(sql) {
|
||||
assert.match(sql, /information_schema\.columns/u);
|
||||
const error = new Error("schema missing");
|
||||
error.code = "3F000";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const readiness = await store.readiness();
|
||||
assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED);
|
||||
assert.equal(readiness.connection.queryAttempted, true);
|
||||
assert.equal(readiness.connection.queryResult, "schema_blocked");
|
||||
assert.equal(readiness.gates.ssl.status, "ready");
|
||||
assert.equal(readiness.gates.auth.status, "ready");
|
||||
assert.equal(readiness.gates.schema.status, "blocked");
|
||||
assert.equal(readiness.gates.migration.status, "not_checked");
|
||||
assert.equal(readiness.durabilityContract.blockedLayer, "schema");
|
||||
});
|
||||
|
||||
test("configured postgres runtime classifies SSL negotiation blocker separately from auth", async () => {
|
||||
const store = createConfiguredCloudRuntimeStore({
|
||||
env: {
|
||||
HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres",
|
||||
HWLAB_CLOUD_RUNTIME_DURABLE: "true"
|
||||
},
|
||||
queryClient: {
|
||||
async query(sql) {
|
||||
assert.match(sql, /information_schema\.columns/u);
|
||||
const error = new Error("The server does not support SSL connections");
|
||||
error.code = "08P01";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const readiness = await store.readiness();
|
||||
assert.equal(readiness.adapter, RUNTIME_STORE_KIND_POSTGRES);
|
||||
assert.equal(readiness.durable, false);
|
||||
assert.equal(readiness.ready, false);
|
||||
assert.equal(readiness.status, "blocked");
|
||||
assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED);
|
||||
assert.equal(readiness.connection.queryAttempted, true);
|
||||
assert.equal(readiness.connection.queryResult, "ssl_negotiation_blocked");
|
||||
assert.equal(readiness.connection.errorCode, "08P01");
|
||||
assert.equal(readiness.gates.ssl.status, "blocked");
|
||||
assert.equal(readiness.gates.auth.status, "not_checked");
|
||||
assert.equal(readiness.gates.schema.status, "not_checked");
|
||||
assert.equal(readiness.gates.migration.status, "not_checked");
|
||||
assert.equal(readiness.durabilityContract.ready, false);
|
||||
assert.equal(readiness.durabilityContract.blockedLayer, "ssl");
|
||||
assert.equal(readiness.durabilityContract.secretMaterialRead, false);
|
||||
assert.equal(JSON.stringify(readiness).includes("The server does not support SSL connections"), false);
|
||||
});
|
||||
|
||||
test("configured postgres runtime classifies pg_hba no-encryption rejection as SSL before auth", async () => {
|
||||
const store = createConfiguredCloudRuntimeStore({
|
||||
env: {
|
||||
HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres",
|
||||
HWLAB_CLOUD_RUNTIME_DURABLE: "true"
|
||||
},
|
||||
sslMode: "disable",
|
||||
queryClient: {
|
||||
async query(sql) {
|
||||
assert.match(sql, /information_schema\.columns/u);
|
||||
const error = new Error('no pg_hba.conf entry for host "[redacted]", user "[redacted]", database "[redacted]", no encryption');
|
||||
error.code = "28000";
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const readiness = await store.readiness();
|
||||
assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED);
|
||||
assert.equal(readiness.connection.queryAttempted, true);
|
||||
assert.equal(readiness.connection.queryResult, "ssl_negotiation_blocked");
|
||||
assert.equal(readiness.connection.errorCode, "28000");
|
||||
assert.equal(readiness.gates.ssl.status, "blocked");
|
||||
assert.equal(readiness.gates.auth.status, "not_checked");
|
||||
assert.equal(readiness.durabilityContract.blockedLayer, "ssl");
|
||||
assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false);
|
||||
assert.equal(JSON.stringify(readiness).includes("pg_hba.conf"), false);
|
||||
});
|
||||
|
||||
test("configured postgres runtime reports schema blocker without green readiness", async () => {
|
||||
const store = createConfiguredCloudRuntimeStore({
|
||||
env: {
|
||||
@@ -133,6 +225,30 @@ test("configured postgres runtime keeps durable false when migration ledger is m
|
||||
assert.equal(readiness.durabilityContract.dbLiveEvidenceIsDurabilityEvidence, false);
|
||||
});
|
||||
|
||||
test("configured postgres runtime classifies migration query failures separately from generic readiness queries", async () => {
|
||||
const store = createConfiguredCloudRuntimeStore({
|
||||
env: {
|
||||
HWLAB_CLOUD_RUNTIME_ADAPTER: "postgres",
|
||||
HWLAB_CLOUD_RUNTIME_DURABLE: "true"
|
||||
},
|
||||
queryClient: createFakePostgresClient({ migrationErrorCode: "57014" })
|
||||
});
|
||||
|
||||
const readiness = await store.readiness();
|
||||
assert.equal(readiness.blocker, RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED);
|
||||
assert.equal(readiness.schema.ready, true);
|
||||
assert.equal(readiness.migration.checked, true);
|
||||
assert.equal(readiness.migration.errorCode, "57014");
|
||||
assert.equal(readiness.connection.queryAttempted, true);
|
||||
assert.equal(readiness.connection.queryResult, "migration_blocked");
|
||||
assert.equal(readiness.gates.ssl.status, "ready");
|
||||
assert.equal(readiness.gates.auth.status, "ready");
|
||||
assert.equal(readiness.gates.schema.status, "ready");
|
||||
assert.equal(readiness.gates.migration.status, "blocked");
|
||||
assert.equal(readiness.gates.durability.status, "not_checked");
|
||||
assert.equal(readiness.durabilityContract.blockedLayer, "migration");
|
||||
});
|
||||
|
||||
test("configured postgres runtime classifies durable read query blocker after schema and migration", async () => {
|
||||
const store = createConfiguredCloudRuntimeStore({
|
||||
env: {
|
||||
@@ -430,7 +546,12 @@ test("configured postgres runtime persists and queries records through query cli
|
||||
assert.equal(evidence.records[0].operationId, invoke.operationId);
|
||||
});
|
||||
|
||||
function createFakePostgresClient({ migrationReady = true, countErrorCode = null, readErrorCode = null } = {}) {
|
||||
function createFakePostgresClient({
|
||||
migrationReady = true,
|
||||
migrationErrorCode = null,
|
||||
countErrorCode = null,
|
||||
readErrorCode = null
|
||||
} = {}) {
|
||||
const state = {
|
||||
gateway_sessions: new Map(),
|
||||
box_resources: new Map(),
|
||||
@@ -465,6 +586,11 @@ function createFakePostgresClient({ migrationReady = true, countErrorCode = null
|
||||
return { rows: [{ count: state[table]?.size ?? 0 }] };
|
||||
}
|
||||
if (sql.startsWith("SELECT id, schema_version FROM hwlab_schema_migrations")) {
|
||||
if (migrationErrorCode) {
|
||||
const error = new Error("migration query failed");
|
||||
error.code = migrationErrorCode;
|
||||
throw error;
|
||||
}
|
||||
const row = state.hwlab_schema_migrations.get(params[0]);
|
||||
return { rows: row ? [row] : [] };
|
||||
}
|
||||
|
||||
@@ -16,7 +16,8 @@ import {
|
||||
RUNTIME_DURABLE_ADAPTER_DRIVER_MISSING,
|
||||
RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_QUERY_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED
|
||||
RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED,
|
||||
RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED
|
||||
} from "../../internal/db/runtime-store.mjs";
|
||||
import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs";
|
||||
import { ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs";
|
||||
@@ -572,6 +573,7 @@ function conclusionReadySummary(report) {
|
||||
}
|
||||
|
||||
function blockerScope(blocker) {
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED) return "runtime-migration-ssl";
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED) return "runtime-migration-auth";
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_SCHEMA_BLOCKED) return "runtime-migration-schema";
|
||||
if (blocker === RUNTIME_DURABLE_ADAPTER_MIGRATION_BLOCKED) return "runtime-migration-ledger";
|
||||
@@ -588,6 +590,13 @@ function classifyMigrationError(error) {
|
||||
summary: "Postgres driver is unavailable for DEV runtime migration apply."
|
||||
};
|
||||
}
|
||||
if (isMigrationSslError(error)) {
|
||||
return {
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_SSL_BLOCKED,
|
||||
errorCode,
|
||||
summary: "DEV runtime migration reached Postgres, but SSL negotiation or SSL mode compatibility blocked apply."
|
||||
};
|
||||
}
|
||||
if (["28P01", "28000", "42501"].includes(errorCode)) {
|
||||
return {
|
||||
blocker: RUNTIME_DURABLE_ADAPTER_AUTH_BLOCKED,
|
||||
@@ -609,6 +618,37 @@ function classifyMigrationError(error) {
|
||||
};
|
||||
}
|
||||
|
||||
function isMigrationSslError(error) {
|
||||
const errorCode = typeof error?.code === "string" ? error.code : "";
|
||||
if (errorCode.startsWith("ERR_SSL") || errorCode.startsWith("ERR_TLS")) {
|
||||
return true;
|
||||
}
|
||||
if ([
|
||||
"DEPTH_ZERO_SELF_SIGNED_CERT",
|
||||
"SELF_SIGNED_CERT_IN_CHAIN",
|
||||
"UNABLE_TO_VERIFY_LEAF_SIGNATURE",
|
||||
"UNABLE_TO_GET_ISSUER_CERT_LOCALLY",
|
||||
"CERT_HAS_EXPIRED"
|
||||
].includes(errorCode)) {
|
||||
return true;
|
||||
}
|
||||
const message = typeof error?.message === "string" ? error.message.toLowerCase() : "";
|
||||
return [
|
||||
"does not support ssl",
|
||||
"ssl is not enabled",
|
||||
"ssl negotiation",
|
||||
"ssl off",
|
||||
"ssl required",
|
||||
"requires ssl",
|
||||
"no ssl encryption",
|
||||
"no encryption",
|
||||
"hostssl",
|
||||
"server requires ssl",
|
||||
"before secure tls connection",
|
||||
"tls handshake"
|
||||
].some((pattern) => message.includes(pattern));
|
||||
}
|
||||
|
||||
async function writeReport(report, reportPath, root) {
|
||||
const absolute = path.resolve(root, reportPath);
|
||||
await mkdir(path.dirname(absolute), { recursive: true });
|
||||
|
||||
@@ -55,7 +55,6 @@ test("live dry-run separates migration ledger blocker from auth and schema", asy
|
||||
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: "disable"
|
||||
},
|
||||
queryClient: createFakeQueryClient({ migrationReady: false }),
|
||||
@@ -74,6 +73,73 @@ test("live dry-run separates migration ledger blocker from auth and schema", asy
|
||||
assert.equal(report.runtime.migration.missing, true);
|
||||
});
|
||||
|
||||
test("live dry-run separates SSL negotiation blocker from auth and schema", async () => {
|
||||
const report = await buildDevRuntimeMigrationReport(
|
||||
parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]),
|
||||
{
|
||||
env: {
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "require"
|
||||
},
|
||||
queryClient: {
|
||||
async query(sql) {
|
||||
assert.match(sql, /information_schema\.columns/u);
|
||||
const error = new Error("The server does not support SSL connections");
|
||||
error.code = "08P01";
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
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, "not_checked");
|
||||
assert.equal(report.gates.schema.status, "not_checked");
|
||||
assert.equal(report.gates.migration.status, "not_checked");
|
||||
assert.equal(report.gates.readiness.status, "not_checked");
|
||||
assert.equal(report.blockers[0].scope, "runtime-migration-ssl");
|
||||
assert.equal(report.blockers[0].evidence.blocker, "runtime_durable_adapter_ssl_blocked");
|
||||
assert.equal(report.runtime.blocker, "runtime_durable_adapter_ssl_blocked");
|
||||
assert.equal(report.runtime.connection.queryResult, "ssl_negotiation_blocked");
|
||||
assert.equal(JSON.stringify(report).includes("The server does not support SSL connections"), false);
|
||||
});
|
||||
|
||||
test("live dry-run classifies pg_hba no-encryption rejection as SSL with ssl disabled", async () => {
|
||||
const report = await buildDevRuntimeMigrationReport(
|
||||
parseArgs(["--dry-run", "--allow-live-db-read", "--confirm-dev"]),
|
||||
{
|
||||
env: {
|
||||
HWLAB_CLOUD_DB_SSL_MODE: "disable"
|
||||
},
|
||||
queryClient: {
|
||||
async query(sql) {
|
||||
assert.match(sql, /information_schema\.columns/u);
|
||||
const error = new Error('no pg_hba.conf entry for host "[redacted]", user "[redacted]", database "[redacted]", no encryption');
|
||||
error.code = "28000";
|
||||
throw error;
|
||||
}
|
||||
},
|
||||
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, "not_checked");
|
||||
assert.equal(report.gates.schema.status, "not_checked");
|
||||
assert.equal(report.gates.migration.status, "not_checked");
|
||||
assert.equal(report.gates.readiness.status, "not_checked");
|
||||
assert.equal(report.blockers[0].scope, "runtime-migration-ssl");
|
||||
assert.equal(report.blockers[0].evidence.blocker, "runtime_durable_adapter_ssl_blocked");
|
||||
assert.equal(report.runtime.blocker, "runtime_durable_adapter_ssl_blocked");
|
||||
assert.equal(report.runtime.connection.queryResult, "ssl_negotiation_blocked");
|
||||
assert.equal(report.runtime.connection.errorCode, "28000");
|
||||
assert.equal(JSON.stringify(report).includes("pg_hba.conf"), false);
|
||||
});
|
||||
|
||||
test("apply mode records ledger and verifies durable runtime readiness", async () => {
|
||||
const queryClient = createFakeQueryClient({ migrationReady: false });
|
||||
const report = await buildDevRuntimeMigrationReport(
|
||||
|
||||
Reference in New Issue
Block a user