diff --git a/deploy/README.md b/deploy/README.md index dea6e725..dd6488e5 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,7 +18,7 @@ for future compatibility, but PROD deployment is not part of MVP acceptance. - `hwlab-cloud-api` declares the DEV DB env contract with `HWLAB_CLOUD_DB_URL` from Secret reference `hwlab-cloud-api-dev-db/database-url` and non-secret - `HWLAB_CLOUD_DB_SSL_MODE=require`. Runtime DB readiness dials the redacted + `HWLAB_CLOUD_DB_SSL_MODE=disable`. Runtime DB readiness dials the redacted host parsed from that Secret URL. `cloud-api-db` is only an optional desired alias until Service plus Endpoint/EndpointSlice ownership and rollout/apply contract exist; see diff --git a/deploy/deploy.json b/deploy/deploy.json index d197870b..3c35e001 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -194,7 +194,7 @@ "HWLAB_IMAGE_TAG": "7de6edd", "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy", "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url", - "HWLAB_CLOUD_DB_SSL_MODE": "require", + "HWLAB_CLOUD_DB_SSL_MODE": "disable", "HWLAB_CLOUD_DB_CONTRACT": "dev-redacted-presence-only", "HWLAB_CLOUD_RUNTIME_ADAPTER": "postgres", "HWLAB_CLOUD_RUNTIME_DURABLE": "true", diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index e2f9da5e..96487245 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -365,7 +365,7 @@ }, "HWLAB_CLOUD_DB_SSL_MODE": { "type": "string", - "const": "require" + "const": "disable" }, "HWLAB_CLOUD_DB_CONTRACT": { "type": "string", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 0ec16544..0a9f88fa 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -75,7 +75,7 @@ }, { "name": "HWLAB_CLOUD_DB_SSL_MODE", - "value": "require" + "value": "disable" }, { "name": "HWLAB_CLOUD_DB_CONTRACT", diff --git a/deploy/k8s/dev/health-contract.yaml b/deploy/k8s/dev/health-contract.yaml index cd12da52..22d8bc94 100644 --- a/deploy/k8s/dev/health-contract.yaml +++ b/deploy/k8s/dev/health-contract.yaml @@ -12,7 +12,7 @@ "data": { "endpoint": "http://74.48.78.17:16667", "cloud-api": "GET /health/live via hwlab-edge-proxy then hwlab-cloud-api:6667", - "cloud-api-db": "DEV DB config gate requires HWLAB_CLOUD_DB_URL from Secret hwlab-cloud-api-dev-db/database-url and HWLAB_CLOUD_DB_SSL_MODE=require. Runtime readiness dials the redacted host parsed from the Secret URL and reports endpointSource=secret-url-host. cloud-api-db is an optional desired alias only until this repo owns Service plus Endpoint or EndpointSlice manifests and rollout/apply contract; health reports env presence, env injection, redacted connection attempt/result, and liveConnected without exposing secret values", + "cloud-api-db": "DEV DB config gate requires HWLAB_CLOUD_DB_URL from Secret hwlab-cloud-api-dev-db/database-url and HWLAB_CLOUD_DB_SSL_MODE=disable. Runtime readiness dials the redacted host parsed from the Secret URL and reports endpointSource=secret-url-host. cloud-api-db is an optional desired alias only until this repo owns Service plus Endpoint or EndpointSlice manifests and rollout/apply contract; health reports env presence, env injection, redacted connection attempt/result, and liveConnected without exposing secret values", "cloud-web": "GET /health/live on hwlab-cloud-web:8080; consumes cloud-api only", "agent": "hwlab-agent-mgr readiness gates worker template creation; hwlab-agent-worker is suspended until a session is scheduled", "sim": "hwlab-gateway-simu:7101 and hwlab-box-simu:7201 expose /health/live for DEV smoke", diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index 6bc396e7..02d5a530 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -289,7 +289,7 @@ unchanged. `hwlab-cloud-api` has a DEV DB env contract placeholder: `HWLAB_CLOUD_DB_URL` must come from Secret reference `hwlab-cloud-api-dev-db/database-url`, and -`HWLAB_CLOUD_DB_SSL_MODE=require`. Runtime readiness dials the redacted host +`HWLAB_CLOUD_DB_SSL_MODE=disable`. Runtime readiness dials the redacted host parsed from that Secret URL. `cloud-api-db` remains an optional desired alias, not a readiness gate or TCP probe target, until this repo owns its Service/Endpoint rollout contract. The local health gate reports DB runtime diff --git a/docs/dev-edge-health.md b/docs/dev-edge-health.md index fbb9b27a..ee1dece9 100644 --- a/docs/dev-edge-health.md +++ b/docs/dev-edge-health.md @@ -35,7 +35,7 @@ Required DEV DB config names: - `HWLAB_CLOUD_DB_URL` from Secret reference `hwlab-cloud-api-dev-db/database-url`. -- `HWLAB_CLOUD_DB_SSL_MODE=require`. +- `HWLAB_CLOUD_DB_SSL_MODE=disable`. - Runtime TCP dial target: the redacted host parsed from `HWLAB_CLOUD_DB_URL`, reported as `endpointSource="secret-url-host"`. diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index 00b2b671..81c56e1f 100644 --- a/docs/dev-gate-preflight.md +++ b/docs/dev-gate-preflight.md @@ -70,7 +70,7 @@ The preflight checks: - `hwlab-cloud-api` declares the DEV DB env contract: `HWLAB_CLOUD_DB_URL` from Secret reference `hwlab-cloud-api-dev-db/database-url` and - `HWLAB_CLOUD_DB_SSL_MODE=require`. Runtime readiness dials the redacted host + `HWLAB_CLOUD_DB_SSL_MODE=disable`. Runtime readiness dials the redacted host 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. diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs index ff75367c..eea3fa3a 100644 --- a/internal/cloud/db-contract.mjs +++ b/internal/cloud/db-contract.mjs @@ -40,7 +40,7 @@ export const DEV_DB_ENV_CONTRACT = Object.freeze({ "schema" ]), nonSecretDefaults: Object.freeze({ - HWLAB_CLOUD_DB_SSL_MODE: "require" + HWLAB_CLOUD_DB_SSL_MODE: "disable" }), forbidden: Object.freeze({ prodAllowed: false, diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 6990737c..bb89f2df 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -122,7 +122,7 @@ test("cloud api health reports DB env presence and live connection classificatio await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve)); const dbPort = fakeDb.address().port; process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test@127.0.0.1:${dbPort}/hwlab`; - process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; + process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable"; delete process.env.HWLAB_CLOUD_DB_SERVICE_NAME; delete process.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE; delete process.env.HWLAB_CLOUD_DB_HOST; @@ -213,7 +213,7 @@ test("cloud api health separates DB connected from durable runtime schema readin await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve)); const dbPort = fakeDb.address().port; process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test@127.0.0.1:${dbPort}/hwlab`; - process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; + process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable"; const server = createCloudApiServer({ runtimeStore: { @@ -302,7 +302,7 @@ test("cloud api health keeps DB live evidence separate when durable adapter quer await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve)); const dbPort = fakeDb.address().port; process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test:password@127.0.0.1:${dbPort}/hwlab`; - process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; + process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable"; const server = createCloudApiServer({ runtimeStore: { @@ -404,7 +404,7 @@ test("cloud api health does not treat DB env presence-only as live readiness", a const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; const originalProbeDisabled = process.env.HWLAB_CLOUD_DB_PROBE_DISABLED; process.env.HWLAB_CLOUD_DB_URL = "postgres://user:password@db.example.invalid:5432/hwlab"; - process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; + process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable"; delete process.env.HWLAB_CLOUD_DB_PROBE_DISABLED; const server = createCloudApiServer(); diff --git a/scripts/deploy-desired-state-plan.test.mjs b/scripts/deploy-desired-state-plan.test.mjs index 4e680a61..80a6b977 100644 --- a/scripts/deploy-desired-state-plan.test.mjs +++ b/scripts/deploy-desired-state-plan.test.mjs @@ -18,13 +18,17 @@ async function makeFixture({ deployEnvMirrors = true, deploySkillsCommitId = null, deployCodeAgentProviderEnv = true, + deployDbEnv = true, + deployDbSslMode = "disable", workloadTag = commitId, workloadEnvCommitId = commitId, workloadEnvImageTag = commitId, workloadEnvImage = null, workloadEnvMirrors = true, workloadSkillsCommitId = null, - workloadCodeAgentProviderEnv = true + workloadCodeAgentProviderEnv = true, + workloadDbEnv = true, + workloadDbSslMode = "disable" } = {}) { const root = await mkdtemp(path.join(os.tmpdir(), "hwlab-desired-state-")); await mkdir(path.join(root, "deploy/k8s/base"), { recursive: true }); @@ -59,6 +63,28 @@ async function makeFixture({ } ] : []; + const dbEnv = + serviceId === "hwlab-cloud-api" && deployDbEnv + ? { + HWLAB_CLOUD_DB_URL: "secretRef:hwlab-cloud-api-dev-db/database-url", + HWLAB_CLOUD_DB_SSL_MODE: deployDbSslMode + } + : {}; + const workloadDbEntries = + serviceId === "hwlab-cloud-api" && workloadDbEnv + ? [ + { + name: "HWLAB_CLOUD_DB_URL", + valueFrom: { + secretKeyRef: { + name: "hwlab-cloud-api-dev-db", + key: "database-url" + } + } + }, + { name: "HWLAB_CLOUD_DB_SSL_MODE", value: workloadDbSslMode } + ] + : []; const deploy = { manifestVersion: "v1", environment: "dev", @@ -77,6 +103,7 @@ async function makeFixture({ HWLAB_IMAGE: deployEnvImage ?? image, HWLAB_IMAGE_TAG: deployEnvImageTag, ...(deploySkillsCommitId ? { HWLAB_SKILLS_COMMIT_ID: deploySkillsCommitId } : {}), + ...dbEnv, ...providerEnv } : {} @@ -136,6 +163,7 @@ async function makeFixture({ { name: "HWLAB_IMAGE", value: workloadEnvImage ?? image }, { name: "HWLAB_IMAGE_TAG", value: workloadEnvImageTag }, ...(workloadSkillsCommitId ? [{ name: "HWLAB_SKILLS_COMMIT_ID", value: workloadSkillsCommitId }] : []), + ...workloadDbEntries, ...workloadProviderEnv ] : [] @@ -174,12 +202,30 @@ test("passes when cloud-api preserves provider env and DEV egress contract", asy const repoRoot = await makeFixture(); const plan = await buildDesiredStatePlan({ repoRoot }); assert.equal(plan.status, "pass"); + assert.equal(plan.cloudApiDb.ready, true); + assert.equal(plan.cloudApiDb.sslMode.expected, "disable"); + assert.equal(plan.cloudApiDb.sslMode.deployMatches, true); + assert.equal(plan.cloudApiDb.sslMode.workloadMatches, true); assert.equal(plan.codeAgentProvider.ready, true); assert.equal(plan.codeAgentProvider.secretRef.present, true); assert.equal(plan.codeAgentProvider.egress.deployMatchesDevProxy, true); assert.deepEqual(plan.diagnostics, []); }); +test("blocks when cloud-api DB SSL mode drifts back to require", async () => { + const repoRoot = await makeFixture({ + deployDbSslMode: "require", + workloadDbSslMode: "require" + }); + const plan = await buildDesiredStatePlan({ repoRoot }); + assert.equal(plan.status, "blocked"); + assert.equal(plan.cloudApiDb.ready, false); + assert.equal(plan.cloudApiDb.sslMode.expected, "disable"); + assert.equal(plan.cloudApiDb.sslMode.deployMatches, false); + assert.equal(plan.cloudApiDb.sslMode.workloadMatches, false); + assert.ok(plan.diagnostics.some((diagnostic) => diagnostic.code === "cloud_api_db_contract_mismatch")); +}); + test("blocks when cloud-web runtime identity mirrors are missing", async () => { const repoRoot = await makeFixture({ serviceId: "hwlab-cloud-web", diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index 0fcc4f87..26821d64 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -192,7 +192,13 @@ function validateSource(ctx, manifest) { function validateCloudApiDbSource(ctx, env) { const alias = DEV_DB_ENV_CONTRACT.dns; expectEqual(ctx, env.HWLAB_CLOUD_DB_URL, "secretRef:hwlab-cloud-api-dev-db/database-url", "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_URL", "cloud API DB URL Secret reference"); - expectEqual(ctx, env.HWLAB_CLOUD_DB_SSL_MODE, "require", "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SSL_MODE", "cloud API DB SSL mode"); + expectEqual( + ctx, + env.HWLAB_CLOUD_DB_SSL_MODE, + DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE, + "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SSL_MODE", + "cloud API DEV DB SSL mode" + ); expectEqual(ctx, env.HWLAB_CLOUD_DB_CONTRACT, "dev-redacted-presence-only", "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_CONTRACT", "cloud API DB redacted contract marker"); expectEqual(ctx, DEV_DB_ENV_CONTRACT.endpointAuthority.source, "secret-url-host", "internal/cloud/db-contract.mjs.endpointAuthority.source", "cloud API DB runtime authority source"); expectEqual(ctx, DEV_DB_ENV_CONTRACT.endpointAuthority.env, "HWLAB_CLOUD_DB_URL", "internal/cloud/db-contract.mjs.endpointAuthority.env", "cloud API DB runtime authority env"); @@ -343,6 +349,13 @@ function validateCloudApiDbOptionalAliasArtifacts(ctx, services, workloads) { expectEqual(ctx, service, undefined, "deploy/k8s/base/services.yaml.cloud-api-db", "cloud-api-db remains optional until Service/Endpoint ownership and rollout contract exist"); const container = mapByServiceId(listItems(workloads)).get("hwlab-cloud-api")?.spec?.template?.spec?.containers?.[0]; const env = new Map((container?.env ?? []).map((entry) => [entry.name, entry.value])); + expectEqual( + ctx, + env.get("HWLAB_CLOUD_DB_SSL_MODE"), + DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE, + "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SSL_MODE", + "cloud API workload DEV DB SSL mode" + ); for (const name of [ "HWLAB_CLOUD_DB_SERVICE_NAME", "HWLAB_CLOUD_DB_SERVICE_NAMESPACE", diff --git a/scripts/src/deploy-desired-state-plan.mjs b/scripts/src/deploy-desired-state-plan.mjs index 4e19e95f..a567b53f 100644 --- a/scripts/src/deploy-desired-state-plan.mjs +++ b/scripts/src/deploy-desired-state-plan.mjs @@ -8,6 +8,7 @@ import { DEV_CODE_AGENT_PROVIDER_CONTRACT, inspectCodeAgentProviderManifestRefs } from "../../internal/cloud/code-agent-contract.mjs"; +import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs"; const execFileAsync = promisify(execFile); const defaultRepoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -329,6 +330,100 @@ function inspectCodeAgentProviderDesiredState(ctx, deployService, workloadRecord return inspection; } +function dbSecretRefPlaceholder() { + const ref = DEV_DB_ENV_CONTRACT.secretRefs[0]; + return `secretRef:${ref.secretName}/${ref.secretKey}`; +} + +function secretRefMatches(entry, ref) { + return ( + entry?.valueFrom?.secretKeyRef?.name === ref.secretName && + entry?.valueFrom?.secretKeyRef?.key === ref.secretKey + ); +} + +function inspectCloudApiDbDesiredState(ctx, deployService, workloadRecords) { + const workloadRecord = workloadRecords.find((workload) => workload.containerName === "hwlab-cloud-api") ?? workloadRecords[0] ?? null; + const deployEnv = deployService?.env ?? {}; + const workloadEnv = workloadRecord?.env ?? {}; + const secretRef = DEV_DB_ENV_CONTRACT.secretRefs[0]; + const expectedSecretRef = dbSecretRefPlaceholder(); + const expectedSslMode = DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE; + const deploySecretMatches = deployEnv.HWLAB_CLOUD_DB_URL === expectedSecretRef; + const workloadSecretMatches = secretRefMatches(workloadEnv.HWLAB_CLOUD_DB_URL, secretRef); + const deploySslMatches = deployEnv.HWLAB_CLOUD_DB_SSL_MODE === expectedSslMode; + const workloadSslMatches = workloadEnv.HWLAB_CLOUD_DB_SSL_MODE === expectedSslMode; + const ready = deploySecretMatches && workloadSecretMatches && deploySslMatches && workloadSslMatches; + + if (!ready) { + addDiagnostic(ctx, { + code: "cloud_api_db_contract_mismatch", + path: "deploy.cloudApiDb", + message: "hwlab-cloud-api desired state must preserve DEV DB Secret ref and non-SSL runtime mode", + expected: { + secretRef: `${secretRef.secretName}/${secretRef.secretKey}`, + sslMode: expectedSslMode + }, + actual: { + deploySecretRef: deploySecretMatches ? "secretRef:declared" : "missing_or_mismatched", + workloadSecretRef: workloadSecretMatches ? "secretRef:declared" : "missing_or_mismatched", + deploySslMode: deployEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null, + workloadSslMode: workloadEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null + }, + secretMaterialRead: false, + valuesRedacted: true + }); + } + + recordObservation(ctx, { + source: "deploy", + serviceId: "hwlab-cloud-api", + path: `${deployPath}.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SSL_MODE`, + field: "HWLAB_CLOUD_DB_SSL_MODE", + kind: "runtime-config", + value: deployEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null, + expected: expectedSslMode + }); + recordObservation(ctx, { + source: "workload", + serviceId: "hwlab-cloud-api", + path: `${workloadsPath}.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SSL_MODE`, + field: "HWLAB_CLOUD_DB_SSL_MODE", + kind: "runtime-config", + value: workloadEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null, + expected: expectedSslMode + }); + + return { + contractVersion: DEV_DB_ENV_CONTRACT.contractVersion, + environment: DEV_DB_ENV_CONTRACT.environment, + status: ready ? "pass" : "blocked", + ready, + secretRef: { + env: secretRef.env, + secretName: secretRef.secretName, + secretKey: secretRef.secretKey, + deployPlaceholderPresent: deploySecretMatches, + workloadSecretRefPresent: workloadSecretMatches, + present: deploySecretMatches && workloadSecretMatches, + redacted: true + }, + sslMode: { + env: "HWLAB_CLOUD_DB_SSL_MODE", + value: deployEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null, + workloadValue: workloadEnv.HWLAB_CLOUD_DB_SSL_MODE ?? null, + expected: expectedSslMode, + deployMatches: deploySslMatches, + workloadMatches: workloadSslMatches, + secret: false + }, + secretMaterialRead: false, + valuesRedacted: true, + liveDbEvidence: false, + fixtureEvidence: false + }; +} + function indexWorkloads(workloads) { const byService = new Map(); for (const item of listItems(workloads)) { @@ -616,6 +711,7 @@ export async function buildDesiredStatePlan(options = {}) { const services = []; let codeAgentProvider = null; + let cloudApiDb = null; for (const serviceId of serviceIds) { const deployService = deployByService.get(serviceId); const catalogService = catalogByService.get(serviceId); @@ -774,6 +870,7 @@ export async function buildDesiredStatePlan(options = {}) { services.push(service); if (serviceId === "hwlab-cloud-api") { + cloudApiDb = inspectCloudApiDbDesiredState(ctx, deployService, workloadsForService); codeAgentProvider = inspectCodeAgentProviderDesiredState(ctx, deployService, workloadsForService); } } @@ -838,6 +935,13 @@ export async function buildDesiredStatePlan(options = {}) { nonAuthoritativeEvidence: [artifactReportPath], note: "This planner reviews source desired-state only. It does not prove image existence, registry reachability, a real DEV apply, or M3 DEV-LIVE hardware-loop evidence." }, + cloudApiDb: cloudApiDb ?? { + status: "blocked", + ready: false, + secretMaterialRead: false, + valuesRedacted: true, + liveDbEvidence: false + }, codeAgentProvider: codeAgentProvider ?? { status: "blocked", ready: false, diff --git a/scripts/src/dev-deploy-apply.mjs b/scripts/src/dev-deploy-apply.mjs index 6d8a1059..86243d6f 100755 --- a/scripts/src/dev-deploy-apply.mjs +++ b/scripts/src/dev-deploy-apply.mjs @@ -1133,14 +1133,33 @@ async function checkCloudApiDb(deploy, workloads, services, blockers) { const missingSecretRefs = requiredEnv .filter((env) => env.secretRef && !env.secretRef.present) .map((env) => `${env.secretRef.secretName}/${env.secretRef.secretKey}`); + const expectedSslMode = DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE; + const sslModeMatches = + deployEnv.HWLAB_CLOUD_DB_SSL_MODE === expectedSslMode && + workloadEnv.get("HWLAB_CLOUD_DB_SSL_MODE")?.value === expectedSslMode; + const mismatchedNonSecretEnv = sslModeMatches + ? [] + : [`HWLAB_CLOUD_DB_SSL_MODE must be ${expectedSslMode}`]; const dnsContract = inspectCloudApiDbOptionalAliasContract(deployEnv, workloadEnv, services); const missingDnsContract = dnsContract.missing; - if (missingManifest.length > 0 || missingK8s.length > 0 || missingSecretRefs.length > 0 || missingDnsContract.length > 0) { + if ( + missingManifest.length > 0 || + missingK8s.length > 0 || + missingSecretRefs.length > 0 || + mismatchedNonSecretEnv.length > 0 || + missingDnsContract.length > 0 + ) { addBlocker( blockers, "runtime_blocker", "cloud-api-db-config", - `cloud-api DEV DB env/authority contract is incomplete; missing ${[...missingManifest, ...missingK8s, ...missingSecretRefs, ...missingDnsContract].join(", ")}` + `cloud-api DEV DB env/authority contract is incomplete; missing ${[ + ...missingManifest, + ...missingK8s, + ...missingSecretRefs, + ...mismatchedNonSecretEnv, + ...missingDnsContract + ].join(", ")}` ); } @@ -1148,6 +1167,7 @@ async function checkCloudApiDb(deploy, workloads, services, blockers) { missingManifest.length === 0 && missingK8s.length === 0 && missingSecretRefs.length === 0 && + mismatchedNonSecretEnv.length === 0 && missingDnsContract.length === 0; const health = { status: configReady ? "degraded" : "blocked", @@ -1181,6 +1201,7 @@ async function checkCloudApiDb(deploy, workloads, services, blockers) { missingManifest, missingK8s, missingSecretRefs, + mismatchedNonSecretEnv, missingDnsContract, dns: dnsContract, runtimeHealth: summarizeDbContract(health), diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index 58e339e6..47d1273d 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -834,20 +834,21 @@ function validateCloudApiDbContract(reporter, deploy, workloads, services) { ...staticContract.missingDeployEnv, ...staticContract.missingK8sEnv, ...staticContract.missingSecretRefs, + ...staticContract.mismatchedNonSecretEnv, ...staticContract.missingDnsContract ]; reporter.check( "cloud-api-db-env-contract", "db", "blocked", - `cloud-api DEV DB manifest is missing ${missing.join(", ")}.`, + `cloud-api DEV DB manifest contract is blocked by ${missing.join(", ")}.`, evidence ); reporter.block({ type: "contract_blocker", scope: "cloud-api-db-env-contract", - summary: `cloud-api DEV DB contract is incomplete; missing ${missing.join(", ")}.`, - nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so they expose the required DB env names and redacted Secret reference. Do not make cloud-api-db a hard readiness gate unless this repo also owns its Service/Endpoint rollout contract." + summary: `cloud-api DEV DB contract is incomplete; blocked by ${missing.join(", ")}.`, + nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so they expose the required DB env names, redacted Secret reference, and HWLAB_CLOUD_DB_SSL_MODE=disable. Do not make cloud-api-db a hard readiness gate unless this repo also owns its Service/Endpoint rollout contract." }); } @@ -873,7 +874,7 @@ function validateCloudApiDbContract(reporter, deploy, workloads, services) { type: "runtime_blocker", scope: "cloud-api-db-health-gate", summary: `cloud-api DB runtime env is not ready; missing ${healthContract.missingEnv.join(", ")}.`, - nextTask: "Configure DEV hwlab-cloud-api with Secret hwlab-cloud-api-dev-db/database-url and HWLAB_CLOUD_DB_SSL_MODE=require, then rerun health/preflight without printing the secret value." + nextTask: "Configure DEV hwlab-cloud-api with Secret hwlab-cloud-api-dev-db/database-url and HWLAB_CLOUD_DB_SSL_MODE=disable, then rerun health/preflight without printing the secret value." }); } @@ -914,6 +915,13 @@ function inspectCloudApiDbStaticContract(deploy, workloads, services) { const missingSecretRefs = fields .filter((field) => field.secretRef && !field.secretRef.present) .map((field) => `${field.secretRef.secretName}/${field.secretRef.secretKey}`); + const expectedSslMode = DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE; + const sslModeMatches = + deployEnv.HWLAB_CLOUD_DB_SSL_MODE === expectedSslMode && + workloadEnv.get("HWLAB_CLOUD_DB_SSL_MODE")?.value === expectedSslMode; + const mismatchedNonSecretEnv = sslModeMatches + ? [] + : [`HWLAB_CLOUD_DB_SSL_MODE must be ${expectedSslMode}`]; const dnsContract = inspectCloudApiDbOptionalAliasContract(deployEnv, workloadEnv, services); return { @@ -923,12 +931,14 @@ function inspectCloudApiDbStaticContract(deploy, workloads, services) { missingDeployEnv.length === 0 && missingK8sEnv.length === 0 && missingSecretRefs.length === 0 && + mismatchedNonSecretEnv.length === 0 && dnsContract.ready, requiredEnv: fields, dns: dnsContract, missingDeployEnv, missingK8sEnv, missingSecretRefs, + mismatchedNonSecretEnv, missingDnsContract: dnsContract.missing, secretMaterialRead: false, valuesRedacted: true, diff --git a/scripts/src/dev-runtime-migration.test.mjs b/scripts/src/dev-runtime-migration.test.mjs index f4a006da..f9e1283d 100644 --- a/scripts/src/dev-runtime-migration.test.mjs +++ b/scripts/src/dev-runtime-migration.test.mjs @@ -15,7 +15,7 @@ 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" + HWLAB_CLOUD_DB_SSL_MODE: "disable" }, now: () => "2026-05-22T00:00:00.000Z" }); @@ -38,7 +38,7 @@ test("apply mode refuses missing DEV confirmation before touching query client", const report = await buildDevRuntimeMigrationReport(parseArgs(["--apply"]), { env: { HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", - HWLAB_CLOUD_DB_SSL_MODE: "require" + HWLAB_CLOUD_DB_SSL_MODE: "disable" }, queryClient, now: () => "2026-05-22T00:00:00.000Z" @@ -56,7 +56,7 @@ test("live dry-run separates migration ledger blocker from auth and schema", asy { env: { HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", - HWLAB_CLOUD_DB_SSL_MODE: "require" + HWLAB_CLOUD_DB_SSL_MODE: "disable" }, queryClient: createFakeQueryClient({ migrationReady: false }), now: () => "2026-05-22T00:00:00.000Z" @@ -81,7 +81,7 @@ test("apply mode records ledger and verifies durable runtime readiness", async ( { env: { HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", - HWLAB_CLOUD_DB_SSL_MODE: "require" + HWLAB_CLOUD_DB_SSL_MODE: "disable" }, queryClient, now: () => "2026-05-22T00:00:00.000Z" @@ -112,7 +112,7 @@ test("live dry-run closes pg pool after readiness verification", async () => { { env: { HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", - HWLAB_CLOUD_DB_SSL_MODE: "require" + HWLAB_CLOUD_DB_SSL_MODE: "disable" }, pgModuleLoader: async () => createFakePgModule({ backingClient, pools }), now: () => "2026-05-22T00:00:00.000Z" @@ -133,7 +133,7 @@ test("apply mode uses a dedicated pg client transaction", async () => { { env: { HWLAB_CLOUD_DB_URL: "postgresql://hwlab:redacted@example.invalid:5432/hwlab", - HWLAB_CLOUD_DB_SSL_MODE: "require" + HWLAB_CLOUD_DB_SSL_MODE: "disable" }, pgModuleLoader: async () => createFakePgModule({ backingClient, pools }), now: () => "2026-05-22T00:00:00.000Z" diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index 79ec9dd7..8c093a59 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -170,10 +170,19 @@ assert.equal(cloudApi.env.HWLAB_COMMIT_ID, deployManifest.commitId, "cloud-api h assert.equal(cloudApi.env.HWLAB_IMAGE, cloudApi.image, "cloud-api health image evidence"); assert.equal(cloudApi.env.HWLAB_IMAGE_TAG, deployManifest.commitId.slice(0, 7), "cloud-api health image tag evidence"); assert.equal(cloudApi.env.HWLAB_CLOUD_DB_URL, "secretRef:hwlab-cloud-api-dev-db/database-url", "cloud-api DB URL must be a Secret reference placeholder"); -assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SSL_MODE, "require", "cloud-api DB SSL mode"); +assert.equal( + cloudApi.env.HWLAB_CLOUD_DB_SSL_MODE, + DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE, + "cloud-api DEV DB SSL mode" +); assert.equal(cloudApi.env.HWLAB_CLOUD_DB_CONTRACT, "dev-redacted-presence-only", "cloud-api DB contract marker"); assert.equal(cloudApi.env.HWLAB_CLOUD_RUNTIME_ADAPTER, "postgres", "cloud-api runtime durable adapter"); assert.equal(cloudApi.env.HWLAB_CLOUD_RUNTIME_DURABLE, "true", "cloud-api runtime durable flag"); +assert.equal( + cloudApiWorkloadEnv.HWLAB_CLOUD_DB_SSL_MODE?.value, + DEV_DB_ENV_CONTRACT.nonSecretDefaults.HWLAB_CLOUD_DB_SSL_MODE, + "cloud-api workload DEV DB SSL mode" +); assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_RUNTIME_ADAPTER?.value, "postgres", "cloud-api workload runtime durable adapter"); assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_RUNTIME_DURABLE?.value, "true", "cloud-api workload runtime durable flag"); assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SERVICE_NAME, undefined, "cloud-api DB alias service name is optional");