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 cbbf522c..679c208f 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 3ec3aba2..b7d7d4f7 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 f851d324..bb501234 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 e642af6b..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 @@ -42,7 +44,15 @@ names so gate reports can identify `cloud-api-db-health-gate`. When the env is present but no live DB probe is attempted, the gate remains blocked with `connectionResult: "not_attempted"`. When a probe is attempted but `liveConnected` is false, the gate remains blocked/degraded with the redacted -connection classifier. Env presence alone is not live DB evidence. +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. + +The Code Agent provider Secret for #143 is a separate blocker. The edge-health +report may record only key-presence evidence for +`hwlab-code-agent-provider/openai-api-key` and runtime error classification such +as `provider_secret_missing`; it must not read or print provider Secret values. +Do not collapse this #143 provider Secret gap into the DB live blocker. ## Route Classification @@ -75,9 +85,18 @@ Blocked `frp_blocker` and `edge_proxy_blocker` reports now include a The committed report at `reports/dev-gate/dev-edge-health.json` records a read-only live run where public `http://74.48.78.17:16667/health/live` returned -HWLAB DEV health from `hwlab-cloud-api`, DB config was present, but runtime -health still reported no live DB connection attempt/result from the deployed -image. Classification: `app_health_blocker` with likely layer `cloud-api-db`. +HWLAB DEV health from `hwlab-cloud-api`, DB config was present, and runtime +health attempted a redacted TCP DB probe. The probe was not connected: +`connected=false`, `liveConnected=false`, `liveDbEvidence=false`, +`connectionResult="dns_error"`, and +`classification="dns_resolution_failed"`. This is a DB live readiness blocker +for #49, not proof that DB is connected. + +The same report records the #143 Code Agent provider Secret gap separately as +`provider_secret_missing` when key-presence evidence for +`hwlab-code-agent-provider/openai-api-key` is absent. DB live readiness, +provider-backed Code Agent chat, M3 hardware trusted-loop proof, M4 live agent +loop, and M5 DEV-LIVE acceptance remain separate evidence layers. ## DEV FRP Contract diff --git a/docs/dev-evidence-blocker-aggregator.md b/docs/dev-evidence-blocker-aggregator.md index 14e8abd7..9d795214 100644 --- a/docs/dev-evidence-blocker-aggregator.md +++ b/docs/dev-evidence-blocker-aggregator.md @@ -50,7 +50,8 @@ without promoting the full gate: | --- | --- | | `Frontend DEV revision` | Latest accepted Cloud Workbench frontend revision on the active `http://74.48.78.17:16666/` browser endpoint. Frontend revision evidence is browser-only and cannot green DB, M3, M4, or M5. | | `EDGE/ROUTE live` | `16666` browser and `16667` health route evidence from read-only probes. | -| `DB live/degraded` | Cloud API DB readiness from redacted health evidence; route success does not imply DB live. | +| `DB live/degraded` | Cloud API DB readiness from redacted health evidence; route success and env presence do not imply DB live. A `dns_error`/`dns_resolution_failed` DB probe is a DB live blocker. | +| `Code Agent provider Secret` | #143 provider-backed chat readiness from redacted runtime error and Secret key-presence evidence only; missing provider Secret is independent from DB live. | | `M3 hardware trusted loop` | Real DEV `res_boxsimu_1:DO1 -> hwlab-patch-panel -> res_boxsimu_2:DI1` plus operation, trace, audit, and evidence identifiers. | | `M4 agent loop` | Agent loop live preflight and scheduling/evidence closure, gated by DB and runtime readiness. | | `artifact/desired-state source` | Artifact source coverage for the current target ref plus desired-state dry-run/apply status. | @@ -100,9 +101,9 @@ The v2 report reads committed report fixtures only: The report remains blocked until real DEV-LIVE evidence is attached for the required downstream HWLAB runtime services. Public route evidence on -`16666/16667` is necessary but not sufficient: DB live readiness, M3 trusted -loop evidence, M4 agent-loop evidence, and artifact/desired-state source -coverage stay separate. +`16666/16667` is necessary but not sufficient: DB live readiness, #143 Code +Agent provider Secret readiness, M3 trusted loop evidence, M4 agent-loop +evidence, and artifact/desired-state source coverage stay separate. The legacy base-image blocker is not current when the committed reports show an approved Node 20 builder base. Artifact/current remains stricter: a target ref diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index 47d62b6a..9671fba8 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/reports/dev-gate/dev-edge-health.json b/reports/dev-gate/dev-edge-health.json index 9396ac60..3001da5f 100644 --- a/reports/dev-gate/dev-edge-health.json +++ b/reports/dev-gate/dev-edge-health.json @@ -4,12 +4,20 @@ "reportVersion": "v1", "issue": "pikasTech/HWLAB#36", "taskId": "dev-edge-health", - "commitId": "86d769525caf", + "commitId": "c7de4745f491", "acceptanceLevel": "dev_edge_health", "devOnly": true, "prodDisabled": true, + "reportLifecycle": { + "version": "v1", + "state": "active", + "activeEndpoint": "http://74.48.78.17:16667", + "activeBrowserEndpoint": "http://74.48.78.17:16666", + "deprecatedEndpoint": null, + "summary": "Current DEV edge-health report; deprecated public 6666/6667 endpoints are not valid active evidence." + }, "status": "blocked", - "generatedAt": "2026-05-22T04:30:36.613Z", + "generatedAt": "2026-05-22T13:45:42.982Z", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:16667", "sourceContract": { @@ -28,6 +36,7 @@ "node scripts/validate-dev-gate-report.mjs", "node --check scripts/dev-edge-health-smoke.mjs", "node --check scripts/src/dev-edge-health-smoke-lib.mjs", + "node --test scripts/src/dev-edge-health-smoke-lib.test.mjs", "node scripts/dev-edge-health-smoke.mjs --live --write-report" ], "localSmoke": { @@ -58,18 +67,50 @@ "hwlab-dev hwlab-edge-proxy, hwlab-tunnel-client, hwlab-router, and hwlab-cloud-api are observable", "No PROD endpoint, secret read, restart, or UniDesk runtime substitute is used" ], - "summary": "cloud-api DB env is injected, but runtime health has not attempted a live DB connection" + "summary": "DB endpoint DNS resolution failed from the cloud-api runtime" }, "blockers": [ { "type": "runtime_blocker", "scope": "cloud-api-db", "status": "open", - "summary": "cloud-api DB env is injected, but runtime health has not attempted a live DB connection" + "summary": "DB endpoint DNS resolution failed from the cloud-api runtime", + "classification": "dns_resolution_failed", + "sourceIssue": "pikasTech/HWLAB#49", + "impact": { + "blocks": [ + "DB live readiness", + "M4 live agent scheduling precondition", + "M5 DEV-LIVE acceptance" + ], + "separateFrom": [ + "pikasTech/HWLAB#143 provider Secret", + "M3 hardware trusted-loop proof" + ] + } + }, + { + "type": "agent_blocker", + "scope": "code-agent-provider-secret", + "status": "open", + "summary": "Code Agent provider Secret hwlab-code-agent-provider/openai-api-key is not present as key-presence evidence; #143 real provider-backed chat remains blocked", + "classification": "provider_secret_missing", + "sourceIssue": "pikasTech/HWLAB#143", + "impact": { + "blocks": [ + "#143 provider-backed Code Agent chat", + "M4 provider-backed agent execution", + "M5 DEV-LIVE acceptance" + ], + "separateFrom": [ + "pikasTech/HWLAB#49 DB live readiness", + "M3 hardware trusted-loop proof" + ] + } } ], "edgeHealth": { - "generatedAt": "2026-05-22T04:30:36.613Z", + "generatedAt": "2026-05-22T13:45:42.982Z", "mode": "live-read-only", "endpoint": "http://74.48.78.17:16667", "safety": { @@ -101,7 +142,7 @@ "serviceCount": 13, "edgeProxy": { "serviceId": "hwlab-edge-proxy", - "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:73b379f", + "image": "127.0.0.1:5000/hwlab/hwlab-edge-proxy:7e29522", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -113,7 +154,7 @@ }, "tunnelClient": { "serviceId": "hwlab-tunnel-client", - "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:73b379f", + "image": "127.0.0.1:5000/hwlab/hwlab-tunnel-client:7e29522", "namespace": "hwlab-dev", "healthPath": "/health/live", "profile": "dev", @@ -134,6 +175,7 @@ "connectionChecked": false, "connectionAttempted": false, "connectionResult": "manifest_only_not_attempted", + "liveDbEvidence": false, "requiredEnv": [ { "name": "HWLAB_CLOUD_DB_URL", @@ -173,14 +215,46 @@ "durationMs": 0, "errorCode": null }, + "redaction": { + "valuesRedacted": true, + "endpointRedacted": true, + "valueRedacted": true, + "secretMaterialRead": false, + "secretRefsOnly": true + }, "blocker": "Manifest declares DB env/Secret references but does not prove runtime env injection or live DB connection", - "liveDbEvidence": false, "fixtureEvidence": false, "manifestConfigReady": true, "envInjected": false, "secretMaterialRead": false, "valuesRedacted": true, "note": "Manifest-only DB env placeholder; this is not runtime env injection or live DB evidence." + }, + "codeAgentProvider": { + "status": "degraded", + "sourceIssue": "pikasTech/HWLAB#143", + "requiredEnv": [ + { + "name": "OPENAI_API_KEY", + "present": true, + "redacted": true, + "source": "k8s-secret-ref" + } + ], + "secretRef": { + "env": "OPENAI_API_KEY", + "secretName": "hwlab-code-agent-provider", + "secretKey": "openai-api-key", + "present": true, + "envInjected": false, + "secretPresent": "not_observed_by_manifest", + "secretKeyPresent": "declared", + "secretValueRead": false, + "redacted": true + }, + "secretMaterialRead": false, + "valuesRedacted": true, + "note": "Manifest-only Code Agent provider Secret reference; this is not runtime Secret injection or provider connectivity evidence." } } }, @@ -189,19 +263,19 @@ "host": "74.48.78.17", "port": 16667, "status": "connected", - "durationMs": 228 + "durationMs": 206 }, { "host": "74.48.78.17", "port": 7000, "status": "connected", - "durationMs": 212 + "durationMs": 218 }, { "host": "74.48.78.17", "port": 7402, "status": "connected", - "durationMs": 216 + "durationMs": 207 } ], "publicHttp": [ @@ -214,7 +288,7 @@ "connection": "keep-alive", "content-length": "661", "content-type": "application/json; charset=utf-8", - "date": "Fri, 22 May 2026 04:30:35 GMT", + "date": "Fri, 22 May 2026 13:45:42 GMT", "keep-alive": "timeout=5" }, "json": { @@ -237,14 +311,14 @@ "digest": "unknown" }, "endpoint": "http://74.48.78.17:16667", - "observedAt": "2026-05-22T04:30:35.063Z", + "observedAt": "2026-05-22T13:45:42.602Z", "details": { "upstream": "http://hwlab-cloud-api.hwlab-dev.svc.cluster.local:6667", "mode": "dev-edge-proxy" } }, "bodyPreview": "{\n \"serviceId\": \"hwlab-edge-proxy\",\n \"environment\": \"dev\",\n \"status\": \"ok\",\n \"service\": {\n \"id\": \"hwlab-edge-proxy\",\n \"role\": \"public-dev-ingress\",\n \"healthPath\": \"/health\",\n \"livePath\": \"/health/live\"\n },\n \"commit\": {\n \"id\": \"cb35ada6860653b27269b0a57991184118cbf4b1\",\n \"source\": \"runtime-env\"\n },\n \"image\": {\n \"reference\": \"ghcr.io/pikastech/hwlab-edge-proxy:cb35ada\",\n \"tag\": \"cb35ada\",\n \"digest\": \"unknown\"\n },\n \"endpoint\": \"http://74.48.78.17:16667\",\n \"obse", - "durationMs": 2000 + "durationMs": 837 }, { "url": "http://74.48.78.17:16667/health/live", @@ -255,7 +329,7 @@ "cache-control": "no-store", "connection": "keep-alive", "content-type": "application/json; charset=utf-8", - "date": "Fri, 22 May 2026 04:30:35 GMT", + "date": "Fri, 22 May 2026 13:45:42 GMT", "keep-alive": "timeout=5", "transfer-encoding": "chunked" }, @@ -270,25 +344,29 @@ "livePath": "/health/live" }, "commit": { - "id": "cb35ada", + "id": "c7de474", "source": "runtime-env" }, "image": { - "reference": "127.0.0.1:5000/hwlab/hwlab-cloud-api:cb35ada", - "tag": "cb35ada", + "reference": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474", + "tag": "c7de474", "digest": "unknown" }, "endpoint": "http://74.48.78.17:16667", - "observedAt": "2026-05-22T04:30:35.064Z", + "observedAt": "2026-05-22T13:45:42.604Z", "db": { "contractVersion": "v1", "environment": "dev", "connected": false, - "connectionChecked": false, + "liveConnected": false, + "liveDbEvidence": false, + "connectionChecked": true, + "connectionAttempted": true, + "connectionResult": "dns_error", "configReady": true, "ready": false, "status": "degraded", - "mode": "configured_without_live_connection", + "mode": "live_connection_blocked", "fields": [ { "name": "HWLAB_CLOUD_DB_URL", @@ -312,17 +390,44 @@ "secretName": "hwlab-cloud-api-dev-db", "secretKey": "database-url", "present": true, + "envInjected": true, + "secretPresent": "not_observed_by_runtime", + "secretKeyPresent": "not_observed_by_runtime", "redacted": true } ], + "connection": { + "attempted": true, + "networkAttempted": true, + "probeType": "tcp-connect", + "endpointRedacted": true, + "valueRedacted": true, + "timeoutMs": 1200, + "durationMs": 1, + "missingEnv": [], + "result": "dns_error", + "classification": "dns_resolution_failed", + "errorCode": "ENOTFOUND" + }, + "redaction": { + "valuesRedacted": true, + "endpointRedacted": true, + "valueRedacted": true, + "secretMaterialRead": false, + "secretRefsOnly": true + }, "safety": { "devOnly": true, "prodAllowed": false, "secretsRead": false, + "secretMaterialRead": false, "valuesRedacted": true, - "liveDbEvidence": false + "endpointRedacted": true, + "liveDbEvidence": false, + "liveDbConnectedEvidence": false }, - "evidence": "env_presence_only_no_live_db" + "blocker": "DB endpoint DNS resolution failed from the cloud-api runtime", + "evidence": "live_db_tcp_connection_blocked" }, "runtime": { "adapter": "memory", @@ -339,8 +444,8 @@ } } }, - "bodyPreview": "{\"serviceId\":\"hwlab-cloud-api\",\"environment\":\"dev\",\"status\":\"degraded\",\"service\":{\"id\":\"hwlab-cloud-api\",\"role\":\"cloud-api\",\"healthPath\":\"/health\",\"livePath\":\"/health/live\"},\"commit\":{\"id\":\"cb35ada\",\"source\":\"runtime-env\"},\"image\":{\"reference\":\"127.0.0.1:5000/hwlab/hwlab-cloud-api:cb35ada\",\"tag\":\"cb35ada\",\"digest\":\"unknown\"},\"endpoint\":\"http://74.48.78.17:16667\",\"observedAt\":\"2026-05-22T04:30:35.064Z\",\"db\":{\"contractVersion\":\"v1\",\"environment\":\"dev\",\"connected\":false,\"connectionChecked\":false,\"c", - "durationMs": 820 + "bodyPreview": "{\"serviceId\":\"hwlab-cloud-api\",\"environment\":\"dev\",\"status\":\"degraded\",\"service\":{\"id\":\"hwlab-cloud-api\",\"role\":\"cloud-api\",\"healthPath\":\"/health\",\"livePath\":\"/health/live\"},\"commit\":{\"id\":\"c7de474\",\"source\":\"runtime-env\"},\"image\":{\"reference\":\"127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474\",\"tag\":\"c7de474\",\"digest\":\"unknown\"},\"endpoint\":\"http://74.48.78.17:16667\",\"observedAt\":\"2026-05-22T13:45:42.604Z\",\"db\":{\"contractVersion\":\"v1\",\"environment\":\"dev\",\"connected\":false,\"liveConnected\":false,\"liveD", + "durationMs": 810 } ], "kubernetes": { @@ -429,7 +534,7 @@ { "name": "hwlab-cloud-api", "readyAddresses": [ - "10.42.0.201" + "10.42.0.226" ], "notReadyAddresses": [], "ports": [ @@ -537,7 +642,7 @@ ] }, { - "name": "hwlab-cloud-api-f48dcdfb-9n6zh", + "name": "hwlab-cloud-api-74c969765d-kzvdd", "phase": "Running", "serviceId": "hwlab-cloud-api", "containers": [ @@ -545,13 +650,13 @@ "name": "hwlab-cloud-api", "ready": true, "restartCount": 0, - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:cb35ada", - "imageID": "127.0.0.1:5000/hwlab/hwlab-cloud-api@sha256:3946efdd7360409cea6a897cb03a6f3172f14819d3132df3aab821621909513f" + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-api:c7de474", + "imageID": "127.0.0.1:5000/hwlab/hwlab-cloud-api@sha256:86fe92c0dd5d5883851b38805e6f5acc49d2876552599c18f85480cf0d3bf981" } ] }, { - "name": "hwlab-cloud-web-cd9655b98-gxtmc", + "name": "hwlab-cloud-web-7d48b84f4b-n6hw6", "phase": "Running", "serviceId": "hwlab-cloud-web", "containers": [ @@ -559,8 +664,8 @@ "name": "hwlab-cloud-web", "ready": true, "restartCount": 0, - "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:cb35ada", - "imageID": "127.0.0.1:5000/hwlab/hwlab-cloud-web@sha256:868e2a11c56cc1a27c77de3fa71a7b092f21ae98a4052104e9071bd93960abfa" + "image": "127.0.0.1:5000/hwlab/hwlab-cloud-web:c7de474", + "imageID": "127.0.0.1:5000/hwlab/hwlab-cloud-web@sha256:91cfacc1fc011a96bb47ff00d2ab09ee3c93003b7ec482308ec5ac35004e5627" } ] }, @@ -660,7 +765,22 @@ "command": "kubectl describe secret (key-presence-only)", "error": null }, - "notes": [] + "codeAgentProviderSecret": { + "observable": true, + "sourceIssue": "pikasTech/HWLAB#143", + "env": "OPENAI_API_KEY", + "secretName": "hwlab-code-agent-provider", + "secretKey": "openai-api-key", + "secretPresent": false, + "secretKeyPresent": false, + "secretValueRead": false, + "redacted": true, + "command": "kubectl get secret -o name", + "error": "Error from server (NotFound): secrets \"hwlab-code-agent-provider\" not found" + }, + "notes": [ + "kubectl Code Agent provider Secret presence probe failed: Error from server (NotFound): secrets \"hwlab-code-agent-provider\" not found" + ] }, "clusterDns": [ { @@ -709,19 +829,21 @@ "secretRefPresent": true, "envInjected": true }, - "connectionAttempted": false, - "connectionResult": "not_attempted", + "connectionAttempted": true, + "connectionResult": "dns_error", + "connectionClassification": "dns_resolution_failed", "liveConnected": false, "valuesRedacted": true, "secretMaterialRead": false, "liveDbEvidence": false, - "blocker": "cloud-api DB env is injected, but runtime health has not attempted a live DB connection", + "blocker": "DB endpoint DNS resolution failed from the cloud-api runtime", "evidence": [ { "configReady": true, "missingEnv": [], - "connectionAttempted": false, - "connectionResult": "not_attempted", + "connectionAttempted": true, + "connectionResult": "dns_error", + "classification": "dns_resolution_failed", "liveConnected": false, "endpointRedacted": true, "valueRedacted": true @@ -730,35 +852,127 @@ }, "status": "blocker", "classification": "app_health_blocker", - "blocker": "cloud-api DB env is injected, but runtime health has not attempted a live DB connection", + "blocker": "DB endpoint DNS resolution failed from the cloud-api runtime", "diagnosis": { "likelyLayer": "cloud-api-db", "confidence": "high", - "likelyCause": "cloud-api DB env is injected, but runtime health has not attempted a live DB connection", + "likelyCause": "DB endpoint DNS resolution failed from the cloud-api runtime", "evidence": [ { "configReady": true, "missingEnv": [], - "connectionAttempted": false, - "connectionResult": "not_attempted", + "connectionAttempted": true, + "connectionResult": "dns_error", + "classification": "dns_resolution_failed", "liveConnected": false, "endpointRedacted": true, "valueRedacted": true } ], "notProven": [ - "live DB endpoint reachability", "authenticated SQL query readiness" ], "nextTask": "Deploy cloud-api DB runtime readiness probe and/or repair DEV DB connectivity, then rerun the read-only health smoke without reading or printing the DB secret value." + }, + "providerSecretReadiness": { + "status": "blocked", + "sourceIssue": "pikasTech/HWLAB#143", + "provider": "openai-responses", + "requiredEnv": "OPENAI_API_KEY", + "manifestRefPresent": true, + "secret": { + "observable": true, + "sourceIssue": "pikasTech/HWLAB#143", + "env": "OPENAI_API_KEY", + "secretName": "hwlab-code-agent-provider", + "secretKey": "openai-api-key", + "secretPresent": false, + "secretKeyPresent": false, + "secretValueRead": false, + "redacted": true, + "command": "kubectl get secret -o name", + "error": "Error from server (NotFound): secrets \"hwlab-code-agent-provider\" not found" + }, + "providerCallAttempted": false, + "providerConnected": false, + "secretMaterialRead": false, + "valuesRedacted": true, + "classification": "provider_secret_missing", + "blocker": "Code Agent provider Secret hwlab-code-agent-provider/openai-api-key is not present as key-presence evidence; #143 real provider-backed chat remains blocked", + "evidence": [ + { + "source": "kubernetes-secret-presence", + "manifestRefPresent": true, + "secretPresent": false, + "secretKeyPresent": false, + "secretValueRead": false, + "redacted": true + } + ] + }, + "parallelBlockers": [ + { + "id": "cloud-api-db-live", + "type": "runtime_blocker", + "scope": "cloud-api-db", + "status": "open", + "sourceIssue": "pikasTech/HWLAB#49", + "classification": "dns_resolution_failed", + "summary": "DB endpoint DNS resolution failed from the cloud-api runtime", + "impact": { + "blocks": [ + "DB live readiness", + "M4 live agent scheduling precondition", + "M5 DEV-LIVE acceptance" + ], + "separateFrom": [ + "pikasTech/HWLAB#143 provider Secret", + "M3 hardware trusted-loop proof" + ] + } + }, + { + "id": "code-agent-provider-secret", + "type": "agent_blocker", + "scope": "code-agent-provider-secret", + "status": "open", + "sourceIssue": "pikasTech/HWLAB#143", + "classification": "provider_secret_missing", + "summary": "Code Agent provider Secret hwlab-code-agent-provider/openai-api-key is not present as key-presence evidence; #143 real provider-backed chat remains blocked", + "impact": { + "blocks": [ + "#143 provider-backed Code Agent chat", + "M4 provider-backed agent execution", + "M5 DEV-LIVE acceptance" + ], + "separateFrom": [ + "pikasTech/HWLAB#49 DB live readiness", + "M3 hardware trusted-loop proof" + ] + } + } + ], + "milestoneImpact": { + "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." + }, + "M4": { + "status": "blocked", + "blockedBy": [ + "cloud-api-db-live", + "code-agent-provider-secret" + ], + "summary": "M4 live agent-loop remains blocked until DB live readiness and provider-backed agent prerequisites are independently green." + }, + "M5": { + "status": "blocked", + "blockedBy": [ + "cloud-api-db-live", + "code-agent-provider-secret" + ], + "summary": "M5 DEV-LIVE acceptance cannot be promoted from route health, env presence, or provider manifest refs while DB live and #143 provider Secret blockers remain open." + } } - }, - "reportLifecycle": { - "version": "v1", - "state": "active", - "activeEndpoint": "http://74.48.78.17:16667", - "activeBrowserEndpoint": "http://74.48.78.17:16666", - "deprecatedEndpoint": null, - "summary": "Current DEV edge-health report; deprecated public 6666/6667 endpoints are not valid active evidence." } } 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-edge-health-smoke-lib.mjs b/scripts/src/dev-edge-health-smoke-lib.mjs index cf644b63..61432675 100644 --- a/scripts/src/dev-edge-health-smoke-lib.mjs +++ b/scripts/src/dev-edge-health-smoke-lib.mjs @@ -15,6 +15,12 @@ const publicHost = "74.48.78.17"; const publicPort = 16667; const namespace = "hwlab-dev"; const tcpPorts = [16667, 7000, 7402]; +const CODE_AGENT_PROVIDER_SECRET_CONTRACT = Object.freeze({ + sourceIssue: "pikasTech/HWLAB#143", + env: "OPENAI_API_KEY", + secretName: "hwlab-code-agent-provider", + secretKey: "openai-api-key" +}); const runtimeServices = [ { serviceId: "hwlab-cloud-api", port: 6667 }, { serviceId: "hwlab-router", port: 7401 }, @@ -69,11 +75,24 @@ export async function runDevEdgeHealthSmoke(argv) { }; edgeHealth.runtimeDbReadiness = inspectRuntimeDbReadiness(edgeHealth); Object.assign(edgeHealth, classify(edgeHealth)); + edgeHealth.providerSecretReadiness = inspectProviderSecretReadiness(edgeHealth); + edgeHealth.parallelBlockers = classifyParallelBlockers(edgeHealth); + if (edgeHealth.status === "pass" && edgeHealth.parallelBlockers.length > 0) { + Object.assign(edgeHealth, classifyParallelBlockerStatus(edgeHealth.parallelBlockers)); + } + edgeHealth.milestoneImpact = classifyMilestoneImpact(edgeHealth); const report = await createDevGateReport(edgeHealth); await maybeWriteReport(report, args); return { report, exitCode: edgeHealth.status === "pass" ? 0 : 2 }; } +export const __testHooks = Object.freeze({ + classifyMilestoneImpact, + classifyParallelBlockers, + inspectProviderSecretReadiness, + summarizeRuntimeDbReadiness +}); + function parseArgs(argv) { const args = { live: false, @@ -106,8 +125,7 @@ function nowIso() { async function createDevGateReport(edgeHealth) { const commitId = await gitCommitId(); const status = edgeHealth.status === "pass" ? "pass" : edgeHealth.status === "not_run" ? "not_run" : "blocked"; - const blockerType = status === "blocked" ? blockerTypeFor(edgeHealth.classification) : null; - const blockerScope = edgeHealth.diagnosis?.likelyLayer ?? edgeHealth.classification; + const blockers = reportBlockers(edgeHealth, status); return { $schema: "https://hwlab.pikastech.local/schemas/dev-gate-report.schema.json", @@ -140,6 +158,7 @@ async function createDevGateReport(edgeHealth) { "node scripts/validate-dev-gate-report.mjs", "node --check scripts/dev-edge-health-smoke.mjs", "node --check scripts/src/dev-edge-health-smoke-lib.mjs", + "node --test scripts/src/dev-edge-health-smoke-lib.test.mjs", "node scripts/dev-edge-health-smoke.mjs --live --write-report" ], localSmoke: { @@ -172,16 +191,7 @@ async function createDevGateReport(edgeHealth) { ], summary: edgeHealth.blocker ?? "DEV edge health preconditions are satisfied." }, - blockers: blockerType - ? [ - { - type: blockerType, - scope: blockerScope, - status: "open", - summary: edgeHealth.blocker - } - ] - : [], + blockers, edgeHealth }; } @@ -204,6 +214,34 @@ function blockerTypeFor(classification) { return "network_blocker"; } +function reportBlockers(edgeHealth, status) { + if (status !== "blocked") { + return []; + } + + const parallelBlockers = Array.isArray(edgeHealth.parallelBlockers) ? edgeHealth.parallelBlockers : []; + if (parallelBlockers.length > 0) { + return parallelBlockers.map(({ type, scope, status: blockerStatus, summary, classification, sourceIssue, impact }) => ({ + type, + scope, + status: blockerStatus, + summary, + classification, + sourceIssue, + impact + })); + } + + return [ + { + type: blockerTypeFor(edgeHealth.classification), + scope: edgeHealth.diagnosis?.likelyLayer ?? edgeHealth.classification, + status: "open", + summary: edgeHealth.blocker + } + ]; +} + function requireDevEndpoint() { const endpoint = new URL(DEV_ENDPOINT); assert.equal(endpoint.protocol, "http:", "DEV endpoint must use http"); @@ -246,7 +284,8 @@ async function inspectContracts() { serviceCount: deploy.services?.length ?? 0, edgeProxy: deploy.services?.find((service) => service.serviceId === "hwlab-edge-proxy") ?? null, tunnelClient: deploy.services?.find((service) => service.serviceId === "hwlab-tunnel-client") ?? null, - cloudApiDb: inspectCloudApiDbContract(deploy) + cloudApiDb: inspectCloudApiDbContract(deploy), + codeAgentProvider: inspectCodeAgentProviderContract(deploy) } }; } @@ -322,6 +361,38 @@ function inspectCloudApiDbContract(deploy) { }; } +function inspectCodeAgentProviderContract(deploy) { + const env = deploy.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {}; + const expectedRef = `secretRef:${CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretName}/${CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretKey}`; + const secretRefPresent = env[CODE_AGENT_PROVIDER_SECRET_CONTRACT.env] === expectedRef; + return { + status: secretRefPresent ? "degraded" : "blocked", + sourceIssue: CODE_AGENT_PROVIDER_SECRET_CONTRACT.sourceIssue, + requiredEnv: [ + { + name: CODE_AGENT_PROVIDER_SECRET_CONTRACT.env, + present: Boolean(env[CODE_AGENT_PROVIDER_SECRET_CONTRACT.env]), + redacted: true, + source: "k8s-secret-ref" + } + ], + secretRef: { + env: CODE_AGENT_PROVIDER_SECRET_CONTRACT.env, + secretName: CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretName, + secretKey: CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretKey, + present: secretRefPresent, + envInjected: false, + secretPresent: "not_observed_by_manifest", + secretKeyPresent: secretRefPresent ? "declared" : "not_observed_by_manifest", + secretValueRead: false, + redacted: true + }, + secretMaterialRead: false, + valuesRedacted: true, + note: "Manifest-only Code Agent provider Secret reference; this is not runtime Secret injection or provider connectivity evidence." + }; +} + function notRunDbReadiness(blockerMessage) { return { status: "not_run", @@ -330,6 +401,7 @@ function notRunDbReadiness(blockerMessage) { secret: unknownDbSecretPresence("not_observed"), connectionAttempted: false, connectionResult: "not_run", + connectionClassification: "not_run", liveConnected: false, valuesRedacted: true, secretMaterialRead: false, @@ -359,6 +431,7 @@ function summarizeRuntimeDbReadiness(db, secretPresence = unknownDbSecretPresenc secret: secretPresence, connectionAttempted: false, connectionResult: "health_db_missing", + connectionClassification: "health_db_missing", liveConnected: false, valuesRedacted: true, secretMaterialRead: false, @@ -374,6 +447,8 @@ function summarizeRuntimeDbReadiness(db, secretPresence = unknownDbSecretPresenc ); const connectionAttempted = Boolean(db.connectionAttempted ?? db.connectionChecked); const connectionResult = db.connectionResult ?? db.connection?.result ?? (connectionAttempted ? "unknown" : "not_attempted"); + const connectionClassification = + db.connection?.classification ?? classificationForRuntimeDb(db, connectionAttempted, connectionResult); const liveConnected = Boolean(db.liveConnected ?? db.connected ?? db.ready); const blockerMessage = liveConnected ? null : db.blocker ?? blockerForRuntimeDb(db, connectionAttempted, connectionResult); const status = liveConnected ? "pass" : "blocked"; @@ -393,6 +468,7 @@ function summarizeRuntimeDbReadiness(db, secretPresence = unknownDbSecretPresenc }, connectionAttempted, connectionResult, + connectionClassification, liveConnected, valuesRedacted: db.redaction?.valuesRedacted !== false && db.safety?.valuesRedacted === true, secretMaterialRead: false, @@ -404,6 +480,7 @@ function summarizeRuntimeDbReadiness(db, secretPresence = unknownDbSecretPresenc missingEnv: Array.isArray(db.missingEnv) ? db.missingEnv : [], connectionAttempted, connectionResult, + classification: connectionClassification, liveConnected, endpointRedacted: db.connection?.endpointRedacted !== false, valueRedacted: true @@ -412,6 +489,34 @@ function summarizeRuntimeDbReadiness(db, secretPresence = unknownDbSecretPresenc }; } +function classificationForRuntimeDb(db, connectionAttempted, connectionResult) { + if (typeof db?.connectionClassification === "string") { + return db.connectionClassification; + } + if (typeof db?.classification === "string") { + return db.classification; + } + if (db?.configReady !== true) { + return "missing_runtime_env"; + } + if (!connectionAttempted) { + return "configured_no_live_attempt"; + } + if (connectionResult === "dns_error") { + return "dns_resolution_failed"; + } + if (connectionResult === "refused") { + return "tcp_refused"; + } + if (connectionResult === "timeout") { + return "tcp_timeout"; + } + if (connectionResult === "network_unreachable") { + return "network_unreachable"; + } + return connectionResult === "connected" ? "tcp_connected" : "tcp_error"; +} + function blockerForRuntimeDb(db, connectionAttempted, connectionResult) { if (db.configReady !== true) { const missing = Array.isArray(db.missingEnv) && db.missingEnv.length > 0 ? db.missingEnv.join(", ") : "required DB env"; @@ -436,6 +541,18 @@ function unknownDbSecretPresence(state) { }; } +function unknownProviderSecretPresence(state) { + return { + observable: false, + secretName: CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretName, + secretKey: CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretKey, + secretPresent: state, + secretKeyPresent: state, + secretValueRead: false, + redacted: true + }; +} + async function inspectKubernetes() { const hasKubectl = await commandExists("kubectl"); const result = { @@ -446,6 +563,7 @@ async function inspectKubernetes() { endpoints: [], pods: [], dbSecret: unknownDbSecretPresence("not_observed"), + codeAgentProviderSecret: unknownProviderSecretPresence("not_observed"), notes: [] }; if (!hasKubectl) { @@ -464,6 +582,7 @@ async function inspectKubernetes() { "-n", namespace, "get", "pods", "-l", "hwlab.pikastech.local/profile=dev", "-o", "json" ]); await collectDbSecretPresence(result); + await collectCodeAgentProviderSecretPresence(result); return result; } @@ -551,6 +670,48 @@ async function collectDbSecretPresence(result) { } } +async function collectCodeAgentProviderSecretPresence(result) { + const ref = CODE_AGENT_PROVIDER_SECRET_CONTRACT; + const base = { + observable: true, + sourceIssue: ref.sourceIssue, + env: ref.env, + secretName: ref.secretName, + secretKey: ref.secretKey, + secretPresent: false, + secretKeyPresent: false, + secretValueRead: false, + redacted: true + }; + const exists = await runCommand("kubectl", ["-n", namespace, "get", "secret", ref.secretName, "-o", "name"], { + timeoutMs: 5000 + }); + if (exists.exitCode !== 0) { + result.codeAgentProviderSecret = { + ...base, + command: "kubectl get secret -o name", + error: exists.stderr.trim() || "secret not observed" + }; + result.notes.push(`kubectl Code Agent provider Secret presence probe failed: ${exists.stderr.trim()}`); + return; + } + + const keyPresence = await runCommand("kubectl", ["-n", namespace, "describe", "secret", ref.secretName], { + timeoutMs: 5000 + }); + const keyLinePattern = new RegExp(`^\\s*${escapeRegExp(ref.secretKey)}:\\s+\\d+\\s+bytes\\s*$`, "mu"); + result.codeAgentProviderSecret = { + ...base, + secretPresent: true, + secretKeyPresent: keyPresence.exitCode === 0 && keyLinePattern.test(keyPresence.stdout), + command: "kubectl describe secret (key-presence-only)", + error: keyPresence.exitCode === 0 ? null : keyPresence.stderr.trim() + }; + if (keyPresence.exitCode !== 0) { + result.notes.push(`kubectl Code Agent provider Secret key presence probe failed: ${keyPresence.stderr.trim()}`); + } +} + async function inspectClusterDns() { const results = []; for (const service of runtimeServices) { @@ -615,6 +776,131 @@ function classifyReachableHealth(json, runtimeDb = null) { return { status: "pass", classification: "none", blocker: null }; } +function inspectProviderSecretReadiness(edgeHealth) { + const secret = edgeHealth.kubernetes?.codeAgentProviderSecret ?? unknownProviderSecretPresence("not_observed"); + const manifest = edgeHealth.contracts?.deploy?.codeAgentProvider ?? null; + const manifestRefPresent = manifest?.secretRef?.present === true; + const secretPresent = secret.secretPresent === true; + const secretKeyPresent = secret.secretKeyPresent === true; + const status = secret.observable && (!secretPresent || !secretKeyPresent) ? "blocked" : "not_proven"; + const classification = status === "blocked" ? "provider_secret_missing" : "provider_secret_presence_not_proven"; + return { + status, + sourceIssue: CODE_AGENT_PROVIDER_SECRET_CONTRACT.sourceIssue, + provider: "openai-responses", + requiredEnv: CODE_AGENT_PROVIDER_SECRET_CONTRACT.env, + manifestRefPresent, + secret: { + ...secret, + secretValueRead: false, + redacted: true + }, + providerCallAttempted: false, + providerConnected: false, + secretMaterialRead: false, + valuesRedacted: true, + classification, + blocker: status === "blocked" + ? `Code Agent provider Secret ${CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretName}/${CODE_AGENT_PROVIDER_SECRET_CONTRACT.secretKey} is not present as key-presence evidence; #143 real provider-backed chat remains blocked` + : "Code Agent provider Secret presence was not proven by this edge-health run", + evidence: [ + { + source: secret.observable ? "kubernetes-secret-presence" : "runtime-not-observable", + manifestRefPresent, + secretPresent, + secretKeyPresent, + secretValueRead: false, + redacted: true + } + ] + }; +} + +function classifyParallelBlockers(edgeHealth) { + const blockers = []; + const dbReadiness = edgeHealth.runtimeDbReadiness; + if (dbReadiness?.status === "blocked") { + blockers.push({ + id: "cloud-api-db-live", + type: "runtime_blocker", + scope: "cloud-api-db", + status: "open", + sourceIssue: "pikasTech/HWLAB#49", + classification: dbReadiness.connectionClassification ?? dbReadiness.connectionResult, + summary: dbReadiness.blocker, + impact: { + blocks: ["DB live readiness", "M4 live agent scheduling precondition", "M5 DEV-LIVE acceptance"], + separateFrom: ["pikasTech/HWLAB#143 provider Secret", "M3 hardware trusted-loop proof"] + } + }); + } + + const provider = edgeHealth.providerSecretReadiness; + if (provider?.status === "blocked") { + blockers.push({ + id: "code-agent-provider-secret", + type: "agent_blocker", + scope: "code-agent-provider-secret", + status: "open", + sourceIssue: CODE_AGENT_PROVIDER_SECRET_CONTRACT.sourceIssue, + classification: provider.classification, + summary: provider.blocker, + impact: { + blocks: ["#143 provider-backed Code Agent chat", "M4 provider-backed agent execution", "M5 DEV-LIVE acceptance"], + separateFrom: ["pikasTech/HWLAB#49 DB live readiness", "M3 hardware trusted-loop proof"] + } + }); + } + return blockers; +} + +function classifyParallelBlockerStatus(parallelBlockers) { + return blocker( + "parallel_runtime_blocker", + parallelBlockers.map((item) => item.summary).join("; "), + { + likelyLayer: "parallel-dev-blockers", + confidence: "high", + likelyCause: "one or more independent DEV blockers remain open after route health passed", + evidence: parallelBlockers.map(({ id, scope, classification, sourceIssue }) => ({ + id, + scope, + classification, + sourceIssue + })), + notProven: ["DB-backed runtime readiness", "provider-backed Code Agent readiness", "M3/M4/M5 DEV-LIVE acceptance"], + nextTask: "Close each independent blocker with redacted evidence, then rerun the read-only edge smoke." + } + ); +} + +function classifyMilestoneImpact(edgeHealth) { + const dbBlocked = edgeHealth.runtimeDbReadiness?.status === "blocked"; + const providerBlocked = edgeHealth.providerSecretReadiness?.status === "blocked"; + 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." + }, + M4: { + status: dbBlocked || providerBlocked ? "blocked" : "not_proven", + blockedBy: [ + ...(dbBlocked ? ["cloud-api-db-live"] : []), + ...(providerBlocked ? ["code-agent-provider-secret"] : []) + ], + summary: "M4 live agent-loop remains blocked until DB live readiness and provider-backed agent prerequisites are independently green." + }, + M5: { + status: dbBlocked || providerBlocked ? "blocked" : "not_proven", + blockedBy: [ + ...(dbBlocked ? ["cloud-api-db-live"] : []), + ...(providerBlocked ? ["code-agent-provider-secret"] : []) + ], + summary: "M5 DEV-LIVE acceptance cannot be promoted from route health, env presence, or provider manifest refs while DB live and #143 provider Secret blockers remain open." + } + }; +} + function classifyRefusedPort(publicEdgePort, frpsControl, tunnelHealth) { if (frpsControl?.status === "error" && frpsControl.code === "ECONNREFUSED") { const allPortsRefused = tunnelHealth?.status === "error" && tunnelHealth.code === "ECONNREFUSED"; diff --git a/scripts/src/dev-edge-health-smoke-lib.test.mjs b/scripts/src/dev-edge-health-smoke-lib.test.mjs new file mode 100644 index 00000000..07bb2bde --- /dev/null +++ b/scripts/src/dev-edge-health-smoke-lib.test.mjs @@ -0,0 +1,111 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { __testHooks } from "./dev-edge-health-smoke-lib.mjs"; + +test("runtime DB readiness preserves dns resolution classification", () => { + const readiness = __testHooks.summarizeRuntimeDbReadiness( + { + configReady: true, + liveConnected: false, + liveDbEvidence: false, + connectionAttempted: true, + connectionResult: "dns_error", + 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: "dns_error", + classification: "dns_resolution_failed", + endpointRedacted: true, + valueRedacted: true + }, + redaction: { valuesRedacted: true }, + safety: { valuesRedacted: true, liveDbEvidence: false }, + blocker: "DB endpoint DNS resolution failed from the cloud-api runtime" + }, + { + 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.connectionResult, "dns_error"); + assert.equal(readiness.connectionClassification, "dns_resolution_failed"); + assert.equal(readiness.liveConnected, false); + assert.equal(readiness.liveDbEvidence, false); + assert.equal(readiness.secretMaterialRead, false); + assert.equal(readiness.evidence[0].classification, "dns_resolution_failed"); +}); + +test("parallel blockers keep DB DNS and provider Secret gaps independent", () => { + const edgeHealth = { + runtimeDbReadiness: { + status: "blocked", + connectionResult: "dns_error", + connectionClassification: "dns_resolution_failed", + blocker: "DB endpoint DNS resolution failed from the cloud-api runtime" + }, + providerSecretReadiness: { + status: "blocked", + classification: "provider_secret_missing", + blocker: "Code Agent provider Secret hwlab-code-agent-provider/openai-api-key is not present as key-presence evidence" + } + }; + + const blockers = __testHooks.classifyParallelBlockers(edgeHealth); + assert.deepEqual( + blockers.map((blocker) => blocker.scope), + ["cloud-api-db", "code-agent-provider-secret"] + ); + assert.equal(blockers[0].classification, "dns_resolution_failed"); + assert.equal(blockers[0].sourceIssue, "pikasTech/HWLAB#49"); + assert.equal(blockers[1].classification, "provider_secret_missing"); + assert.equal(blockers[1].sourceIssue, "pikasTech/HWLAB#143"); + assert.ok(blockers[0].impact.separateFrom.includes("pikasTech/HWLAB#143 provider Secret")); + assert.ok(blockers[1].impact.separateFrom.includes("pikasTech/HWLAB#49 DB live readiness")); +}); + +test("provider Secret blocker remains open even if DB is not blocked", () => { + const blockers = __testHooks.classifyParallelBlockers({ + runtimeDbReadiness: { status: "pass" }, + providerSecretReadiness: { + status: "blocked", + classification: "provider_secret_missing", + blocker: "Code Agent provider Secret hwlab-code-agent-provider/openai-api-key is not present as key-presence evidence" + } + }); + + assert.equal(blockers.length, 1); + assert.equal(blockers[0].scope, "code-agent-provider-secret"); + assert.equal(blockers[0].type, "agent_blocker"); +}); + +test("milestone impact does not promote DB or provider evidence into M3/M4/M5", () => { + const impact = __testHooks.classifyMilestoneImpact({ + runtimeDbReadiness: { status: "blocked" }, + providerSecretReadiness: { status: "blocked" } + }); + + assert.equal(impact.M3.status, "separate_blocker"); + assert.deepEqual(impact.M4.blockedBy, ["cloud-api-db-live", "code-agent-provider-secret"]); + assert.deepEqual(impact.M5.blockedBy, ["cloud-api-db-live", "code-agent-provider-secret"]); + assert.match(impact.M5.summary, /cannot be promoted/u); +}); 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}`);