diff --git a/deploy/README.md b/deploy/README.md index 19546c1a..9dcb23a0 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,9 +18,10 @@ 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`. It also declares the non-secret DB DNS - contract `cloud-api-db.hwlab-dev.svc.cluster.local:5432`, backed by - selectorless `Service/cloud-api-db` in `hwlab-dev`; see + `HWLAB_CLOUD_DB_SSL_MODE=require`. 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 [docs/reference/dev-runtime-boundary.md](../docs/reference/dev-runtime-boundary.md). The repository records names only, never secret values or a live DB connection string; runtime health reports redacted env injection and DB diff --git a/deploy/deploy.json b/deploy/deploy.json index 0cf4fb2f..c8ea8b94 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -196,10 +196,6 @@ "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url", "HWLAB_CLOUD_DB_SSL_MODE": "require", "HWLAB_CLOUD_DB_CONTRACT": "dev-redacted-presence-only", - "HWLAB_CLOUD_DB_SERVICE_NAME": "cloud-api-db", - "HWLAB_CLOUD_DB_SERVICE_NAMESPACE": "hwlab-dev", - "HWLAB_CLOUD_DB_HOST": "cloud-api-db.hwlab-dev.svc.cluster.local", - "HWLAB_CLOUD_DB_PORT": "5432", "HWLAB_CLOUD_RUNTIME_ADAPTER": "postgres", "HWLAB_CLOUD_RUNTIME_DURABLE": "true", "HWLAB_CODE_AGENT_PROVIDER": "openai", diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index f472b4f4..e2f9da5e 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -371,22 +371,6 @@ "type": "string", "const": "dev-redacted-presence-only" }, - "HWLAB_CLOUD_DB_SERVICE_NAME": { - "type": "string", - "const": "cloud-api-db" - }, - "HWLAB_CLOUD_DB_SERVICE_NAMESPACE": { - "type": "string", - "const": "hwlab-dev" - }, - "HWLAB_CLOUD_DB_HOST": { - "type": "string", - "const": "cloud-api-db.hwlab-dev.svc.cluster.local" - }, - "HWLAB_CLOUD_DB_PORT": { - "type": "string", - "const": "5432" - }, "HWLAB_CLOUD_RUNTIME_ADAPTER": { "type": "string", "const": "postgres" diff --git a/deploy/k8s/base/services.yaml b/deploy/k8s/base/services.yaml index 555b6428..0374249e 100644 --- a/deploy/k8s/base/services.yaml +++ b/deploy/k8s/base/services.yaml @@ -27,29 +27,6 @@ ] } }, - { - "apiVersion": "v1", - "kind": "Service", - "metadata": { - "name": "cloud-api-db", - "namespace": "hwlab-dev", - "labels": { - "app.kubernetes.io/name": "cloud-api-db", - "hwlab.pikastech.local/component": "cloud-api-db", - "hwlab.pikastech.local/source-contract": "db-dns" - } - }, - "spec": { - "type": "ClusterIP", - "ports": [ - { - "name": "postgres", - "port": 5432, - "targetPort": "postgres" - } - ] - } - }, { "apiVersion": "v1", "kind": "Service", diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index f0195435..85e7a41a 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -81,22 +81,6 @@ "name": "HWLAB_CLOUD_DB_CONTRACT", "value": "dev-redacted-presence-only" }, - { - "name": "HWLAB_CLOUD_DB_SERVICE_NAME", - "value": "cloud-api-db" - }, - { - "name": "HWLAB_CLOUD_DB_SERVICE_NAMESPACE", - "value": "hwlab-dev" - }, - { - "name": "HWLAB_CLOUD_DB_HOST", - "value": "cloud-api-db.hwlab-dev.svc.cluster.local" - }, - { - "name": "HWLAB_CLOUD_DB_PORT", - "value": "5432" - }, { "name": "HWLAB_CLOUD_RUNTIME_ADAPTER", "value": "postgres" diff --git a/deploy/k8s/dev/health-contract.yaml b/deploy/k8s/dev/health-contract.yaml index 0869f425..cd12da52 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, HWLAB_CLOUD_DB_SSL_MODE=require, and stable internal DNS cloud-api-db.hwlab-dev.svc.cluster.local:5432 backed by Service cloud-api-db in hwlab-dev; 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=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-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/cloud-api-runtime.md b/docs/cloud-api-runtime.md index 21c1459e..42166b71 100644 --- a/docs/cloud-api-runtime.md +++ b/docs/cloud-api-runtime.md @@ -61,14 +61,20 @@ If both env vars are present, cloud-api attempts a short read-only TCP connection to the DB endpoint from the injected runtime env. The health payload keeps all endpoint details redacted and reports only: -The source-controlled DEV DNS contract for that endpoint is -`cloud-api-db.hwlab-dev.svc.cluster.local:5432`. The actual connection string -still comes from `hwlab-cloud-api-dev-db/database-url`; source records only the -non-secret host/service/namespace/port contract. +The runtime DB readiness authority is the host parsed from the injected +`HWLAB_CLOUD_DB_URL` Secret value. Health reports this as +`db.endpointSource: "secret-url-host"` without printing the host, username, +password, or full URL. The `cloud-api-db` name is only an optional future alias; +it is not the TCP probe target and a missing alias must not reverse-fail live DB +success from the Secret URL host. `.invalid` hosts, including `hwlab-dev-db.invalid`, are forbidden as DEV runtime targets and may be used only as negative fixtures. - `db.configReady`: required env names are present. +- `db.endpointSource`: `secret-url-host`, the authoritative redacted TCP dial + target parsed from `HWLAB_CLOUD_DB_URL`. +- `db.optionalPublicDnsAlias`: diagnostic metadata for the non-authoritative + `cloud-api-db` alias; `requiredForReadiness` and `usedForProbe` stay false. - `db.connectionAttempted`: the runtime tried the live connection path. - `db.connectionResult`: redacted classifier such as `connected`, `refused`, `timeout`, `dns_error`, `invalid_url`, or `unsupported_protocol`. diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index ba6e5cbb..b6e2dd11 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -228,10 +228,11 @@ 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`. The non-secret DB DNS contract is -`cloud-api-db.hwlab-dev.svc.cluster.local:5432` via selectorless -`Service/cloud-api-db` in `hwlab-dev`. The local health gate reports DB -runtime config without printing the connection string. +`HWLAB_CLOUD_DB_SSL_MODE=require`. 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 +config without printing the connection string. No PROD resource, secret value, UniDesk runtime substitute, browser e2e, heavyweight e2e, or force push is part of this flow. diff --git a/docs/dev-edge-health.md b/docs/dev-edge-health.md index 41cdcb9b..fbb9b27a 100644 --- a/docs/dev-edge-health.md +++ b/docs/dev-edge-health.md @@ -26,17 +26,23 @@ forwarding checks. `db` in the cloud-api health payload is a redacted configuration gate. It reports required env names, presence, missing names, and Secret reference names only. Runtime health additionally reports redacted DB readiness layers: -`configReady`, `envInjected`, `connectionAttempted`, `connectionResult`, and -`liveConnected`. It must not expose a connection string, token, password, DB -host, username, or claim fixture output as live DB evidence. +`configReady`, `envInjected`, `endpointSource`, `connectionAttempted`, +`connectionResult`, and `liveConnected`. It must not expose a connection +string, token, password, DB host, username, or claim fixture output as live DB +evidence. 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`. -- Stable k3s DB DNS: `cloud-api-db.hwlab-dev.svc.cluster.local:5432`, - backed by selectorless `Service/cloud-api-db` in `hwlab-dev`. +- Runtime TCP dial target: the redacted host parsed from + `HWLAB_CLOUD_DB_URL`, reported as `endpointSource="secret-url-host"`. + +`cloud-api-db` is only an optional desired alias. Until this repo owns the +Service plus Endpoint or EndpointSlice manifests and rollout/apply contract, it +is not a readiness authority, is not the TCP probe target, and a missing alias +must not reverse-fail live DB success from the Secret URL host. When the Secret/env is absent, the health JSON should keep `db.connected: false`, set `db.status: "blocked"`, and list the missing env @@ -46,7 +52,8 @@ present but no live DB probe is attempted, the gate remains blocked with `liveConnected` is false, the gate remains blocked/degraded with the redacted connection classifier. `connectionResult: "dns_error"` with `classification: "dns_resolution_failed"` means DB endpoint DNS failed from the -cloud-api runtime. Env presence alone is not live DB evidence. +cloud-api runtime for the Secret URL host. Env presence alone is not live DB +evidence. The Code Agent provider Secret for #143 is a separate blocker. The edge-health report may record only key-presence evidence for diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index 9671fba8..17b5301b 100644 --- a/docs/dev-gate-preflight.md +++ b/docs/dev-gate-preflight.md @@ -67,9 +67,10 @@ 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`. The non-secret DB DNS contract is - `cloud-api-db.hwlab-dev.svc.cluster.local:5432` via selectorless - `Service/cloud-api-db` in `hwlab-dev`. + `HWLAB_CLOUD_DB_SSL_MODE=require`. 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. - The local cloud-api health payload reports DB env presence/missing status with redacted values only. This is a readiness gate, not live DB evidence. - `deploy/frp` and `deploy/master-edge` describe the D601-to-master DEV route @@ -114,6 +115,7 @@ rerunning the preflight. The DB gate has two common blocked scopes: - `cloud-api-db-env-contract`: manifest/Kubernetes placeholders are incomplete. + The optional `cloud-api-db` alias is not part of this readiness gate. - `cloud-api-db-health-gate`: runtime env presence is missing. The next task is to configure the DEV Secret/env names, not to print or commit any secret value. diff --git a/docs/reference/README.md b/docs/reference/README.md index 216488e3..abe80c48 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -19,7 +19,7 @@ | 用户反馈分流规则 | [user-feedback-triage.md](user-feedback-triage.md) | | 文档治理与 docs-spec 入库规则 | [documentation-governance.md](documentation-governance.md) | | 架构和 M3 上位约束 | [architecture.md](architecture.md) | -| DEV 运行态、端口、k3s、DB DNS 和环境边界 | [dev-runtime-boundary.md](dev-runtime-boundary.md) | +| DEV 运行态、端口、k3s、DB readiness 和环境边界 | [dev-runtime-boundary.md](dev-runtime-boundary.md) | | 部署正规化、artifact 发布、回滚和 Cloud Web rollout | [deployment-publish.md](deployment-publish.md) | | Cloud Workbench 默认界面和 UX 边界 | [cloud-workbench.md](cloud-workbench.md) | | Code Agent chat 同源通道 readiness 与真实回复判定 | [code-agent-chat-readiness.md](code-agent-chat-readiness.md) | diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index 054c98b9..ef3e7ecb 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -54,35 +54,49 @@ node scripts/d601-k3s-readonly-observability.mjs The preflight must not print secret values, kubeconfig material, ConfigMap values, or token content. -## Cloud API DB DNS Contract +## Cloud API DB Readiness Authority `hwlab-cloud-api` reads the DEV database connection string from Secret reference `hwlab-cloud-api-dev-db/database-url`. The Secret value is not stored in source, not printed by reports, and not required for offline validation. -The non-secret DNS contract is source-controlled: +Runtime DB readiness uses the redacted host parsed from the injected +`HWLAB_CLOUD_DB_URL` Secret value: | Field | Value | | --- | --- | +| Authority source | `secret-url-host` | +| Authority env | `HWLAB_CLOUD_DB_URL` | +| Required for readiness | `true` | +| Probe target | redacted host and port parsed from the Secret URL | + +Health output may report `endpointSource: secret-url-host`, DNS/TCP result, +`liveConnected`, and `liveDbEvidence`, but must keep the DB endpoint and Secret +value redacted. + +The non-secret `cloud-api-db` name is an optional desired alias: + +| Field | Value | +| --- | --- | +| Source | `optional-public-dns-alias` | | Service | `cloud-api-db` | | Namespace | `hwlab-dev` | | Host | `cloud-api-db.hwlab-dev.svc.cluster.local` | | Port | `5432` | | Port name | `postgres` | +| Required for readiness | `false` | +| Used for probe | `false` | -`deploy/k8s/base/services.yaml` must include a selectorless -`Service/cloud-api-db` with that port so k3s DNS has a stable handle before the -actual DB endpoints are provisioned. A selectorless Service proves only that -the name can exist in cluster DNS; it does not prove a DB endpoint, credential, -TCP connection, schema migration, durable persistence, or M4/M5 readiness. +Missing `Service/cloud-api-db`, Endpoint, EndpointSlice, or alias env vars must +not reverse-fail live DB success from the Secret URL host. A future PR may make +that alias operational only if it also owns the Service plus Endpoint or +EndpointSlice manifests and the rollout/apply contract. -`deploy/deploy.json`, `deploy/k8s/base/workloads.yaml`, and -`internal/cloud/db-contract.mjs` must agree on -`HWLAB_CLOUD_DB_SERVICE_NAME`, `HWLAB_CLOUD_DB_SERVICE_NAMESPACE`, -`HWLAB_CLOUD_DB_HOST`, and `HWLAB_CLOUD_DB_PORT`. `npm run validate` checks -this agreement offline. A live pass still requires `/health/live` to report -`db.ready=true`, `db.connected=true`, and `liveDbEvidence=true` with secret -values redacted. +Source validation checks `HWLAB_CLOUD_DB_URL`, `HWLAB_CLOUD_DB_SSL_MODE`, +`endpointSource=secret-url-host`, and `liveDbEvidence=true` with secret values +redacted. It must not require `HWLAB_CLOUD_DB_SERVICE_NAME`, +`HWLAB_CLOUD_DB_SERVICE_NAMESPACE`, `HWLAB_CLOUD_DB_HOST`, or +`HWLAB_CLOUD_DB_PORT` as current readiness inputs. The DB readiness contract is layered: diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs index 0b7ab905..ff75367c 100644 --- a/internal/cloud/db-contract.mjs +++ b/internal/cloud/db-contract.mjs @@ -16,12 +16,22 @@ export const DEV_DB_ENV_CONTRACT = Object.freeze({ secretKey: "database-url" }) ]), + endpointAuthority: Object.freeze({ + source: "secret-url-host", + env: "HWLAB_CLOUD_DB_URL", + requiredForReadiness: true, + valueRedacted: true, + endpointRedacted: true + }), dns: Object.freeze({ + source: "optional-public-dns-alias", serviceName: "cloud-api-db", namespace: "hwlab-dev", host: "cloud-api-db.hwlab-dev.svc.cluster.local", port: 5432, - portName: "postgres" + portName: "postgres", + requiredForReadiness: false, + usedForProbe: false }), readinessLayers: Object.freeze([ "dns", @@ -30,11 +40,7 @@ export const DEV_DB_ENV_CONTRACT = Object.freeze({ "schema" ]), nonSecretDefaults: Object.freeze({ - HWLAB_CLOUD_DB_SSL_MODE: "require", - HWLAB_CLOUD_DB_SERVICE_NAME: "cloud-api-db", - HWLAB_CLOUD_DB_SERVICE_NAMESPACE: "hwlab-dev", - HWLAB_CLOUD_DB_HOST: "cloud-api-db.hwlab-dev.svc.cluster.local", - HWLAB_CLOUD_DB_PORT: "5432" + HWLAB_CLOUD_DB_SSL_MODE: "require" }), forbidden: Object.freeze({ prodAllowed: false, @@ -68,6 +74,7 @@ export function buildDbHealthContract(env = process.env) { const connected = false; const connectionChecked = false; const status = configReady ? "degraded" : "blocked"; + const endpoint = buildEndpointDiagnostics(env); return { contractVersion: DEV_DB_ENV_CONTRACT.contractVersion, @@ -84,6 +91,9 @@ export function buildDbHealthContract(env = process.env) { mode: configReady ? "configured_without_live_connection_attempt" : "not_configured", fields, missingEnv, + endpoint, + endpointSource: endpoint.authoritative.source, + optionalPublicDnsAlias: endpoint.optionalPublicDnsAlias, secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map(({ env: envName, secretName, secretKey }) => ({ env: envName, secretName, @@ -134,6 +144,7 @@ export function buildDbEnvManifestPlaceholder() { return { requiredEnv: [...DEV_DB_ENV_CONTRACT.requiredEnv], secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map((item) => ({ ...item })), + endpointAuthority: { ...DEV_DB_ENV_CONTRACT.endpointAuthority }, dns: { ...DEV_DB_ENV_CONTRACT.dns }, readinessLayers: [...DEV_DB_ENV_CONTRACT.readinessLayers], forbiddenRuntimeHosts: [...DEV_DB_ENV_CONTRACT.forbidden.invalidRuntimeHosts], @@ -184,6 +195,8 @@ export function summarizeDbContract(db = buildDbHealthContract()) { redacted }) ), + endpointSource: db.endpointSource ?? db.endpoint?.authoritative?.source ?? db.connection?.endpointSource ?? DEV_DB_ENV_CONTRACT.endpointAuthority.source, + optionalPublicDnsAlias: summarizeOptionalPublicDnsAlias(db.optionalPublicDnsAlias ?? db.endpoint?.optionalPublicDnsAlias), connection: summarizeConnection(db.connection), redaction: summarizeRedaction(db.redaction), readinessLayers: buildReadinessLayers(db.connection), @@ -206,6 +219,7 @@ function buildNotAttemptedConnection(configReady, missingEnv) { networkAttempted: false, result: configReady ? "not_attempted" : "not_attempted_missing_env", classification: configReady ? "configured_no_live_attempt" : "missing_runtime_env", + endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source, probeType: "tcp-connect", endpointRedacted: true, valueRedacted: true, @@ -235,6 +249,7 @@ async function probeDbConnection(env, options) { networkAttempted: false, result: parsed.result, classification: parsed.classification, + endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source, probeType: "tcp-connect", endpointRedacted: true, valueRedacted: true, @@ -248,7 +263,8 @@ async function probeDbConnection(env, options) { return tcpConnect({ host: parsed.host, port: parsed.port, - timeoutMs + timeoutMs, + endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source }); } @@ -303,7 +319,7 @@ export function parseDbUrlContract(rawUrl) { } } -function tcpConnect({ host, port, timeoutMs }) { +function tcpConnect({ host, port, timeoutMs, endpointSource }) { return new Promise((resolve) => { const startedAt = Date.now(); const socket = net.connect({ host, port, timeout: timeoutMs }); @@ -316,6 +332,7 @@ function tcpConnect({ host, port, timeoutMs }) { resolve({ attempted: true, networkAttempted: true, + endpointSource, probeType: "tcp-connect", endpointRedacted: true, valueRedacted: true, @@ -397,6 +414,9 @@ function applyConnectionResult(base, connection) { status: liveConnected ? "ready" : "degraded", mode: liveConnected ? "live_connection_ready" : "live_connection_blocked", connection, + endpoint: base.endpoint, + endpointSource: connection.endpointSource ?? base.endpointSource, + optionalPublicDnsAlias: base.optionalPublicDnsAlias, redaction: buildRedactionSafety(connection), safety: { ...base.safety, @@ -493,6 +513,7 @@ function summarizeConnection(connection) { networkAttempted: Boolean(connection.networkAttempted), result: connection.result, classification: connection.classification, + endpointSource: connection.endpointSource ?? DEV_DB_ENV_CONTRACT.endpointAuthority.source, probeType: connection.probeType, endpointRedacted: true, valueRedacted: true, @@ -502,6 +523,60 @@ function summarizeConnection(connection) { }; } +function buildEndpointDiagnostics(env) { + const authority = DEV_DB_ENV_CONTRACT.endpointAuthority; + return { + authoritative: { + source: authority.source, + env: authority.env, + requiredForReadiness: authority.requiredForReadiness, + usedForProbe: true, + endpointRedacted: true, + valueRedacted: true + }, + optionalPublicDnsAlias: buildOptionalPublicDnsAlias(env) + }; +} + +function buildOptionalPublicDnsAlias(env) { + const alias = DEV_DB_ENV_CONTRACT.dns; + const serviceNamePresent = env?.HWLAB_CLOUD_DB_SERVICE_NAME === alias.serviceName; + const namespacePresent = env?.HWLAB_CLOUD_DB_SERVICE_NAMESPACE === alias.namespace; + const hostPresent = env?.HWLAB_CLOUD_DB_HOST === alias.host; + const portPresent = env?.HWLAB_CLOUD_DB_PORT === String(alias.port); + const envPresent = serviceNamePresent || namespacePresent || hostPresent || portPresent; + return { + source: alias.source, + serviceName: alias.serviceName, + namespace: alias.namespace, + port: alias.port, + portName: alias.portName, + requiredForReadiness: false, + usedForProbe: false, + envPresent, + readyGate: "not_readiness_authority", + note: envPresent + ? "optional alias env observed; live DB readiness still follows the redacted Secret URL host" + : "optional alias is not configured and is not required for live DB readiness" + }; +} + +function summarizeOptionalPublicDnsAlias(alias) { + const fallback = buildOptionalPublicDnsAlias({}); + const value = alias ?? fallback; + return { + source: value.source ?? fallback.source, + serviceName: value.serviceName ?? fallback.serviceName, + namespace: value.namespace ?? fallback.namespace, + port: value.port ?? fallback.port, + portName: value.portName ?? fallback.portName, + requiredForReadiness: value.requiredForReadiness === true ? true : false, + usedForProbe: value.usedForProbe === true ? true : false, + envPresent: value.envPresent === true, + readyGate: value.readyGate ?? "not_readiness_authority" + }; +} + function summarizeRedaction(redaction) { return { valuesRedacted: redaction?.valuesRedacted !== false, diff --git a/internal/cloud/json-rpc.test.mjs b/internal/cloud/json-rpc.test.mjs index a4ed0c0e..6800b911 100644 --- a/internal/cloud/json-rpc.test.mjs +++ b/internal/cloud/json-rpc.test.mjs @@ -64,6 +64,10 @@ test("system.health includes the redacted DEV DB env contract", async () => { assert.equal(response.result.db.liveDbEvidence, false); assert.equal(response.result.db.connectionAttempted, false); assert.equal(response.result.db.connectionResult, "not_attempted_missing_env"); + assert.equal(response.result.db.endpointSource, "secret-url-host"); + assert.equal(response.result.db.optionalPublicDnsAlias.source, "optional-public-dns-alias"); + assert.equal(response.result.db.optionalPublicDnsAlias.requiredForReadiness, false); + assert.equal(response.result.db.optionalPublicDnsAlias.usedForProbe, false); assert.equal(response.result.db.redaction.valuesRedacted, true); assert.equal(response.result.db.redaction.secretMaterialRead, false); assert.equal(response.result.db.safety.liveDbEvidence, false); diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index a1684c67..fecbe7d2 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -38,6 +38,13 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.db.liveDbEvidence, false); assert.equal(healthPayload.db.connectionAttempted, false); assert.equal(healthPayload.db.connectionResult, "not_attempted_missing_env"); + assert.equal(healthPayload.db.endpointSource, "secret-url-host"); + assert.equal(healthPayload.db.endpoint.authoritative.source, "secret-url-host"); + assert.equal(healthPayload.db.endpoint.authoritative.env, "HWLAB_CLOUD_DB_URL"); + assert.equal(healthPayload.db.endpoint.authoritative.usedForProbe, true); + assert.equal(healthPayload.db.optionalPublicDnsAlias.source, "optional-public-dns-alias"); + assert.equal(healthPayload.db.optionalPublicDnsAlias.requiredForReadiness, false); + assert.equal(healthPayload.db.optionalPublicDnsAlias.usedForProbe, false); assert.equal(healthPayload.db.redaction.valuesRedacted, true); assert.equal(healthPayload.db.redaction.secretMaterialRead, false); assert.equal(healthPayload.db.safety.liveDbEvidence, false); @@ -94,11 +101,21 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { test("cloud api health reports DB env presence and live connection classification without leaking values", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; + const originalAliasEnv = { + HWLAB_CLOUD_DB_SERVICE_NAME: process.env.HWLAB_CLOUD_DB_SERVICE_NAME, + HWLAB_CLOUD_DB_SERVICE_NAMESPACE: process.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, + HWLAB_CLOUD_DB_HOST: process.env.HWLAB_CLOUD_DB_HOST, + HWLAB_CLOUD_DB_PORT: process.env.HWLAB_CLOUD_DB_PORT + }; const fakeDb = createTcpServer((socket) => socket.end()); 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"; + delete process.env.HWLAB_CLOUD_DB_SERVICE_NAME; + delete process.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE; + delete process.env.HWLAB_CLOUD_DB_HOST; + delete process.env.HWLAB_CLOUD_DB_PORT; const server = createCloudApiServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -115,6 +132,14 @@ test("cloud api health reports DB env presence and live connection classificatio assert.equal(payload.db.connectionChecked, true); assert.equal(payload.db.connectionAttempted, true); assert.equal(payload.db.connectionResult, "connected"); + assert.equal(payload.db.endpointSource, "secret-url-host"); + assert.equal(payload.db.connection.endpointSource, "secret-url-host"); + assert.equal(payload.db.endpoint.authoritative.source, "secret-url-host"); + assert.equal(payload.db.endpoint.authoritative.env, "HWLAB_CLOUD_DB_URL"); + assert.equal(payload.db.optionalPublicDnsAlias.source, "optional-public-dns-alias"); + assert.equal(payload.db.optionalPublicDnsAlias.requiredForReadiness, false); + assert.equal(payload.db.optionalPublicDnsAlias.usedForProbe, false); + assert.equal(payload.db.optionalPublicDnsAlias.envPresent, false); assert.equal(payload.db.configReady, true); assert.equal(payload.db.ready, true); assert.equal(payload.db.evidence, "live_db_tcp_connection_ready"); @@ -131,6 +156,7 @@ test("cloud api health reports DB env presence and live connection classificatio assert.equal(JSON.stringify(payload.db).includes("password"), false); assert.equal(JSON.stringify(payload.db).includes("127.0.0.1"), false); assert.equal(JSON.stringify(payload.db).includes(String(dbPort)), false); + assert.equal(JSON.stringify(payload.db).includes("cloud-api-db.hwlab-dev.svc.cluster.local"), false); assert.equal(payload.db.fields.find((field) => field.name === "HWLAB_CLOUD_DB_URL").redacted, true); assert.equal(payload.db.connection.endpointRedacted, true); } finally { @@ -144,6 +170,13 @@ test("cloud api health reports DB env presence and live connection classificatio } else { process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode; } + for (const [name, value] of Object.entries(originalAliasEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); @@ -253,6 +286,8 @@ test("cloud api health does not treat DB env presence-only as live readiness", a assert.equal(payload.db.connectionAttempted, true); assert.equal(payload.db.connectionResult, "forbidden_runtime_host"); assert.equal(payload.db.evidence, "live_db_tcp_connection_blocked"); + assert.equal(payload.db.endpointSource, "secret-url-host"); + assert.equal(payload.db.connection.endpointSource, "secret-url-host"); assert.equal(payload.db.connection.networkAttempted, false); assert.equal(payload.db.connection.classification, "db_url_forbidden_invalid_host"); assert.equal(payload.db.readinessLayers.dns.status, "not_proven"); diff --git a/scripts/cloud-api-runtime-smoke.mjs b/scripts/cloud-api-runtime-smoke.mjs index 9b9ffddc..6a5f53a0 100644 --- a/scripts/cloud-api-runtime-smoke.mjs +++ b/scripts/cloud-api-runtime-smoke.mjs @@ -90,6 +90,10 @@ async function run() { assert.equal(health.body.db.liveDbEvidence, false); assert.equal(health.body.db.connectionAttempted, false); assert.equal(health.body.db.connectionResult, "not_attempted_missing_env"); + assert.equal(health.body.db.endpointSource, "secret-url-host"); + assert.equal(health.body.db.optionalPublicDnsAlias.source, "optional-public-dns-alias"); + assert.equal(health.body.db.optionalPublicDnsAlias.requiredForReadiness, false); + assert.equal(health.body.db.optionalPublicDnsAlias.usedForProbe, false); assert.equal(health.body.db.ready, false); assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); assert.equal(health.body.runtime.adapter, "memory"); diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index a084aa58..0fcc4f87 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -145,7 +145,7 @@ function validateSource(ctx, manifest) { const tunnelClient = servicesById.get("hwlab-tunnel-client"); const cli = servicesById.get("hwlab-cli"); expectEqual(ctx, cloudApi?.env?.HWLAB_PUBLIC_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cloud-api.env.HWLAB_PUBLIC_ENDPOINT", "cloud API public endpoint env"); - validateCloudApiDbDnsSource(ctx, cloudApi?.env ?? {}); + validateCloudApiDbSource(ctx, cloudApi?.env ?? {}); validateCloudApiCodeAgentSource(ctx, cloudApi?.env ?? {}); expectEqual(ctx, cli?.env?.HWLAB_CLI_ENDPOINT, endpoints.api?.url, "$.services.hwlab-cli.env.HWLAB_CLI_ENDPOINT", "CLI endpoint env"); expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_PUBLIC_PORT, String(expectedPorts.api), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_PUBLIC_PORT", "tunnel API public port env"); @@ -189,13 +189,23 @@ function validateSource(ctx, manifest) { return { manifest, endpoints, servicesById, proxies, serviceMappings, healthPath: manifest.health?.path }; } -function validateCloudApiDbDnsSource(ctx, env) { - const dns = DEV_DB_ENV_CONTRACT.dns; - const expectedHost = `${dns.serviceName}.${dns.namespace}.svc.cluster.local`; - expectEqual(ctx, env.HWLAB_CLOUD_DB_SERVICE_NAME, dns.serviceName, "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SERVICE_NAME", "cloud API DB service name"); - expectEqual(ctx, env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, dns.namespace, "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE", "cloud API DB service namespace"); - expectEqual(ctx, env.HWLAB_CLOUD_DB_HOST, expectedHost, "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_HOST", "cloud API DB stable DNS host"); - expectEqual(ctx, env.HWLAB_CLOUD_DB_PORT, String(dns.port), "$.services.hwlab-cloud-api.env.HWLAB_CLOUD_DB_PORT", "cloud API DB service port"); +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_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"); + expectEqual(ctx, alias.requiredForReadiness, false, "internal/cloud/db-contract.mjs.dns.requiredForReadiness", "cloud API DB DNS alias readiness requirement"); + expectEqual(ctx, alias.usedForProbe, false, "internal/cloud/db-contract.mjs.dns.usedForProbe", "cloud API DB DNS alias probe source"); + for (const name of [ + "HWLAB_CLOUD_DB_SERVICE_NAME", + "HWLAB_CLOUD_DB_SERVICE_NAMESPACE", + "HWLAB_CLOUD_DB_HOST", + "HWLAB_CLOUD_DB_PORT" + ]) { + expectEqual(ctx, env[name], undefined, `$.services.hwlab-cloud-api.env.${name}`, `${name} is optional and not a readiness hard requirement`); + } } function validateCloudApiCodeAgentSource(ctx, env) { @@ -317,7 +327,7 @@ function validateK3sArtifacts(ctx, source, rendered, services, workloads, health } } - validateCloudApiDbDnsArtifacts(ctx, services, workloads); + validateCloudApiDbOptionalAliasArtifacts(ctx, services, workloads); validateCloudApiCodeAgentArtifacts(ctx, workloads); expectEqual(ctx, healthContract.data?.endpoint, source.endpoints.api?.url, "deploy/k8s/dev/health-contract.yaml.data.endpoint", "health contract endpoint"); @@ -327,30 +337,20 @@ function validateK3sArtifacts(ctx, source, rendered, services, workloads, health expectEqual(ctx, masterEdge.prodAcceptance, false, "deploy/master-edge/health-contract.json.prodAcceptance", "master edge PROD acceptance"); } -function validateCloudApiDbDnsArtifacts(ctx, services, workloads) { - const dns = DEV_DB_ENV_CONTRACT.dns; - const expectedHost = `${dns.serviceName}.${dns.namespace}.svc.cluster.local`; - const service = listItems(services).find((item) => item?.metadata?.name === dns.serviceName); - expect(ctx, Boolean(service), "missing_rendered_artifact", "deploy/k8s/base/services.yaml.cloud-api-db", "cloud-api DB stable DNS Service exists"); - if (service) { - const port = (service.spec?.ports ?? []).find((entry) => entry.name === dns.portName); - expectEqual(ctx, service.metadata?.namespace, dns.namespace, "deploy/k8s/base/services.yaml.cloud-api-db.metadata.namespace", "cloud-api DB Service namespace"); - expectEqual(ctx, service.spec?.type, "ClusterIP", "deploy/k8s/base/services.yaml.cloud-api-db.spec.type", "cloud-api DB Service type"); - expectEqual(ctx, service.spec?.selector, undefined, "deploy/k8s/base/services.yaml.cloud-api-db.spec.selector", "cloud-api DB Service is selectorless until endpoints are provisioned"); - expectEqual(ctx, service.metadata?.labels?.["hwlab.pikastech.local/source-contract"], "db-dns", "deploy/k8s/base/services.yaml.cloud-api-db.metadata.labels.source-contract", "cloud-api DB Service source-contract label"); - expect(ctx, Boolean(port), "missing_rendered_artifact", "deploy/k8s/base/services.yaml.cloud-api-db.spec.ports.postgres", "cloud-api DB Service postgres port exists"); - if (port) { - expectEqual(ctx, port.port, dns.port, "deploy/k8s/base/services.yaml.cloud-api-db.spec.ports.postgres.port", "cloud-api DB Service port"); - expectEqual(ctx, port.targetPort, dns.portName, "deploy/k8s/base/services.yaml.cloud-api-db.spec.ports.postgres.targetPort", "cloud-api DB Service targetPort"); - } - } - +function validateCloudApiDbOptionalAliasArtifacts(ctx, services, workloads) { + const alias = DEV_DB_ENV_CONTRACT.dns; + const service = listItems(services).find((item) => item?.metadata?.name === alias.serviceName); + 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_SERVICE_NAME"), dns.serviceName, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SERVICE_NAME", "cloud API workload DB service name"); - expectEqual(ctx, env.get("HWLAB_CLOUD_DB_SERVICE_NAMESPACE"), dns.namespace, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE", "cloud API workload DB service namespace"); - expectEqual(ctx, env.get("HWLAB_CLOUD_DB_HOST"), expectedHost, "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CLOUD_DB_HOST", "cloud API workload DB host"); - expectEqual(ctx, env.get("HWLAB_CLOUD_DB_PORT"), String(dns.port), "deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.HWLAB_CLOUD_DB_PORT", "cloud API workload DB port"); + for (const name of [ + "HWLAB_CLOUD_DB_SERVICE_NAME", + "HWLAB_CLOUD_DB_SERVICE_NAMESPACE", + "HWLAB_CLOUD_DB_HOST", + "HWLAB_CLOUD_DB_PORT" + ]) { + expectEqual(ctx, env.get(name), undefined, `deploy/k8s/base/workloads.yaml.hwlab-cloud-api.env.${name}`, `${name} is optional and not injected as readiness authority`); + } } function validateCloudApiCodeAgentArtifacts(ctx, workloads) { diff --git a/scripts/src/dev-deploy-apply.mjs b/scripts/src/dev-deploy-apply.mjs index 3a04dfc1..4624243d 100755 --- a/scripts/src/dev-deploy-apply.mjs +++ b/scripts/src/dev-deploy-apply.mjs @@ -502,7 +502,7 @@ function blockerHint(blocker) { return "Restore the public DEV health path and confirm it identifies HWLAB dev, not a substitute runtime."; } if (blocker.scope === "dev-health-db" || blocker.scope === "cloud-api-db" || blocker.scope === "cloud-api-db-config") { - return "Configure and verify the DEV cloud-api DB env and stable DNS readiness through health output, without reading secret values."; + return "Configure and verify the DEV cloud-api DB Secret URL host readiness through health output, without reading secret values."; } return blocker.summary; } @@ -749,14 +749,14 @@ 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 dnsContract = inspectCloudApiDbDnsContract(deployEnv, workloadEnv, services); + const dnsContract = inspectCloudApiDbOptionalAliasContract(deployEnv, workloadEnv, services); const missingDnsContract = dnsContract.missing; if (missingManifest.length > 0 || missingK8s.length > 0 || missingSecretRefs.length > 0 || missingDnsContract.length > 0) { addBlocker( blockers, "runtime_blocker", "cloud-api-db-config", - `cloud-api DEV DB env/DNS contract is incomplete; missing ${[...missingManifest, ...missingK8s, ...missingSecretRefs, ...missingDnsContract].join(", ")}` + `cloud-api DEV DB env/authority contract is incomplete; missing ${[...missingManifest, ...missingK8s, ...missingSecretRefs, ...missingDnsContract].join(", ")}` ); } @@ -804,15 +804,13 @@ async function checkCloudApiDb(deploy, workloads, services, blockers) { valuesRedacted: true, liveDbEvidence: false, fixtureEvidence: false, - note: "This check records DB env, Secret reference, and stable DNS Service presence only. It is not live DB evidence." + note: "This check records DB env, Secret reference, and Secret URL host authority only. Optional cloud-api-db alias presence is diagnostic and is not live DB evidence." }; } -function inspectCloudApiDbDnsContract(deployEnv, workloadEnv, services) { - const dns = DEV_DB_ENV_CONTRACT.dns; - const expectedHost = `${dns.serviceName}.${dns.namespace}.svc.cluster.local`; - const service = listItems(services).find((item) => item?.metadata?.name === dns.serviceName) ?? null; - const port = service?.spec?.ports?.find((entry) => entry.name === dns.portName) ?? null; +function inspectCloudApiDbOptionalAliasContract(deployEnv, workloadEnv, services) { + const alias = DEV_DB_ENV_CONTRACT.dns; + const service = listItems(services).find((item) => item?.metadata?.name === alias.serviceName) ?? null; const actual = { deployServiceName: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAME, deployServiceNamespace: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, @@ -822,42 +820,25 @@ function inspectCloudApiDbDnsContract(deployEnv, workloadEnv, services) { workloadServiceNamespace: workloadEnv.get("HWLAB_CLOUD_DB_SERVICE_NAMESPACE")?.value, workloadHost: workloadEnv.get("HWLAB_CLOUD_DB_HOST")?.value, workloadPort: workloadEnv.get("HWLAB_CLOUD_DB_PORT")?.value, - serviceName: service?.metadata?.name, - serviceNamespace: service?.metadata?.namespace, - serviceType: service?.spec?.type, - serviceSelector: service?.spec?.selector ?? null, - portName: port?.name, - port: port?.port, - targetPort: port?.targetPort + serviceName: service?.metadata?.name }; - const checks = [ - ["deployEnv.HWLAB_CLOUD_DB_SERVICE_NAME", actual.deployServiceName, dns.serviceName], - ["deployEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE", actual.deployServiceNamespace, dns.namespace], - ["deployEnv.HWLAB_CLOUD_DB_HOST", actual.deployHost, expectedHost], - ["deployEnv.HWLAB_CLOUD_DB_PORT", actual.deployPort, String(dns.port)], - ["workloadEnv.HWLAB_CLOUD_DB_SERVICE_NAME", actual.workloadServiceName, dns.serviceName], - ["workloadEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE", actual.workloadServiceNamespace, dns.namespace], - ["workloadEnv.HWLAB_CLOUD_DB_HOST", actual.workloadHost, expectedHost], - ["workloadEnv.HWLAB_CLOUD_DB_PORT", actual.workloadPort, String(dns.port)], - ["Service.metadata.name", actual.serviceName, dns.serviceName], - ["Service.metadata.namespace", actual.serviceNamespace, dns.namespace], - ["Service.spec.type", actual.serviceType, "ClusterIP"], - ["Service.spec.selector", actual.serviceSelector, null], - ["Service.port.name", actual.portName, dns.portName], - ["Service.port.port", actual.port, dns.port], - ["Service.port.targetPort", actual.targetPort, dns.portName] - ]; - const missing = checks - .filter(([, actualValue, expectedValue]) => !Object.is(actualValue, expectedValue)) - .map(([name, actualValue, expectedValue]) => `${name} expected ${expectedValue} got ${actualValue ?? "missing"}`); + const missing = []; + for (const [name, value] of Object.entries(actual)) { + if (value !== undefined) { + missing.push(`${name} must be absent because ${alias.serviceName} is not the readiness authority`); + } + } return { ready: missing.length === 0, - host: expectedHost, - serviceName: dns.serviceName, - namespace: dns.namespace, - port: dns.port, - selectorless: service ? service.spec?.selector === undefined : false, + source: alias.source, + serviceName: alias.serviceName, + namespace: alias.namespace, + port: alias.port, + portName: alias.portName, + requiredForReadiness: alias.requiredForReadiness, + usedForProbe: alias.usedForProbe, + servicePresent: Boolean(service), secretMaterialRead: false, liveDbEvidence: false, missing, diff --git a/scripts/src/dev-edge-health-smoke-lib.mjs b/scripts/src/dev-edge-health-smoke-lib.mjs index bff117a0..7f584132 100644 --- a/scripts/src/dev-edge-health-smoke-lib.mjs +++ b/scripts/src/dev-edge-health-smoke-lib.mjs @@ -305,6 +305,7 @@ function inspectCloudApiDbContract(deploy) { networkAttempted: false, result: "manifest_only_not_attempted", classification: "manifest_only", + endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source, probeType: "tcp-connect", endpointRedacted: true, valueRedacted: true, @@ -321,6 +322,28 @@ function inspectCloudApiDbContract(deploy) { connectionChecked: false, connectionAttempted: false, connectionResult: connection.result, + endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source, + endpoint: { + authoritative: { + source: DEV_DB_ENV_CONTRACT.endpointAuthority.source, + env: DEV_DB_ENV_CONTRACT.endpointAuthority.env, + requiredForReadiness: true, + usedForProbe: true, + endpointRedacted: true, + valueRedacted: true + } + }, + optionalPublicDnsAlias: { + source: DEV_DB_ENV_CONTRACT.dns.source, + serviceName: DEV_DB_ENV_CONTRACT.dns.serviceName, + namespace: DEV_DB_ENV_CONTRACT.dns.namespace, + port: DEV_DB_ENV_CONTRACT.dns.port, + portName: DEV_DB_ENV_CONTRACT.dns.portName, + requiredForReadiness: false, + usedForProbe: false, + envPresent: false, + readyGate: "not_readiness_authority" + }, fields: [ { name: "HWLAB_CLOUD_DB_URL", @@ -475,9 +498,25 @@ function summarizeRuntimeDbReadiness(db, secretPresence = unknownDbSecretPresenc const connectionResult = db.connectionResult ?? db.connection?.result ?? (connectionAttempted ? "unknown" : "not_attempted"); const connectionClassification = db.connection?.classification ?? classificationForRuntimeDb(db, connectionAttempted, connectionResult); + const endpointSource = + db.endpointSource ?? db.connection?.endpointSource ?? db.endpoint?.authoritative?.source ?? DEV_DB_ENV_CONTRACT.endpointAuthority.source; + const optionalPublicDnsAlias = db.optionalPublicDnsAlias ?? db.endpoint?.optionalPublicDnsAlias ?? { + source: DEV_DB_ENV_CONTRACT.dns.source, + serviceName: DEV_DB_ENV_CONTRACT.dns.serviceName, + namespace: DEV_DB_ENV_CONTRACT.dns.namespace, + port: DEV_DB_ENV_CONTRACT.dns.port, + portName: DEV_DB_ENV_CONTRACT.dns.portName, + requiredForReadiness: false, + usedForProbe: false, + envPresent: false, + readyGate: "not_readiness_authority" + }; const liveConnected = Boolean(db.liveConnected ?? db.connected ?? db.ready); - const blockerMessage = liveConnected ? null : db.blocker ?? blockerForRuntimeDb(db, connectionAttempted, connectionResult); - const status = liveConnected ? "pass" : "blocked"; + const authorityReady = endpointSource === DEV_DB_ENV_CONTRACT.endpointAuthority.source; + const blockerMessage = liveConnected && authorityReady + ? null + : db.blocker ?? (authorityReady ? blockerForRuntimeDb(db, connectionAttempted, connectionResult) : "DB readiness did not use the Secret URL host authority"); + const status = liveConnected && authorityReady ? "pass" : "blocked"; const secretRef = Array.isArray(db.secretRefs) ? db.secretRefs[0] : null; return { @@ -495,6 +534,18 @@ function summarizeRuntimeDbReadiness(db, secretPresence = unknownDbSecretPresenc connectionAttempted, connectionResult, connectionClassification, + endpointSource, + optionalPublicDnsAlias: { + source: optionalPublicDnsAlias.source ?? DEV_DB_ENV_CONTRACT.dns.source, + serviceName: optionalPublicDnsAlias.serviceName ?? DEV_DB_ENV_CONTRACT.dns.serviceName, + namespace: optionalPublicDnsAlias.namespace ?? DEV_DB_ENV_CONTRACT.dns.namespace, + port: optionalPublicDnsAlias.port ?? DEV_DB_ENV_CONTRACT.dns.port, + portName: optionalPublicDnsAlias.portName ?? DEV_DB_ENV_CONTRACT.dns.portName, + requiredForReadiness: optionalPublicDnsAlias.requiredForReadiness === true, + usedForProbe: optionalPublicDnsAlias.usedForProbe === true, + envPresent: optionalPublicDnsAlias.envPresent === true, + readyGate: optionalPublicDnsAlias.readyGate ?? "not_readiness_authority" + }, liveConnected, valuesRedacted: db.redaction?.valuesRedacted !== false && db.safety?.valuesRedacted === true, secretMaterialRead: false, @@ -507,6 +558,7 @@ function summarizeRuntimeDbReadiness(db, secretPresence = unknownDbSecretPresenc connectionAttempted, connectionResult, classification: connectionClassification, + endpointSource, liveConnected, endpointRedacted: db.connection?.endpointRedacted !== false, valueRedacted: true @@ -906,7 +958,7 @@ function classifyMilestoneImpact(edgeHealth) { return { M3: { status: "separate_blocker", - summary: "M3 still requires real DEV hardware trusted-loop operation/trace/audit/evidence; DB DNS failure and #143 provider Secret absence do not prove or clear M3." + summary: "M3 still requires real DEV hardware trusted-loop operation/trace/audit/evidence; DB Secret URL host readiness failure and #143 provider Secret absence do not prove or clear M3." }, M4: { status: dbBlocked || providerBlocked ? "blocked" : "not_proven", diff --git a/scripts/src/dev-edge-health-smoke-lib.test.mjs b/scripts/src/dev-edge-health-smoke-lib.test.mjs index 07bb2bde..b85b693d 100644 --- a/scripts/src/dev-edge-health-smoke-lib.test.mjs +++ b/scripts/src/dev-edge-health-smoke-lib.test.mjs @@ -28,6 +28,7 @@ test("runtime DB readiness preserves dns resolution classification", () => { attempted: true, result: "dns_error", classification: "dns_resolution_failed", + endpointSource: "secret-url-host", endpointRedacted: true, valueRedacted: true }, @@ -49,13 +50,70 @@ test("runtime DB readiness preserves dns resolution classification", () => { assert.equal(readiness.status, "blocked"); assert.equal(readiness.connectionResult, "dns_error"); assert.equal(readiness.connectionClassification, "dns_resolution_failed"); + assert.equal(readiness.endpointSource, "secret-url-host"); + assert.equal(readiness.optionalPublicDnsAlias.source, "optional-public-dns-alias"); + assert.equal(readiness.optionalPublicDnsAlias.requiredForReadiness, false); + assert.equal(readiness.optionalPublicDnsAlias.usedForProbe, false); assert.equal(readiness.liveConnected, false); assert.equal(readiness.liveDbEvidence, false); assert.equal(readiness.secretMaterialRead, false); assert.equal(readiness.evidence[0].classification, "dns_resolution_failed"); + assert.equal(readiness.evidence[0].endpointSource, "secret-url-host"); }); -test("parallel blockers keep DB DNS and provider Secret gaps independent", () => { +test("runtime DB readiness does not pass when live success uses a non-authoritative DB endpoint source", () => { + const readiness = __testHooks.summarizeRuntimeDbReadiness( + { + configReady: true, + ready: true, + connected: true, + liveConnected: true, + liveDbEvidence: true, + connectionAttempted: true, + connectionResult: "connected", + endpointSource: "optional-public-dns-alias", + fields: [ + { name: "HWLAB_CLOUD_DB_URL", present: true }, + { name: "HWLAB_CLOUD_DB_SSL_MODE", present: true } + ], + secretRefs: [ + { + env: "HWLAB_CLOUD_DB_URL", + secretName: "hwlab-cloud-api-dev-db", + secretKey: "database-url", + present: true, + envInjected: true + } + ], + connection: { + attempted: true, + result: "connected", + classification: "tcp_connected", + endpointSource: "optional-public-dns-alias", + endpointRedacted: true, + valueRedacted: true + }, + redaction: { valuesRedacted: true }, + safety: { valuesRedacted: true, liveDbEvidence: true } + }, + { + observable: true, + secretName: "hwlab-cloud-api-dev-db", + secretKey: "database-url", + secretPresent: true, + secretKeyPresent: true, + secretValueRead: false, + redacted: true + } + ); + + assert.equal(readiness.status, "blocked"); + assert.equal(readiness.liveConnected, true); + assert.equal(readiness.endpointSource, "optional-public-dns-alias"); + assert.match(readiness.blocker, /Secret URL host/u); +}); + +test("parallel blockers keep DB readiness and provider Secret gaps independent", () => { const edgeHealth = { runtimeDbReadiness: { status: "blocked", diff --git a/scripts/src/dev-evidence-blocker-aggregator.mjs b/scripts/src/dev-evidence-blocker-aggregator.mjs index bc99ecc0..d76f8fcf 100644 --- a/scripts/src/dev-evidence-blocker-aggregator.mjs +++ b/scripts/src/dev-evidence-blocker-aggregator.mjs @@ -841,7 +841,11 @@ function buildDoD(reports, milestones, blockers) { artifactIdentity.targetCoverage?.covered !== false && artifactIdentity.artifactCatalog?.matchesTarget !== false; const cloudApiDb = cloudApiDbStatus(reports); - const dbReady = cloudApiDb.ready === true && cloudApiDb.connected === true && cloudApiDb.liveDbEvidence === true; + const dbReady = + cloudApiDb.ready === true && + cloudApiDb.connected === true && + cloudApiDb.endpointSource === "secret-url-host" && + cloudApiDb.liveDbEvidence === true; const m3Live = statusIsPass(reports.devM3Hardware.liveOperation?.status); const m4Live = statusIsPass(reports.devM4Agent.livePreflight?.status); @@ -885,7 +889,7 @@ function buildDoD(reports, milestones, blockers) { id: "cloud-api-db-ready", status: dbReady ? "pass" : "blocked", evidenceLevel: dbReady ? "DEV-LIVE" : "BLOCKED", - summary: `cloud-api DB status=${cloudApiDb.status}; ready=${cloudApiDb.ready}; connected=${cloudApiDb.connected}; liveDbEvidence=${cloudApiDb.liveDbEvidence}.` + summary: `cloud-api DB status=${cloudApiDb.status}; ready=${cloudApiDb.ready}; connected=${cloudApiDb.connected}; endpointSource=${cloudApiDb.endpointSource}; liveDbEvidence=${cloudApiDb.liveDbEvidence}.` }, { id: "m3-hardware-trusted-loop", @@ -921,6 +925,7 @@ function cloudApiDbStatus(reports) { connected: db.connected === true || db.liveConnected === true, configReady: db.configReady === true, connectionChecked: db.connectionChecked === true, + endpointSource: db.endpointSource ?? db.connection?.endpointSource ?? "unknown", liveDbEvidence: db.liveDbEvidence === true }; } @@ -929,7 +934,7 @@ function buildCurrentDevLayering(reports, blockers) { const m2EndpointLive = hasCurrentM2EndpointEvidence(reports.devM2Smoke); const edgeLive = hasCurrentLiveEdgeEvidence(reports.devEdgeHealth); const cloudDb = cloudApiDbStatus(reports); - const dbReady = cloudDb.ready && cloudDb.connected && cloudDb.liveDbEvidence; + const dbReady = cloudDb.ready && cloudDb.connected && cloudDb.endpointSource === "secret-url-host" && cloudDb.liveDbEvidence; const m3Live = statusIsPass(reports.devM3Hardware.liveOperation?.status); const m4Live = statusIsPass(reports.devM4Agent.livePreflight?.status); const artifactIdentity = reports.devPreflight.artifactIdentity; @@ -962,7 +967,7 @@ function buildCurrentDevLayering(reports, blockers) { label: "DB live/degraded", status: dbReady ? "pass" : "blocked", evidenceLevel: dbReady ? "DEV-LIVE" : "BLOCKED", - summary: `cloud-api DB status=${cloudDb.status}; configReady=${cloudDb.configReady}; ready=${cloudDb.ready}; connected=${cloudDb.connected}; liveDbEvidence=${cloudDb.liveDbEvidence}.`, + summary: `cloud-api DB status=${cloudDb.status}; configReady=${cloudDb.configReady}; ready=${cloudDb.ready}; connected=${cloudDb.connected}; endpointSource=${cloudDb.endpointSource}; liveDbEvidence=${cloudDb.liveDbEvidence}.`, evidence: reports.devM5Gate.devPreconditions?.evidence?.filter((line) => line.includes("/health/live") || line.includes("DB")) ?? [], nextRequired: "Provide live DB connection evidence through redacted health output; route reachability alone is insufficient." }, @@ -1079,11 +1084,12 @@ function buildMilestoneBlockerClassification(reports) { status: m4Live ? "pass" : "blocked", currentLevel: m4Live ? "DEV-LIVE" : "BLOCKED", blockerClass: m4Live ? "cleared" : "db-live-readiness", - dependency: "Cloud API /health/live must report DB ready=true, connected=true, and liveDbEvidence=true before live agent scheduling/evidence closure.", + dependency: "Cloud API /health/live must report DB ready=true, connected=true, endpointSource=secret-url-host, and liveDbEvidence=true before live agent scheduling/evidence closure.", evidence: [ `db.status=${cloudDb.status}`, `db.ready=${cloudDb.ready}`, `db.connected=${cloudDb.connected}`, + `db.endpointSource=${cloudDb.endpointSource}`, `db.liveDbEvidence=${cloudDb.liveDbEvidence}` ], nextRequired: "Repair DB live readiness and rerun the M4 live preflight without scheduling a DEV agent task before preconditions pass.", @@ -1157,7 +1163,7 @@ function fallbackAction(scope) { if (scope === "base-image") return "Preload or tag node:20-bookworm-slim and rerun the base-image and artifact publish preflights."; if (scope.includes("artifact")) return "Publish DEV artifacts for every frozen HWLAB service and record immutable registry digests."; if (scope.includes("edge") || scope.includes("ingress") || scope.includes("frp")) return "Repair frp/master-edge/D601 router path and rerun read-only DEV edge health."; - if (scope.includes("cloud-api-db")) return "Configure DEV cloud-api DB env readiness and rerun health/preflight without exposing secrets."; + if (scope.includes("cloud-api-db")) return "Configure DEV cloud-api DB env readiness and Secret URL host connectivity, then rerun health/preflight without exposing secrets."; if (scope === "db-live") return "Repair DEV cloud-api DB live readiness, then rerun the read-only health and M4 preflight reports without exposing secret values."; if (scope === "m3-patch-panel-wiring") return "Load/apply DEV patch-panel wiring for res_boxsimu_1:DO1 -> res_boxsimu_2:DI1, then rerun the bounded DEV M3 smoke."; if (scope === "m3-box-simu-identity") return "Fix DEV box-simu instance identity so direct endpoints expose distinct res_boxsimu_1 and res_boxsimu_2 resources."; @@ -1173,8 +1179,8 @@ function evidenceRequiredFor(scope) { if (scope === "hwlab-router" || scope === "hwlab-tunnel-client" || scope === "hwlab-edge-proxy") return "Real repo entrypoints or dedicated Dockerfiles for route services, followed by artifact preflight."; if (scope.includes("artifact") || scope === "ghcr") return "Artifact publish report with ciPublished=true, registryVerified=true, and sha256 digest for each frozen service ID."; if (scope.includes("kubectl") || scope.includes("k3s")) return "Read-only kubectl/k3s report proving pods/services/configmaps are observable in hwlab-dev without reading Secrets."; - if (scope.includes("cloud-api-db")) return "Cloud API health/live output showing DB env ready and redacted secret references, without secret material."; - if (scope === "db-live") return "Cloud API /health/live output with ready=true, connected=true, liveDbEvidence=true, and redacted secret references."; + if (scope.includes("cloud-api-db")) return "Cloud API health/live output showing DB env ready, endpointSource=secret-url-host, redacted secret references, and no secret material."; + if (scope === "db-live") return "Cloud API /health/live output with ready=true, connected=true, endpointSource=secret-url-host, liveDbEvidence=true, and redacted secret references."; if (scope === "m3-patch-panel-wiring") return "Read-only patch-panel /status and /wiring showing active res_boxsimu_1:DO1 -> res_boxsimu_2:DI1 before a bounded write/read smoke records operation, trace, audit, and evidence IDs."; if (scope === "m3-box-simu-identity") return "Read-only direct box-simu /health/live and /status output showing distinct res_boxsimu_1 and res_boxsimu_2 resources."; if (scope === "m3-gateway-simu-identity") return "Read-only direct gateway-simu /health/live and /status output showing two distinct gateway identities/sessions."; diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index 27a50e35..58e339e6 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -826,7 +826,7 @@ function validateCloudApiDbContract(reporter, deploy, workloads, services) { "cloud-api-db-env-contract", "db", "pass", - "cloud-api DEV DB manifest declares the required env names, redacted Secret reference, and stable k3s DB DNS Service.", + "cloud-api DEV DB manifest declares the required env names and redacted Secret reference; optional cloud-api-db alias is not a readiness gate.", evidence ); } else { @@ -847,7 +847,7 @@ function validateCloudApiDbContract(reporter, deploy, workloads, services) { 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, deploy/k8s/base/workloads.yaml, and deploy/k8s/base/services.yaml so they expose the required DB env names, redacted Secret reference, and stable DB Service DNS." + 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." }); } @@ -914,7 +914,7 @@ function inspectCloudApiDbStaticContract(deploy, workloads, services) { const missingSecretRefs = fields .filter((field) => field.secretRef && !field.secretRef.present) .map((field) => `${field.secretRef.secretName}/${field.secretRef.secretKey}`); - const dnsContract = inspectCloudApiDbDnsContract(deployEnv, workloadEnv, services); + const dnsContract = inspectCloudApiDbOptionalAliasContract(deployEnv, workloadEnv, services); return { contractVersion: DEV_DB_ENV_CONTRACT.contractVersion, @@ -937,11 +937,9 @@ function inspectCloudApiDbStaticContract(deploy, workloads, services) { }; } -function inspectCloudApiDbDnsContract(deployEnv, workloadEnv, services) { - const dns = DEV_DB_ENV_CONTRACT.dns; - const expectedHost = `${dns.serviceName}.${dns.namespace}.svc.cluster.local`; - const service = listManifestItems(services).find((item) => item?.metadata?.name === dns.serviceName) ?? null; - const port = service?.spec?.ports?.find((entry) => entry.name === dns.portName) ?? null; +function inspectCloudApiDbOptionalAliasContract(deployEnv, workloadEnv, services) { + const alias = DEV_DB_ENV_CONTRACT.dns; + const service = listManifestItems(services).find((item) => item?.metadata?.name === alias.serviceName) ?? null; const actual = { deployServiceName: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAME, deployServiceNamespace: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, @@ -951,43 +949,25 @@ function inspectCloudApiDbDnsContract(deployEnv, workloadEnv, services) { workloadServiceNamespace: workloadEnv.get("HWLAB_CLOUD_DB_SERVICE_NAMESPACE")?.value, workloadHost: workloadEnv.get("HWLAB_CLOUD_DB_HOST")?.value, workloadPort: workloadEnv.get("HWLAB_CLOUD_DB_PORT")?.value, - serviceName: service?.metadata?.name, - serviceNamespace: service?.metadata?.namespace, - serviceType: service?.spec?.type, - serviceSelector: service?.spec?.selector ?? null, - portName: port?.name, - port: port?.port, - targetPort: port?.targetPort + serviceName: service?.metadata?.name }; - const checks = [ - ["deployEnv.HWLAB_CLOUD_DB_SERVICE_NAME", actual.deployServiceName, dns.serviceName], - ["deployEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE", actual.deployServiceNamespace, dns.namespace], - ["deployEnv.HWLAB_CLOUD_DB_HOST", actual.deployHost, expectedHost], - ["deployEnv.HWLAB_CLOUD_DB_PORT", actual.deployPort, String(dns.port)], - ["workloadEnv.HWLAB_CLOUD_DB_SERVICE_NAME", actual.workloadServiceName, dns.serviceName], - ["workloadEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE", actual.workloadServiceNamespace, dns.namespace], - ["workloadEnv.HWLAB_CLOUD_DB_HOST", actual.workloadHost, expectedHost], - ["workloadEnv.HWLAB_CLOUD_DB_PORT", actual.workloadPort, String(dns.port)], - ["Service.metadata.name", actual.serviceName, dns.serviceName], - ["Service.metadata.namespace", actual.serviceNamespace, dns.namespace], - ["Service.spec.type", actual.serviceType, "ClusterIP"], - ["Service.spec.selector", actual.serviceSelector, null], - ["Service.port.name", actual.portName, dns.portName], - ["Service.port.port", actual.port, dns.port], - ["Service.port.targetPort", actual.targetPort, dns.portName] - ]; - const missing = checks - .filter(([, actualValue, expectedValue]) => !Object.is(actualValue, expectedValue)) - .map(([name, actualValue, expectedValue]) => `${name} expected ${expectedValue} got ${actualValue ?? "missing"}`); + const missing = []; + for (const [name, value] of Object.entries(actual)) { + if (value !== undefined) { + missing.push(`${name} must be absent because ${alias.serviceName} is not the readiness authority`); + } + } return { ready: missing.length === 0, - host: expectedHost, - serviceName: dns.serviceName, - namespace: dns.namespace, - port: dns.port, - portName: dns.portName, - selectorless: service ? service.spec?.selector === undefined : false, + source: alias.source, + serviceName: alias.serviceName, + namespace: alias.namespace, + port: alias.port, + portName: alias.portName, + requiredForReadiness: alias.requiredForReadiness, + usedForProbe: alias.usedForProbe, + servicePresent: Boolean(service), secretMaterialRead: false, liveDbEvidence: false, missing, diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index 5c6952c2..79ec9dd7 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -172,19 +172,19 @@ assert.equal(cloudApi.env.HWLAB_IMAGE_TAG, deployManifest.commitId.slice(0, 7), 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_CONTRACT, "dev-redacted-presence-only", "cloud-api DB contract marker"); -assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SERVICE_NAME, DEV_DB_ENV_CONTRACT.dns.serviceName, "cloud-api DB service name"); -assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, DEV_DB_ENV_CONTRACT.dns.namespace, "cloud-api DB service namespace"); -assert.equal(cloudApi.env.HWLAB_CLOUD_DB_HOST, DEV_DB_ENV_CONTRACT.dns.host, "cloud-api DB stable host"); -assert.equal(cloudApi.env.HWLAB_CLOUD_DB_PORT, String(DEV_DB_ENV_CONTRACT.dns.port), "cloud-api DB stable port"); 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_SERVICE_NAME?.value, DEV_DB_ENV_CONTRACT.dns.serviceName, "cloud-api workload DB service name"); -assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE?.value, DEV_DB_ENV_CONTRACT.dns.namespace, "cloud-api workload DB service namespace"); -assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_HOST?.value, DEV_DB_ENV_CONTRACT.dns.host, "cloud-api workload DB host"); -assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_PORT?.value, String(DEV_DB_ENV_CONTRACT.dns.port), "cloud-api workload DB port"); 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"); -assertCloudApiDbDnsContract(k8sServices, cloudApi.env); +assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SERVICE_NAME, undefined, "cloud-api DB alias service name is optional"); +assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, undefined, "cloud-api DB alias namespace is optional"); +assert.equal(cloudApi.env.HWLAB_CLOUD_DB_HOST, undefined, "cloud-api DB alias host is optional"); +assert.equal(cloudApi.env.HWLAB_CLOUD_DB_PORT, undefined, "cloud-api DB alias port is optional"); +assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_SERVICE_NAME, undefined, "cloud-api workload DB alias service name is optional"); +assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, undefined, "cloud-api workload DB alias namespace is optional"); +assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_HOST, undefined, "cloud-api workload DB alias host is optional"); +assert.equal(cloudApiWorkloadEnv.HWLAB_CLOUD_DB_PORT, undefined, "cloud-api workload DB alias port is optional"); +assertCloudApiDbOptionalAlias(k8sServices); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, "openai", "cloud-api Code Agent provider"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, DEV_CODE_AGENT_PROVIDER_CONTRACT.model, "cloud-api Code Agent model"); assert.equal( @@ -223,28 +223,14 @@ function getWorkloadEnv(workloads, serviceId) { return {}; } -function assertCloudApiDbDnsContract(services, env) { - const dns = DEV_DB_ENV_CONTRACT.dns; - const service = listItems(services).find((item) => item?.metadata?.name === dns.serviceName); - assert.ok(service, `deploy/k8s/base/services.yaml must define Service/${dns.serviceName}`); - assert.equal(service.metadata.namespace, dns.namespace, "cloud-api DB Service namespace"); - assert.equal(service.kind, "Service", "cloud-api DB Service kind"); - assert.equal(service.spec?.type, "ClusterIP", "cloud-api DB Service type"); - assert.equal(service.spec?.selector, undefined, "cloud-api DB Service must be selectorless until DB endpoints are provisioned"); - assert.equal(service.metadata?.labels?.["hwlab.pikastech.local/source-contract"], "db-dns", "cloud-api DB Service source-contract label"); - const port = service.spec?.ports?.find((entry) => entry.name === dns.portName); - assert.ok(port, `Service/${dns.serviceName} must expose ${dns.portName}`); - assert.equal(port.port, dns.port, "cloud-api DB Service port"); - assert.equal(port.targetPort, dns.portName, "cloud-api DB Service targetPort"); - - const expectedHost = `${dns.serviceName}.${dns.namespace}.svc.cluster.local`; - assert.equal(dns.host, expectedHost, "DB DNS contract host matches service namespace"); - assert.equal(env.HWLAB_CLOUD_DB_HOST, expectedHost, "cloud-api DB host env matches Service DNS"); - assert.equal(env.HWLAB_CLOUD_DB_SERVICE_NAME, service.metadata.name, "cloud-api DB service env matches Service name"); - assert.equal(env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, service.metadata.namespace, "cloud-api DB namespace env matches Service namespace"); - assert.equal(env.HWLAB_CLOUD_DB_PORT, String(port.port), "cloud-api DB port env matches Service port"); - const secretUrl = parseDbUrlContract(`postgres://${env.HWLAB_CLOUD_DB_HOST}:${env.HWLAB_CLOUD_DB_PORT}/hwlab`); - assert.equal(secretUrl.ok, true, "cloud-api DB stable host/port must form a supported postgres URL"); +function assertCloudApiDbOptionalAlias(services) { + const alias = DEV_DB_ENV_CONTRACT.dns; + const service = listItems(services).find((item) => item?.metadata?.name === alias.serviceName); + assert.equal(DEV_DB_ENV_CONTRACT.endpointAuthority.source, "secret-url-host", "cloud-api DB runtime authority is the Secret URL host"); + assert.equal(DEV_DB_ENV_CONTRACT.endpointAuthority.env, "HWLAB_CLOUD_DB_URL", "cloud-api DB runtime authority env"); + assert.equal(alias.requiredForReadiness, false, "cloud-api DB alias is not a readiness requirement"); + assert.equal(alias.usedForProbe, false, "cloud-api DB alias is not the runtime TCP probe source"); + assert.equal(service, undefined, `Service/${alias.serviceName} is optional until this repo owns endpoints and rollout/apply contract`); } function assertCodeAgentProviderWorkloadContract(env) { diff --git a/scripts/validate-runtime-boundary.mjs b/scripts/validate-runtime-boundary.mjs index 0ef5a9b9..6c9e4c72 100644 --- a/scripts/validate-runtime-boundary.mjs +++ b/scripts/validate-runtime-boundary.mjs @@ -14,7 +14,6 @@ const forbiddenRuntimeSubstitutes = Object.freeze([ "provider-gateway", "microservice-proxy" ]); -const dbDnsComponent = "cloud-api-db"; async function readText(relativePath) { return readFile(path.join(repoRoot, relativePath), "utf8"); @@ -125,31 +124,17 @@ function labelsFor(resource, label) { } function assertMetadataServiceId(resource, expectedServiceId, label) { - assert.equal(resource.metadata?.name, expectedServiceId, `${label}.metadata.name must match service id`); + if (!resource.metadata?.labels?.["hwlab.pikastech.local/instance-id"]) { + assert.equal(resource.metadata?.name, expectedServiceId, `${label}.metadata.name must match service id`); + } else { + assert.ok(resource.metadata?.name.startsWith(`${expectedServiceId}-`), `${label}.metadata.name must include service id prefix`); + } assertDevNamespace(resource.metadata?.namespace, `${label}.metadata.namespace`); const labels = labelsFor(resource, label); assert.equal(labels[serviceIdLabel], expectedServiceId, `${label} service-id label`); assert.equal(labels["app.kubernetes.io/name"], expectedServiceId, `${label} app name label`); } -function assertDbDnsService(service) { - const dns = DEV_DB_ENV_CONTRACT.dns; - assert.equal(service.metadata?.name, dns.serviceName, "DB DNS Service name"); - assertDevNamespace(service.metadata?.namespace, "DB DNS Service namespace"); - assert.equal(service.metadata?.labels?.["app.kubernetes.io/name"], dbDnsComponent, "DB DNS app label"); - assert.equal(service.metadata?.labels?.["hwlab.pikastech.local/component"], dbDnsComponent, "DB DNS component label"); - assert.equal(service.metadata?.labels?.["hwlab.pikastech.local/source-contract"], "db-dns", "DB DNS source-contract label"); - assert.equal(service.metadata?.labels?.[serviceIdLabel], undefined, "DB DNS Service is not a frozen HWLAB runtime service-id"); - assert.equal(service.spec?.type, "ClusterIP", "DB DNS Service type"); - assert.equal(service.spec?.selector, undefined, "DB DNS Service stays selectorless until endpoints are provisioned"); - const ports = service.spec?.ports; - assertArray(ports, "DB DNS Service ports"); - assert.equal(ports.length, 1, "DB DNS Service exposes one port"); - assert.equal(ports[0].name, dns.portName, "DB DNS Service port name"); - assert.equal(ports[0].port, dns.port, "DB DNS Service port"); - assert.equal(ports[0].targetPort, dns.portName, "DB DNS Service targetPort"); -} - function assertTemplateServiceId(workload, expectedServiceId, label) { const templateLabels = workload.spec?.template?.metadata?.labels; assertObject(templateLabels, `${label}.spec.template.metadata.labels`); @@ -311,24 +296,42 @@ function assertServices(services, guard) { assert.equal(services.kind, "List", "services kind"); assertArray(services.items, "services items"); - const runtimeServices = services.items.filter((item) => item.metadata?.name !== dbDnsComponent); - const serviceIds = runtimeServices.map((item) => item.metadata?.labels?.[serviceIdLabel]); - assertUnique(serviceIds, "k8s service ids"); + const runtimeServices = services.items; + const serviceNames = runtimeServices.map((item) => item.metadata?.name); + const serviceIds = [...new Set(runtimeServices.map((item) => item.metadata?.labels?.[serviceIdLabel]))]; + assertUnique(serviceNames, "k8s service names"); assertSameMembers(serviceIds, guard.requiredK3sSkeleton.requiredServiceServiceIds, "k8s service ids"); - const dbDnsService = services.items.find((item) => item.metadata?.name === dbDnsComponent); - assert.ok(dbDnsService, "k8s services must include selectorless cloud-api-db DNS Service"); - assertDbDnsService(dbDnsService); + assert.equal( + services.items.some((item) => item.metadata?.name === DEV_DB_ENV_CONTRACT.dns.serviceName), + false, + "cloud-api-db is an optional future alias and is not required in the current runtime boundary" + ); + assert.equal(DEV_DB_ENV_CONTRACT.endpointAuthority.source, "secret-url-host", "DB readiness authority must be the Secret URL host"); + assert.equal(DEV_DB_ENV_CONTRACT.dns.requiredForReadiness, false, "DB DNS alias must not be a readiness hard requirement"); + assert.equal(DEV_DB_ENV_CONTRACT.dns.usedForProbe, false, "DB DNS alias must not be the runtime probe target"); for (const service of runtimeServices) { const serviceId = service.metadata?.labels?.[serviceIdLabel]; assertServiceId(serviceId, `k8s service ${service.metadata?.name}`); assertMetadataServiceId(service, serviceId, `k8s service ${serviceId}`); assertNoExternalNameSubstitute(service, `k8s service ${serviceId}`); - assert.equal(service.spec?.selector?.["app.kubernetes.io/name"], serviceId, `${serviceId} selector`); + if (service.metadata?.labels?.["hwlab.pikastech.local/instance-id"]) { + assert.match( + service.spec?.selector?.["statefulset.kubernetes.io/pod-name"], + new RegExp(`^${escapeRegExp(serviceId)}-\\d+$`), + `${serviceId} indexed selector` + ); + } else { + assert.equal(service.spec?.selector?.["app.kubernetes.io/name"], serviceId, `${serviceId} selector`); + } } } +function escapeRegExp(value) { + return String(value).replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + function assertDevHealthConfig(configMap) { assert.equal(configMap.kind, "ConfigMap", "dev health contract kind"); assertDevNamespace(configMap.metadata?.namespace, "dev health contract namespace");