diff --git a/AGENTS.md b/AGENTS.md index 564b808e..dcbdb1ba 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -19,7 +19,7 @@ HWLAB 是硬件实验室运行面和控制面项目。本文是 agent、指挥 - 长期参考入口:[docs/reference/README.md](docs/reference/README.md) - 文档治理规范:[docs/reference/documentation-governance.md](docs/reference/documentation-governance.md) - 架构和 M3 主线:[docs/reference/architecture.md](docs/reference/architecture.md) -- DEV 运行态、端口和 k3s 边界:[docs/reference/dev-runtime-boundary.md](docs/reference/dev-runtime-boundary.md) +- DEV 运行态、端口、k3s 和 DB DNS 边界:[docs/reference/dev-runtime-boundary.md](docs/reference/dev-runtime-boundary.md) - 部署正规化、镜像发布、回滚和 Cloud Web 路径:[docs/reference/deployment-publish.md](docs/reference/deployment-publish.md) - 指挥官协作、PR 和 runner handoff:[docs/reference/commander-collaboration.md](docs/reference/commander-collaboration.md) - M3 闭环 rollout runbook:[docs/reference/m3-loop-rollout-runbook.md](docs/reference/m3-loop-rollout-runbook.md) diff --git a/deploy/README.md b/deploy/README.md index 9ead6542..241b21ef 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -18,9 +18,13 @@ 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`. The repository records names only, never - secret values or a live DB connection string; runtime health reports redacted - env injection and DB connection result classifiers. + `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 + [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 + connection result classifiers. - `deploy/k8s/prod` is a disabled placeholder gate only. - `deploy/frp` describes the D601-to-master reverse link without secrets. - `deploy/master-edge` describes public edge ownership and health boundaries. diff --git a/deploy/deploy.json b/deploy/deploy.json index 1bca6dfa..c56a504a 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -182,6 +182,10 @@ "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_CODE_AGENT_PROVIDER": "openai", "HWLAB_CODE_AGENT_MODEL": "gpt-5.5", "OPENAI_API_KEY": "secretRef:hwlab-code-agent-provider/openai-api-key" diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index ad0a3891..7da73051 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -359,6 +359,22 @@ "HWLAB_CLOUD_DB_CONTRACT": { "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" } } }, diff --git a/deploy/k8s/base/services.yaml b/deploy/k8s/base/services.yaml index 52231bfe..0193b9e4 100644 --- a/deploy/k8s/base/services.yaml +++ b/deploy/k8s/base/services.yaml @@ -27,6 +27,29 @@ ] } }, + { + "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 3744f2fb..69ed15bf 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -81,6 +81,22 @@ "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_CODE_AGENT_PROVIDER", "value": "openai" diff --git a/deploy/k8s/dev/health-contract.yaml b/deploy/k8s/dev/health-contract.yaml index d28b453b..0869f425 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 plus HWLAB_CLOUD_DB_SSL_MODE=require; 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, 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-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 afce68af..c5d650c0 100644 --- a/docs/cloud-api-runtime.md +++ b/docs/cloud-api-runtime.md @@ -41,6 +41,11 @@ 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. + - `db.configReady`: required env names are present. - `db.connectionAttempted`: the runtime tried the live connection path. - `db.connectionResult`: redacted classifier such as `connected`, `refused`, diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index 9b2d3c2e..c9139c94 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -220,8 +220,10 @@ 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 local health gate reports DB runtime -config without printing the connection string. +`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. 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 3aa6c465..41cdcb9b 100644 --- a/docs/dev-edge-health.md +++ b/docs/dev-edge-health.md @@ -35,6 +35,8 @@ 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`. When the Secret/env is absent, the health JSON should keep `db.connected: false`, set `db.status: "blocked"`, and list the missing env diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index b15e475e..0a446945 100644 --- a/docs/dev-gate-preflight.md +++ b/docs/dev-gate-preflight.md @@ -67,7 +67,9 @@ 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`. + `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 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 diff --git a/docs/reference/README.md b/docs/reference/README.md index 59e87ed5..9d9691b2 100644 --- a/docs/reference/README.md +++ b/docs/reference/README.md @@ -11,7 +11,7 @@ as run logs. | --- | --- | | Documentation governance | [documentation-governance.md](documentation-governance.md) | | Architecture and M3 decision line | [architecture.md](architecture.md) | -| DEV runtime, ports, k3s, and environment boundary | [dev-runtime-boundary.md](dev-runtime-boundary.md) | +| DEV runtime, ports, k3s, DB DNS, and environment boundary | [dev-runtime-boundary.md](dev-runtime-boundary.md) | | Deployment regularization, artifact publish, rollback, and Cloud Web rollout | [deployment-publish.md](deployment-publish.md) | | Cloud Workbench default UI and UX boundary | [cloud-workbench.md](cloud-workbench.md) | | Commander, runner, PR, and prompt handoff | [commander-collaboration.md](commander-collaboration.md) | diff --git a/docs/reference/dev-runtime-boundary.md b/docs/reference/dev-runtime-boundary.md index 2e24a46a..84c50073 100644 --- a/docs/reference/dev-runtime-boundary.md +++ b/docs/reference/dev-runtime-boundary.md @@ -54,6 +54,36 @@ 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 + +`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: + +| Field | Value | +| --- | --- | +| Service | `cloud-api-db` | +| Namespace | `hwlab-dev` | +| Host | `cloud-api-db.hwlab-dev.svc.cluster.local` | +| Port | `5432` | +| Port name | `postgres` | + +`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. + +`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. + ## Runtime Substitution Ban UniDesk services, provider-gateway, backend-core, microservice proxies, local diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs index 4eb4099c..b6d635c7 100644 --- a/internal/cloud/db-contract.mjs +++ b/internal/cloud/db-contract.mjs @@ -16,8 +16,19 @@ export const DEV_DB_ENV_CONTRACT = Object.freeze({ secretKey: "database-url" }) ]), + dns: Object.freeze({ + serviceName: "cloud-api-db", + namespace: "hwlab-dev", + host: "cloud-api-db.hwlab-dev.svc.cluster.local", + port: 5432, + portName: "postgres" + }), nonSecretDefaults: Object.freeze({ - HWLAB_CLOUD_DB_SSL_MODE: "require" + 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" }), forbidden: Object.freeze({ prodAllowed: false, @@ -112,6 +123,7 @@ export function buildDbEnvManifestPlaceholder() { return { requiredEnv: [...DEV_DB_ENV_CONTRACT.requiredEnv], secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map((item) => ({ ...item })), + dns: { ...DEV_DB_ENV_CONTRACT.dns }, nonSecretDefaults: { ...DEV_DB_ENV_CONTRACT.nonSecretDefaults }, fixtureEvidence: { liveDbEvidence: false, @@ -202,7 +214,7 @@ function buildRedactionSafety(connection) { async function probeDbConnection(env, options) { const timeoutMs = normalizeTimeoutMs(options.timeoutMs ?? env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS); - const parsed = parseDbTarget(env?.HWLAB_CLOUD_DB_URL); + const parsed = parseDbUrlContract(env?.HWLAB_CLOUD_DB_URL); if (!parsed.ok) { return { attempted: true, @@ -226,7 +238,7 @@ async function probeDbConnection(env, options) { }); } -function parseDbTarget(rawUrl) { +export function parseDbUrlContract(rawUrl) { try { const url = new URL(rawUrl); if (!supportedDbProtocols.has(url.protocol)) { diff --git a/scripts/src/deploy-contract-plan.mjs b/scripts/src/deploy-contract-plan.mjs index 7677a17b..fffdaf46 100644 --- a/scripts/src/deploy-contract-plan.mjs +++ b/scripts/src/deploy-contract-plan.mjs @@ -3,6 +3,7 @@ import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { promisify } from "node:util"; +import { DEV_DB_ENV_CONTRACT } from "../../internal/cloud/db-contract.mjs"; const execFileAsync = promisify(execFile); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -135,6 +136,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 ?? {}); 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"); expectEqual(ctx, tunnelClient?.env?.HWLAB_FRP_WEB_PUBLIC_PORT, String(expectedPorts.frontend), "$.services.hwlab-tunnel-client.env.HWLAB_FRP_WEB_PUBLIC_PORT", "tunnel frontend public port env"); @@ -168,6 +170,15 @@ 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 renderPlan(source) { const proxyPorts = [...new Set(source.proxies.map((proxy) => Number(proxy.remotePort)))].sort((a, b) => a - b); return { @@ -265,6 +276,8 @@ function validateK3sArtifacts(ctx, source, rendered, services, workloads, health } } + validateCloudApiDbDnsArtifacts(ctx, services, workloads); + expectEqual(ctx, healthContract.data?.endpoint, source.endpoints.api?.url, "deploy/k8s/dev/health-contract.yaml.data.endpoint", "health contract endpoint"); expectEqual(ctx, masterEdge.endpoint, source.endpoints.api?.url, "deploy/master-edge/health-contract.json.endpoint", "master edge endpoint"); flagLegacyPublicPort(ctx, "deploy/master-edge/health-contract.json.reverseLink.publicPort", masterEdge.reverseLink?.publicPort, expectedPorts.api, "master edge API"); @@ -272,6 +285,32 @@ 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"); + } + } + + 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"); +} + async function buildPlan() { const ctx = { checks: 0, diagnostics: [] }; const [manifest, services, workloads, healthContract, masterEdge, frpc, frps] = await Promise.all([ diff --git a/scripts/src/dev-deploy-apply.mjs b/scripts/src/dev-deploy-apply.mjs index 8f137702..0d5f8949 100755 --- a/scripts/src/dev-deploy-apply.mjs +++ b/scripts/src/dev-deploy-apply.mjs @@ -339,7 +339,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 readiness through health output, without reading secret values."; + return "Configure and verify the DEV cloud-api DB env and stable DNS readiness through health output, without reading secret values."; } return blocker.summary; } @@ -543,7 +543,7 @@ function validateK8s(devKustomization, namespaceDoc, workloads, services, health }; } -async function checkCloudApiDb(deploy, workloads, blockers) { +async function checkCloudApiDb(deploy, workloads, services, blockers) { const deployEnv = deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {}; const envNames = new Set(Object.keys(deployEnv)); const workloadEnv = new Map(); @@ -586,16 +586,22 @@ async function checkCloudApiDb(deploy, workloads, blockers) { const missingSecretRefs = requiredEnv .filter((env) => env.secretRef && !env.secretRef.present) .map((env) => `${env.secretRef.secretName}/${env.secretRef.secretKey}`); - if (missingManifest.length > 0 || missingK8s.length > 0 || missingSecretRefs.length > 0) { + const dnsContract = inspectCloudApiDbDnsContract(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 contract is incomplete; missing ${[...missingManifest, ...missingK8s, ...missingSecretRefs].join(", ")}` + `cloud-api DEV DB env/DNS contract is incomplete; missing ${[...missingManifest, ...missingK8s, ...missingSecretRefs, ...missingDnsContract].join(", ")}` ); } - const configReady = missingManifest.length === 0 && missingK8s.length === 0 && missingSecretRefs.length === 0; + const configReady = + missingManifest.length === 0 && + missingK8s.length === 0 && + missingSecretRefs.length === 0 && + missingDnsContract.length === 0; const health = { status: configReady ? "degraded" : "blocked", ready: false, @@ -628,12 +634,71 @@ async function checkCloudApiDb(deploy, workloads, blockers) { missingManifest, missingK8s, missingSecretRefs, + missingDnsContract, + dns: dnsContract, runtimeHealth: summarizeDbContract(health), secretMaterialRead: false, valuesRedacted: true, liveDbEvidence: false, fixtureEvidence: false, - note: "This check records DB env/Secret reference presence only. It is not live DB evidence." + note: "This check records DB env, Secret reference, and stable DNS Service presence only. It 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; + const actual = { + deployServiceName: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAME, + deployServiceNamespace: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, + deployHost: deployEnv.HWLAB_CLOUD_DB_HOST, + deployPort: deployEnv.HWLAB_CLOUD_DB_PORT, + workloadServiceName: workloadEnv.get("HWLAB_CLOUD_DB_SERVICE_NAME")?.value, + 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 + }; + 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"}`); + + return { + ready: missing.length === 0, + host: expectedHost, + serviceName: dns.serviceName, + namespace: dns.namespace, + port: dns.port, + selectorless: service ? service.spec?.selector === undefined : false, + secretMaterialRead: false, + liveDbEvidence: false, + missing, + actual }; } @@ -937,7 +1002,7 @@ export async function runDevDeployApply(argv, io = {}) { const artifactEvidence = validateDeployAndCatalog(deploy, catalog, commitId, blockers); const k8sManifest = validateK8s(devKustomization, namespaceDoc, workloads, services, healthContract, deploy, blockers); - const cloudApiDb = await checkCloudApiDb(deploy, workloads, blockers); + const cloudApiDb = await checkCloudApiDb(deploy, workloads, services, blockers); if (!(await readText("internal/cloud/server.mjs")).includes('url.pathname === "/health/live"')) { addBlocker(blockers, "contract_blocker", "cloud-api-health-path", "cloud-api does not implement the k8s /health/live path"); } diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index 904d1350..8099eb77 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -805,8 +805,8 @@ function validateEdgeHealthReport(reporter, edgeReport) { }); } -function validateCloudApiDbContract(reporter, deploy, workloads) { - const staticContract = inspectCloudApiDbStaticContract(deploy, workloads); +function validateCloudApiDbContract(reporter, deploy, workloads, services) { + const staticContract = inspectCloudApiDbStaticContract(deploy, workloads, services); const healthContract = summarizeDbContract(buildDbHealthContract()); const evidence = [ { @@ -822,14 +822,15 @@ function validateCloudApiDbContract(reporter, deploy, workloads) { "cloud-api-db-env-contract", "db", "pass", - "cloud-api DEV DB manifest declares the required env names and redacted Secret reference.", + "cloud-api DEV DB manifest declares the required env names, redacted Secret reference, and stable k3s DB DNS Service.", evidence ); } else { const missing = [ ...staticContract.missingDeployEnv, ...staticContract.missingK8sEnv, - ...staticContract.missingSecretRefs + ...staticContract.missingSecretRefs, + ...staticContract.missingDnsContract ]; reporter.check( "cloud-api-db-env-contract", @@ -842,7 +843,7 @@ function validateCloudApiDbContract(reporter, deploy, workloads) { 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 only the required DB env names and redacted Secret reference." + 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." }); } @@ -872,7 +873,7 @@ function validateCloudApiDbContract(reporter, deploy, workloads) { }); } -function inspectCloudApiDbStaticContract(deploy, workloads) { +function inspectCloudApiDbStaticContract(deploy, workloads, services) { const cloudApi = deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api") ?? {}; const deployEnv = cloudApi.env ?? {}; const workloadEnv = getWorkloadEnvByServiceId(workloads, "hwlab-cloud-api"); @@ -909,15 +910,22 @@ function inspectCloudApiDbStaticContract(deploy, workloads) { const missingSecretRefs = fields .filter((field) => field.secretRef && !field.secretRef.present) .map((field) => `${field.secretRef.secretName}/${field.secretRef.secretKey}`); + const dnsContract = inspectCloudApiDbDnsContract(deployEnv, workloadEnv, services); return { contractVersion: DEV_DB_ENV_CONTRACT.contractVersion, environment: DEV_DB_ENV_CONTRACT.environment, - ready: missingDeployEnv.length === 0 && missingK8sEnv.length === 0 && missingSecretRefs.length === 0, + ready: + missingDeployEnv.length === 0 && + missingK8sEnv.length === 0 && + missingSecretRefs.length === 0 && + dnsContract.ready, requiredEnv: fields, + dns: dnsContract, missingDeployEnv, missingK8sEnv, missingSecretRefs, + missingDnsContract: dnsContract.missing, secretMaterialRead: false, valuesRedacted: true, liveDbEvidence: false, @@ -925,6 +933,64 @@ function inspectCloudApiDbStaticContract(deploy, workloads) { }; } +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; + const actual = { + deployServiceName: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAME, + deployServiceNamespace: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, + deployHost: deployEnv.HWLAB_CLOUD_DB_HOST, + deployPort: deployEnv.HWLAB_CLOUD_DB_PORT, + workloadServiceName: workloadEnv.get("HWLAB_CLOUD_DB_SERVICE_NAME")?.value, + 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 + }; + 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"}`); + + 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, + secretMaterialRead: false, + liveDbEvidence: false, + missing, + actual + }; +} + function getWorkloadEnvByServiceId(workloads, serviceId) { const envByName = new Map(); for (const item of listManifestItems(workloads)) { @@ -1174,7 +1240,7 @@ export async function runPreflight(argv) { ); validateRegistryCapabilities(reporter, registryCapabilities); - validateCloudApiDbContract(reporter, deploy, contracts[3]); + validateCloudApiDbContract(reporter, deploy, contracts[3], contracts[4]); validateArtifactPublishReport(reporter, optionalReports.artifactPublish, artifactIdentity, targetShortCommit, targetCommit, args.targetRef); validateEdgeHealthReport(reporter, optionalReports.edgeHealth); await validateEdgeContracts(reporter, masterEdge); diff --git a/scripts/validate-contract.mjs b/scripts/validate-contract.mjs index ea247c9b..00ec16ae 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -3,6 +3,10 @@ import assert from "node:assert/strict"; import { readdir, readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { + DEV_DB_ENV_CONTRACT, + parseDbUrlContract +} from "../internal/cloud/db-contract.mjs"; import { ENVIRONMENT_DEV, SERVICE_IDS, @@ -53,6 +57,14 @@ async function parseDeployManifest(relativePath) { return doc; } +async function parseK8sList(relativePath) { + const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); + const doc = JSON.parse(raw); + assert.equal(doc.kind, "List", `${relativePath} kind`); + assert.ok(Array.isArray(doc.items), `${relativePath} items`); + return doc; +} + function assertUnique(name, values) { assert.equal(new Set(values).size, values.length, `${name} must be unique`); } @@ -122,6 +134,8 @@ function assertEnvelopeValidation() { const schemas = await parseJSONSchemaFiles("protocol/schemas"); const deploySchemas = await parseJSONSchemaFiles("deploy"); const deployManifest = await parseDeployManifest("deploy/deploy.json"); +const k8sServices = await parseK8sList("deploy/k8s/base/services.yaml"); +const k8sWorkloads = await parseK8sList("deploy/k8s/base/workloads.yaml"); const docsByName = new Map([...schemas, ...deploySchemas].map((item) => [path.basename(item.relativePath), item.doc])); assert.ok(schemas.length >= 13, "expected protocol schemas"); @@ -137,6 +151,7 @@ assert.equal(deployServicesById.has("hwlab-agent-worker"), true); assert.equal(deployServicesById.has("hwlab-agent-skills"), true); const cloudApi = deployServicesById.get("hwlab-cloud-api"); +const cloudApiWorkloadEnv = getWorkloadEnv(k8sWorkloads, "hwlab-cloud-api"); assert.equal(deployManifest.health.path, "/health/live", "deploy health source path"); assert.equal(deployManifest.publicEndpoints.frontend.url, "http://74.48.78.17:16666", "deploy frontend endpoint"); assert.equal(deployManifest.publicEndpoints.api.url, "http://74.48.78.17:16667", "deploy api endpoint"); @@ -153,8 +168,60 @@ 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(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"); +assertCloudApiDbDnsContract(k8sServices, cloudApi.env); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_PROVIDER, "openai", "cloud-api Code Agent provider"); assert.equal(cloudApi.env.HWLAB_CODE_AGENT_MODEL, "gpt-5.5", "cloud-api Code Agent model"); assert.equal(cloudApi.env.OPENAI_API_KEY, "secretRef:hwlab-code-agent-provider/openai-api-key", "cloud-api Code Agent OpenAI key must be a Secret reference placeholder"); console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`); + +function listItems(document) { + return document?.kind === "List" ? document.items ?? [] : [document].filter(Boolean); +} + +function getWorkloadEnv(workloads, serviceId) { + for (const item of listItems(workloads)) { + const itemServiceId = + item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || + item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || + item?.metadata?.name; + if (itemServiceId !== serviceId) { + continue; + } + const container = item?.spec?.template?.spec?.containers?.find((entry) => entry.name === serviceId); + return Object.fromEntries((container?.env ?? []).map((entry) => [entry.name, entry])); + } + 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"); +} diff --git a/scripts/validate-runtime-boundary.mjs b/scripts/validate-runtime-boundary.mjs index 6ca9ff01..0ef5a9b9 100644 --- a/scripts/validate-runtime-boundary.mjs +++ b/scripts/validate-runtime-boundary.mjs @@ -3,6 +3,7 @@ import assert from "node:assert/strict"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { DEV_DB_ENV_CONTRACT } from "../internal/cloud/db-contract.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".."); @@ -13,6 +14,7 @@ const forbiddenRuntimeSubstitutes = Object.freeze([ "provider-gateway", "microservice-proxy" ]); +const dbDnsComponent = "cloud-api-db"; async function readText(relativePath) { return readFile(path.join(repoRoot, relativePath), "utf8"); @@ -130,6 +132,24 @@ function assertMetadataServiceId(resource, expectedServiceId, 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`); @@ -291,11 +311,16 @@ function assertServices(services, guard) { assert.equal(services.kind, "List", "services kind"); assertArray(services.items, "services items"); - const serviceIds = services.items.map((item) => item.metadata?.labels?.[serviceIdLabel]); + const runtimeServices = services.items.filter((item) => item.metadata?.name !== dbDnsComponent); + const serviceIds = runtimeServices.map((item) => item.metadata?.labels?.[serviceIdLabel]); assertUnique(serviceIds, "k8s service ids"); assertSameMembers(serviceIds, guard.requiredK3sSkeleton.requiredServiceServiceIds, "k8s service ids"); - for (const service of services.items) { + 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); + + for (const service of runtimeServices) { const serviceId = service.metadata?.labels?.[serviceIdLabel]; assertServiceId(serviceId, `k8s service ${service.metadata?.name}`); assertMetadataServiceId(service, serviceId, `k8s service ${serviceId}`);