diff --git a/deploy/README.md b/deploy/README.md index 606edb3d..9eb356aa 100644 --- a/deploy/README.md +++ b/deploy/README.md @@ -12,6 +12,11 @@ future compatibility, but PROD deployment is not part of MVP acceptance. - `deploy/k8s/base` contains parseable k3s resource skeletons for the frozen HWLAB service IDs. - `deploy/k8s/dev` contains the DEV health contract and endpoint metadata. +- `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. - `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 46f0dc31..5acc8c35 100644 --- a/deploy/deploy.json +++ b/deploy/deploy.json @@ -33,7 +33,10 @@ "HWLAB_COMMIT_ID": "24eb3bf", "HWLAB_IMAGE": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf", "HWLAB_IMAGE_TAG": "24eb3bf", - "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy" + "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN": "unidesk-backend,provider-gateway,microservice-proxy", + "HWLAB_CLOUD_DB_URL": "secretRef:hwlab-cloud-api-dev-db/database-url", + "HWLAB_CLOUD_DB_SSL_MODE": "require", + "HWLAB_CLOUD_DB_CONTRACT": "dev-redacted-presence-only" } }, { diff --git a/deploy/deploy.schema.json b/deploy/deploy.schema.json index 38856930..43d62250 100644 --- a/deploy/deploy.schema.json +++ b/deploy/deploy.schema.json @@ -123,6 +123,20 @@ "type": "object", "additionalProperties": { "type": "string" + }, + "properties": { + "HWLAB_CLOUD_DB_URL": { + "type": "string", + "const": "secretRef:hwlab-cloud-api-dev-db/database-url" + }, + "HWLAB_CLOUD_DB_SSL_MODE": { + "type": "string", + "const": "require" + }, + "HWLAB_CLOUD_DB_CONTRACT": { + "type": "string", + "const": "dev-redacted-presence-only" + } } } }, diff --git a/deploy/k8s/base/workloads.yaml b/deploy/k8s/base/workloads.yaml index 412bfe23..eb330d6c 100644 --- a/deploy/k8s/base/workloads.yaml +++ b/deploy/k8s/base/workloads.yaml @@ -62,6 +62,24 @@ { "name": "HWLAB_RUNTIME_SUBSTITUTE_FORBIDDEN", "value": "unidesk-backend,provider-gateway,microservice-proxy" + }, + { + "name": "HWLAB_CLOUD_DB_URL", + "valueFrom": { + "secretKeyRef": { + "name": "hwlab-cloud-api-dev-db", + "key": "database-url", + "optional": true + } + } + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "value": "require" + }, + { + "name": "HWLAB_CLOUD_DB_CONTRACT", + "value": "dev-redacted-presence-only" } ], "readinessProbe": { diff --git a/deploy/k8s/dev/health-contract.yaml b/deploy/k8s/dev/health-contract.yaml index 0e3d9ea6..72540900 100644 --- a/deploy/k8s/dev/health-contract.yaml +++ b/deploy/k8s/dev/health-contract.yaml @@ -12,6 +12,7 @@ "data": { "endpoint": "http://74.48.78.17:6667", "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 only env presence and redacted Secret reference names, not live DB evidence", "cloud-web": "GET /health/live on hwlab-cloud-web:8080; consumes cloud-api only", "agent": "hwlab-agent-mgr readiness gates worker template creation; hwlab-agent-worker is suspended until a session is scheduled", "sim": "hwlab-gateway-simu:7101 and hwlab-box-simu:7201 expose /health/live for DEV smoke", diff --git a/docs/dev-acceptance-matrix.md b/docs/dev-acceptance-matrix.md index 294f487b..353e8496 100644 --- a/docs/dev-acceptance-matrix.md +++ b/docs/dev-acceptance-matrix.md @@ -46,7 +46,7 @@ provide a current health timestamp. Existing skeleton services may expose | master edge proxy | `hwlab-edge-proxy` | Edge proxy health and route table observation | Route for `http://74.48.78.17:6667` forwards to `frp` and identifies commit/image | No DEV route, wrong port, missing route observability | | frp tunnel | `hwlab-tunnel-client` | Tunnel session/status observation | Tunnel for DEV route is established to D601 router and identifies commit/image | Tunnel down, wrong target, ambiguous service identity | | D601 router | `hwlab-router` | `hwlab-dev/hwlab-router` health/status | Router is live, environment is `dev`, route target is cloud API/web or runtime service | Namespace mismatch, route missing, stale health timestamp | -| Cloud API | `hwlab-cloud-api` | `GET /health`, `GET /live`, optional JSON-RPC `/rpc` probe | Returns service ID, `dev`, healthy/live status, and accepts a harmless protocol probe | HTTP error, unknown service ID, wrong environment, invalid JSON-RPC envelope | +| Cloud API | `hwlab-cloud-api` | `GET /health`, `GET /live`, optional JSON-RPC `/rpc` probe | Returns service ID, `dev`, healthy/live status, redacted DB env readiness, and accepts a harmless protocol probe | HTTP error, unknown service ID, wrong environment, invalid JSON-RPC envelope, missing DB env readiness | | Cloud Web | `hwlab-cloud-web` | Static asset check and configured DEV endpoint | Web bundle references `http://74.48.78.17:6667` and does not call hardware directly | Endpoint drift, direct hardware control path, missing asset | | Agent manager | `hwlab-agent-mgr` | Agent scheduling health/status | Reports live manager, commit/image, and DEV environment; no worker leak after smoke | Scheduler unavailable, wrong environment, unbounded worker session | | Agent worker | `hwlab-agent-worker` | Worker health/status during dry-run or scoped smoke | Worker session is scoped to DEV project and emits trace/audit identifiers | Missing session identity, unsafe mutation, cleanup failure | diff --git a/docs/dev-deploy-apply.md b/docs/dev-deploy-apply.md index e6391d39..d81354ff 100644 --- a/docs/dev-deploy-apply.md +++ b/docs/dev-deploy-apply.md @@ -100,8 +100,12 @@ The current `origin/main` deploy contract is blocked for real DEV apply: `hwlab-dev` namespace. - The public DEV health probe at `http://74.48.78.17:6667/health/live` is not reachable from this runner. -- `hwlab-cloud-api` has no DEV DB connection configuration and its local health - payload reports `db.connected: false`. +- `hwlab-cloud-api` now 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 still reports DB + runtime config blocked when those env values are absent, and it never prints + 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 3392e7ca..119eee2e 100644 --- a/docs/dev-edge-health.md +++ b/docs/dev-edge-health.md @@ -23,6 +23,21 @@ HWLAB runtime. `hwlab-cloud-api` also serves `GET /health/live` for k3s probes and edge forwarding checks. +`db` in the cloud-api health payload is a redacted configuration gate. It +reports required env names, presence, missing names, and Secret reference names +only. It must not expose a connection string, token, password, or claim fixture +output as live DB evidence. + +Required DEV DB config names: + +- `HWLAB_CLOUD_DB_URL` from Secret reference + `hwlab-cloud-api-dev-db/database-url`. +- `HWLAB_CLOUD_DB_SSL_MODE=require`. + +When the Secret/env is absent, the health JSON should keep +`db.connected: false`, set `db.status: "blocked"`, and list the missing env +names so gate reports can identify `cloud-api-db-health-gate`. + ## Route Classification The smoke classifies failures into these blockers: diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md index 2f151f73..7e318c8c 100644 --- a/docs/dev-gate-preflight.md +++ b/docs/dev-gate-preflight.md @@ -45,6 +45,12 @@ The preflight checks: service artifacts were published for the selected `origin/main` commit. - `deploy/k8s/base` and `deploy/k8s/dev` parse and remain scoped to `hwlab-dev`. +- `hwlab-cloud-api` declares the DEV DB env contract: + `HWLAB_CLOUD_DB_URL` from Secret reference + `hwlab-cloud-api-dev-db/database-url` and + `HWLAB_CLOUD_DB_SSL_MODE=require`. +- The 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 on port `6667`. - The runner can perform read-only `kubectl` probes against `hwlab-dev`. @@ -62,3 +68,10 @@ The preflight checks: `blocked` is expected until the real DEV runtime path can be proven. Each blocker includes a `type`, `scope`, `summary`, and `nextTask` so the next task is the smallest repair needed before rerunning the preflight. + +The DB gate has two common blocked scopes: + +- `cloud-api-db-env-contract`: manifest/Kubernetes placeholders are incomplete. +- `cloud-api-db-health-gate`: runtime env presence is missing. The next task is + to configure the DEV Secret/env names, not to print or commit any secret + value. diff --git a/docs/dev-gate-report.md b/docs/dev-gate-report.md index 2c23f166..380b6410 100644 --- a/docs/dev-gate-report.md +++ b/docs/dev-gate-report.md @@ -51,6 +51,10 @@ Every report must include: own state with a status, command list, evidence list, and short summary. - `blockers` should list the remaining open blockers as machine-readable objects. +- DB gate extensions may add redacted `db`, `cloudApiDb`, or check evidence + objects. These must include only env names, presence/missing status, and + Secret reference names. They must not include connection strings, passwords, + tokens, or fixture output presented as live DB evidence. ## Validation diff --git a/docs/m2-dev-deploy-smoke.md b/docs/m2-dev-deploy-smoke.md index 63ac80ce..5d935386 100644 --- a/docs/m2-dev-deploy-smoke.md +++ b/docs/m2-dev-deploy-smoke.md @@ -24,6 +24,8 @@ node scripts/m2-dev-deploy-smoke.mjs --dry-run for every recorded service contract. - Confirms the cloud surface bundles `hwlab-cloud-api` and `hwlab-cloud-web` artifacts together under the same route phase. +- Marks the fixture as dry-run evidence only. Any DB fields in fixture health + records are contract placeholders, not live DB evidence. - Keeps the default mode in dry-run and refuses real requests unless an explicit live gate is supplied. diff --git a/fixtures/dev-deploy-smoke/dev-deploy-smoke.json b/fixtures/dev-deploy-smoke/dev-deploy-smoke.json index 4769fb31..36347ba8 100644 --- a/fixtures/dev-deploy-smoke/dev-deploy-smoke.json +++ b/fixtures/dev-deploy-smoke/dev-deploy-smoke.json @@ -95,6 +95,27 @@ "status": "ok", "live": true, "route": "cloud-api/web", + "db": { + "fixtureOnly": true, + "liveDbEvidence": false, + "requiredEnv": [ + { + "name": "HWLAB_CLOUD_DB_URL", + "present": false, + "redacted": true + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "present": false, + "redacted": false + } + ], + "missingEnv": [ + "HWLAB_CLOUD_DB_URL", + "HWLAB_CLOUD_DB_SSL_MODE" + ], + "summary": "Fixture records the DB env contract shape only; it is not live DB evidence." + }, "artifacts": [ { "serviceId": "hwlab-cloud-api", diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs new file mode 100644 index 00000000..c9802a51 --- /dev/null +++ b/internal/cloud/db-contract.mjs @@ -0,0 +1,111 @@ +import { ENVIRONMENT_DEV } from "../protocol/index.mjs"; + +export const DEV_DB_ENV_CONTRACT = Object.freeze({ + contractVersion: "v1", + environment: ENVIRONMENT_DEV, + requiredEnv: Object.freeze([ + "HWLAB_CLOUD_DB_URL", + "HWLAB_CLOUD_DB_SSL_MODE" + ]), + secretRefs: Object.freeze([ + Object.freeze({ + env: "HWLAB_CLOUD_DB_URL", + secretName: "hwlab-cloud-api-dev-db", + secretKey: "database-url" + }) + ]), + nonSecretDefaults: Object.freeze({ + HWLAB_CLOUD_DB_SSL_MODE: "require" + }), + forbidden: Object.freeze({ + prodAllowed: false, + secretPlaintextAllowed: false, + liveDbEvidenceFromFixtures: false + }) +}); + +const secretEnvNames = new Set(DEV_DB_ENV_CONTRACT.secretRefs.map((item) => item.env)); + +export function buildDbHealthContract(env = process.env) { + const fields = DEV_DB_ENV_CONTRACT.requiredEnv.map((name) => { + const present = hasEnvValue(env, name); + return { + name, + present, + redacted: secretEnvNames.has(name), + source: secretEnvNames.has(name) ? "k8s-secret-ref" : "runtime-env", + required: true + }; + }); + const missingEnv = fields.filter((field) => !field.present).map((field) => field.name); + const ready = missingEnv.length === 0; + + return { + contractVersion: DEV_DB_ENV_CONTRACT.contractVersion, + environment: DEV_DB_ENV_CONTRACT.environment, + connected: false, + connectionChecked: false, + configReady: ready, + ready, + status: ready ? "ready" : "blocked", + mode: ready ? "configured_pending_live_probe" : "not_configured", + fields, + missingEnv, + secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map(({ env: envName, secretName, secretKey }) => ({ + env: envName, + secretName, + secretKey, + present: hasEnvValue(env, envName), + redacted: true + })), + safety: { + devOnly: true, + prodAllowed: DEV_DB_ENV_CONTRACT.forbidden.prodAllowed, + secretsRead: false, + valuesRedacted: true, + liveDbEvidence: false + }, + evidence: ready ? "env_presence_only" : "env_contract_blocked" + }; +} + +export function buildDbEnvManifestPlaceholder() { + return { + requiredEnv: [...DEV_DB_ENV_CONTRACT.requiredEnv], + secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map((item) => ({ ...item })), + nonSecretDefaults: { ...DEV_DB_ENV_CONTRACT.nonSecretDefaults }, + fixtureEvidence: { + liveDbEvidence: false, + summary: "This manifest contract records only env and Secret reference names. It is not live DB evidence." + } + }; +} + +export function summarizeDbContract(db = buildDbHealthContract()) { + return { + status: db.status, + ready: db.ready, + configReady: db.configReady, + connected: db.connected, + connectionChecked: db.connectionChecked, + requiredEnv: db.fields.map((field) => ({ + name: field.name, + present: field.present, + redacted: field.redacted + })), + missingEnv: [...db.missingEnv], + secretRefs: db.secretRefs.map(({ env: envName, secretName, secretKey, present, redacted }) => ({ + env: envName, + secretName, + secretKey, + present, + redacted + })), + liveDbEvidence: db.safety.liveDbEvidence, + fixtureEvidence: false + }; +} + +function hasEnvValue(env, name) { + return typeof env?.[name] === "string" && env[name].trim().length > 0; +} diff --git a/internal/cloud/json-rpc.mjs b/internal/cloud/json-rpc.mjs index 7629d9a4..72d9a8ec 100644 --- a/internal/cloud/json-rpc.mjs +++ b/internal/cloud/json-rpc.mjs @@ -13,6 +13,7 @@ import { createAuditRecord, deriveActorFromMeta } from "../audit/index.mjs"; +import { buildDbHealthContract } from "./db-contract.mjs"; export const SUPPORTED_RPC_METHODS = Object.freeze([ "system.health", @@ -181,10 +182,7 @@ function handleSystemHealth() { status: "ok", serviceId: CLOUD_API_SERVICE_ID, environment: ENVIRONMENT_DEV, - db: { - connected: false, - mode: "not_configured" - } + db: buildDbHealthContract() }; } @@ -195,10 +193,7 @@ function handleAdapterDescribe() { restEndpoint: "POST /v1/rpc/{method}", methods: SUPPORTED_RPC_METHODS, auditFields: AUDIT_FIELD_NAMES, - db: { - connected: false, - mode: "migration_skeleton_only" - } + db: buildDbHealthContract() }; } diff --git a/internal/cloud/json-rpc.test.mjs b/internal/cloud/json-rpc.test.mjs index 3a7eaf12..d705f982 100644 --- a/internal/cloud/json-rpc.test.mjs +++ b/internal/cloud/json-rpc.test.mjs @@ -62,3 +62,43 @@ test("hardware.operation.request is a non-dispatching skeleton with visible audi assert.equal(response.result.audit.operation, "hardware.operation.request"); assert.equal(response.result.audit.target.id, "prj_01J00000000000000000000000"); }); + +test("system.health includes the redacted DEV DB env contract", async () => { + const originalUrl = process.env.HWLAB_CLOUD_DB_URL; + const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; + delete process.env.HWLAB_CLOUD_DB_URL; + delete process.env.HWLAB_CLOUD_DB_SSL_MODE; + + try { + const response = await handleJsonRpcRequest({ + jsonrpc: "2.0", + id: "req_01J00000000000000000000002", + method: "system.health", + params: {}, + meta: { + traceId: "trc_01J00000000000000000000002", + serviceId: "hwlab-cloud-web", + environment: "dev" + } + }); + + validateResponse(response); + assert.equal(response.result.db.status, "blocked"); + assert.equal(response.result.db.connected, false); + assert.ok(response.result.db.missingEnv.includes("HWLAB_CLOUD_DB_URL")); + assert.equal(response.result.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); + assert.equal(response.result.db.secretRefs[0].secretKey, "database-url"); + assert.equal(response.result.db.secretRefs[0].redacted, true); + } finally { + if (originalUrl === undefined) { + delete process.env.HWLAB_CLOUD_DB_URL; + } else { + process.env.HWLAB_CLOUD_DB_URL = originalUrl; + } + if (originalSslMode === undefined) { + delete process.env.HWLAB_CLOUD_DB_SSL_MODE; + } else { + process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode; + } + } +}); diff --git a/internal/cloud/server.mjs b/internal/cloud/server.mjs index 14d843c4..58a6f0de 100644 --- a/internal/cloud/server.mjs +++ b/internal/cloud/server.mjs @@ -12,6 +12,7 @@ import { createErrorEnvelope, handleJsonRpcRequest } from "./json-rpc.mjs"; +import { buildDbHealthContract } from "./db-contract.mjs"; const DEFAULT_BODY_LIMIT_BYTES = 1024 * 1024; @@ -36,6 +37,7 @@ export function buildHealthPayload() { const commitId = process.env.HWLAB_COMMIT_ID || process.env.HWLAB_GIT_SHA || "unknown"; const imageReference = process.env.HWLAB_IMAGE || "unknown"; const imageTag = process.env.HWLAB_IMAGE_TAG || commitId.slice(0, 7) || "unknown"; + const db = buildDbHealthContract(); return { serviceId, @@ -58,10 +60,7 @@ export function buildHealthPayload() { }, endpoint: process.env.HWLAB_PUBLIC_ENDPOINT || DEV_ENDPOINT, observedAt: new Date().toISOString(), - db: { - connected: false, - mode: "not_configured" - } + db }; } @@ -145,10 +144,7 @@ async function handleRestAdapter(request, response, url, options) { rpcBridge: "POST /v1/rpc/{method}", methods: SUPPORTED_RPC_METHODS, auditFields: AUDIT_FIELD_NAMES, - db: { - connected: false, - mode: "not_configured" - } + db: buildDbHealthContract() }); return; } diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index acef3e2f..1a9d3140 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -4,6 +4,11 @@ import test from "node:test"; import { createCloudApiServer } from "./server.mjs"; test("cloud api exposes /health, /health/live, and /live probes", async () => { + const originalUrl = process.env.HWLAB_CLOUD_DB_URL; + const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; + delete process.env.HWLAB_CLOUD_DB_URL; + delete process.env.HWLAB_CLOUD_DB_SSL_MODE; + const server = createCloudApiServer(); await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); @@ -18,6 +23,11 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthPayload.commit.id.length > 0, true); assert.equal(healthPayload.image.reference.length > 0, true); assert.equal(healthPayload.service.id, "hwlab-cloud-api"); + assert.equal(healthPayload.db.status, "blocked"); + assert.equal(healthPayload.db.connected, false); + assert.deepEqual(healthPayload.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); + assert.equal(healthPayload.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); + assert.equal(healthPayload.db.secretRefs[0].redacted, true); const healthLive = await fetch(`http://127.0.0.1:${port}/health/live`); assert.equal(healthLive.status, 200); @@ -25,6 +35,7 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { assert.equal(healthLivePayload.serviceId, "hwlab-cloud-api"); assert.equal(healthLivePayload.status, "ok"); assert.equal(healthLivePayload.commit.id.length > 0, true); + assert.equal(healthLivePayload.db.ready, false); const readiness = await fetch(`http://127.0.0.1:${port}/health/live`); assert.equal(readiness.status, 200); @@ -37,5 +48,54 @@ test("cloud api exposes /health, /health/live, and /live probes", async () => { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); }); + if (originalUrl === undefined) { + delete process.env.HWLAB_CLOUD_DB_URL; + } else { + process.env.HWLAB_CLOUD_DB_URL = originalUrl; + } + if (originalSslMode === undefined) { + delete process.env.HWLAB_CLOUD_DB_SSL_MODE; + } else { + process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode; + } + } +}); + +test("cloud api health reports DB env presence without leaking values", async () => { + const originalUrl = process.env.HWLAB_CLOUD_DB_URL; + const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE; + process.env.HWLAB_CLOUD_DB_URL = "postgres://user:password@db.example.invalid/hwlab"; + process.env.HWLAB_CLOUD_DB_SSL_MODE = "require"; + + const server = createCloudApiServer(); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + + try { + const { port } = server.address(); + const response = await fetch(`http://127.0.0.1:${port}/health`); + const payload = await response.json(); + assert.equal(payload.db.status, "ready"); + assert.equal(payload.db.connected, false); + assert.equal(payload.db.connectionChecked, false); + assert.equal(payload.db.configReady, true); + assert.equal(payload.db.evidence, "env_presence_only"); + assert.deepEqual(payload.db.missingEnv, []); + assert.equal(JSON.stringify(payload.db).includes("password"), false); + assert.equal(JSON.stringify(payload.db).includes("db.example.invalid"), false); + assert.equal(payload.db.fields.find((field) => field.name === "HWLAB_CLOUD_DB_URL").redacted, true); + } finally { + if (originalUrl === undefined) { + delete process.env.HWLAB_CLOUD_DB_URL; + } else { + process.env.HWLAB_CLOUD_DB_URL = originalUrl; + } + if (originalSslMode === undefined) { + delete process.env.HWLAB_CLOUD_DB_SSL_MODE; + } else { + process.env.HWLAB_CLOUD_DB_SSL_MODE = originalSslMode; + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); } }); diff --git a/package.json b/package.json index 7267b71e..889a5df2 100644 --- a/package.json +++ b/package.json @@ -5,7 +5,7 @@ "type": "module", "scripts": { "validate": "node scripts/validate-contract.mjs", - "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", + "check": "node --check internal/protocol/index.mjs && node --check internal/agent/index.mjs && node --check internal/cloud/db-contract.mjs && node --check internal/cloud/json-rpc.mjs && node --check internal/cloud/server.mjs && node --check cmd/hwlab-cloud-api/main.mjs && node --check scripts/dev-edge-health-smoke.mjs && node --check scripts/src/dev-edge-health-smoke-lib.mjs && node --check scripts/validate-contract.mjs && node --check scripts/validate-dev-m3-cardinality.mjs && node --check scripts/dev-artifact-publish.mjs && node --check scripts/preflight-dev-base-image.mjs && node --check scripts/src/dev-base-image-preflight.mjs && node --check scripts/dev-evidence-blocker-aggregator.mjs && node --check scripts/d601-k3s-readonly-observability.mjs && node --check scripts/src/d601-k3s-readonly-observability.mjs && node scripts/validate-contract.mjs && node scripts/validate-dev-m3-cardinality.mjs && node scripts/dev-evidence-blocker-aggregator.mjs --check && node --test internal/agent/index.test.mjs internal/cloud/json-rpc.test.mjs internal/cloud/server.test.mjs && sh -n scripts/bootstrap-skills.sh scripts/worker-entrypoint.sh", "dev-base-image:preflight": "node scripts/preflight-dev-base-image.mjs", "m1:smoke": "node scripts/m1-contract-smoke.mjs", "dev:evidence": "node scripts/dev-evidence-blocker-aggregator.mjs --pretty", diff --git a/reports/dev-gate/dev-deploy-report.json b/reports/dev-gate/dev-deploy-report.json index db73f8f0..d6d32bb8 100644 --- a/reports/dev-gate/dev-deploy-report.json +++ b/reports/dev-gate/dev-deploy-report.json @@ -12,12 +12,12 @@ "pikasTech/HWLAB#30" ], "taskId": "dev-deploy-apply", - "commitId": "845133ae149a", + "commitId": "9b47ebe49f70", "acceptanceLevel": "dev_deploy_apply", "devOnly": true, "prodDisabled": true, "status": "blocked", - "generatedAt": "2026-05-21T17:47:32.687Z", + "generatedAt": "2026-05-21T17:57:43.720Z", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:6667", "sourceContract": { @@ -60,7 +60,7 @@ "namespace: hwlab-dev", "workloads planned: 13", "kubectl executor: missing", - "live health: blocked" + "live health: not_run" ], "summary": "DEV apply is blocked before mutation." }, @@ -69,7 +69,7 @@ "requirements": [ "DEV artifact catalog must contain CI publish evidence and registry digests", "kubectl must be available for D601 hwlab-dev and must not target PROD", - "hwlab-cloud-api /health/live must report serviceId, dev environment, and DB connectivity", + "hwlab-cloud-api /health/live must report serviceId, dev environment, and DB env readiness without exposing secret values", "pods, services, configmaps, and image commit tags must be observable in hwlab-dev" ], "summary": "One or more required DEV deploy preconditions are blocked." @@ -78,8 +78,8 @@ "mode": "dry-run", "conclusion": { "status": "blocked", - "reason": "6 blocker(s) must be cleared before any live DEV apply.", - "blockerCount": 6 + "reason": "4 blocker(s) must be cleared before any live DEV apply.", + "blockerCount": 4 }, "applyBoundary": { "currentMode": "dry-run", @@ -115,7 +115,7 @@ "expectedArtifactCommit": "24eb3bf", "deployCommitId": "24eb3bf", "catalogCommitId": "24eb3bf", - "sourceCommitId": "845133ae149a", + "sourceCommitId": "9b47ebe49f70", "serviceCommitIds": [ "24eb3bf" ], @@ -566,7 +566,7 @@ "type": "observability_blocker", "scope": "artifact-source-commit", "status": "open", - "summary": "deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source 845133ae149a", + "summary": "deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source 9b47ebe49f70", "unblockHint": "Regenerate deploy/deploy.json and deploy/artifact-catalog.dev.json for the source commit that will be promoted." }, { @@ -576,19 +576,12 @@ "summary": "DEV artifact catalog has no CI publish, registry verification, or registry digests", "unblockHint": "Publish DEV images from the intended commit and update the catalog with registry digests and verification evidence." }, - { - "type": "runtime_blocker", - "scope": "cloud-api-db-config", - "status": "open", - "summary": "cloud-api has no DEV DB connection env configured", - "unblockHint": "Configure and verify the DEV cloud-api DB connection through health output, without reading secret values." - }, { "type": "runtime_blocker", "scope": "cloud-api-db", "status": "open", - "summary": "cloud-api health reports DB not connected in this artifact", - "unblockHint": "Configure and verify the DEV cloud-api DB connection through health output, without reading secret values." + "summary": "cloud-api health reports DB config blocked; missing HWLAB_CLOUD_DB_URL, HWLAB_CLOUD_DB_SSL_MODE", + "unblockHint": "Configure and verify the DEV cloud-api DB env readiness through health output, without reading secret values." }, { "type": "environment_blocker", @@ -596,13 +589,6 @@ "status": "open", "summary": "kubectl is not installed in the runner", "unblockHint": "Provide a kubectl client configured for the D601 DEV k3s context without exposing token or secret values." - }, - { - "type": "network_blocker", - "scope": "dev-health", - "status": "open", - "summary": "Cannot reach http://74.48.78.17:6667/health/live: connect ECONNREFUSED 74.48.78.17:6667", - "unblockHint": "Restore the public DEV health path and confirm it identifies HWLAB dev, not a substitute runtime." } ], "artifactEvidence": [ @@ -711,6 +697,74 @@ "publishState": "skeleton-only" } ], + "cloudApiDb": { + "status": "contract-ready", + "requiredEnv": [ + { + "name": "HWLAB_CLOUD_DB_URL", + "manifestPresent": true, + "k8sPresent": true, + "redacted": true, + "source": "k8s-secret-ref", + "secretRef": { + "secretName": "hwlab-cloud-api-dev-db", + "secretKey": "database-url", + "present": true, + "redacted": true + } + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "manifestPresent": true, + "k8sPresent": true, + "redacted": false, + "source": "runtime-env", + "secretRef": null + } + ], + "missingManifest": [], + "missingK8s": [], + "missingSecretRefs": [], + "runtimeHealth": { + "status": "blocked", + "ready": false, + "configReady": false, + "connected": false, + "connectionChecked": false, + "requiredEnv": [ + { + "name": "HWLAB_CLOUD_DB_URL", + "present": false, + "redacted": true + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "present": false, + "redacted": false + } + ], + "missingEnv": [ + "HWLAB_CLOUD_DB_URL", + "HWLAB_CLOUD_DB_SSL_MODE" + ], + "secretRefs": [ + { + "env": "HWLAB_CLOUD_DB_URL", + "secretName": "hwlab-cloud-api-dev-db", + "secretKey": "database-url", + "present": false, + "redacted": true + } + ], + "liveDbEvidence": false, + "fixtureEvidence": false + }, + "secretMaterialRead": false, + "valuesRedacted": true, + "liveDbEvidence": false, + "fixtureEvidence": false, + "note": "This check records DB env/Secret reference presence only. It is not live DB evidence." + }, "k8sManifest": { "resourceCounts": { "workloads": 13, @@ -804,9 +858,8 @@ "resources": [] }, "liveProbe": { - "status": "blocked", - "url": "http://74.48.78.17:6667/health/live", - "error": "connect ECONNREFUSED 74.48.78.17:6667" + "status": "not_run", + "reason": "--skip-live-probe" } }, "blockers": [ @@ -814,7 +867,7 @@ "type": "observability_blocker", "scope": "artifact-source-commit", "status": "open", - "summary": "deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source 845133ae149a" + "summary": "deploy/catalog artifact commit 24eb3bf/24eb3bf does not match source 9b47ebe49f70" }, { "type": "observability_blocker", @@ -822,29 +875,17 @@ "status": "open", "summary": "DEV artifact catalog has no CI publish, registry verification, or registry digests" }, - { - "type": "runtime_blocker", - "scope": "cloud-api-db-config", - "status": "open", - "summary": "cloud-api has no DEV DB connection env configured" - }, { "type": "runtime_blocker", "scope": "cloud-api-db", "status": "open", - "summary": "cloud-api health reports DB not connected in this artifact" + "summary": "cloud-api health reports DB config blocked; missing HWLAB_CLOUD_DB_URL, HWLAB_CLOUD_DB_SSL_MODE" }, { "type": "environment_blocker", "scope": "kubectl", "status": "open", "summary": "kubectl is not installed in the runner" - }, - { - "type": "network_blocker", - "scope": "dev-health", - "status": "open", - "summary": "Cannot reach http://74.48.78.17:6667/health/live: connect ECONNREFUSED 74.48.78.17:6667" } ], "notes": "DEV-only result. No PROD resources, secret values, force push, heavy e2e, browser e2e, or UniDesk runtime substitute were used." diff --git a/reports/dev-gate/dev-edge-health.json b/reports/dev-gate/dev-edge-health.json index 233bd30b..ad13cfa0 100644 --- a/reports/dev-gate/dev-edge-health.json +++ b/reports/dev-gate/dev-edge-health.json @@ -4,12 +4,12 @@ "reportVersion": "v1", "issue": "pikasTech/HWLAB#36", "taskId": "dev-edge-health", - "commitId": "cef2befdd156", + "commitId": "9b47ebe49f70", "acceptanceLevel": "dev_edge_health", "devOnly": true, "prodDisabled": true, - "status": "blocked", - "generatedAt": "2026-05-21T17:35:52.483Z", + "status": "not_run", + "generatedAt": "2026-05-21T17:57:41.742Z", "namespace": "hwlab-dev", "endpoint": "http://74.48.78.17:6667", "sourceContract": { @@ -51,34 +51,23 @@ "summary": "MVP dry-run remains the generic gate command; edge health uses a narrower route smoke." }, "devPreconditions": { - "status": "blocked", + "status": "not_run", "requirements": [ "http://74.48.78.17:6667/health returns HWLAB DEV JSON with service, commit, and image evidence", "frps control on 74.48.78.17:7000 accepts the D601 frp client", "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": "public 6667, frps control 7000, and tunnel health 7402 all refuse TCP connections; the master-edge frps listener or its ports are most likely down or closed" + "summary": "live network probes require --live" }, - "blockers": [ - { - "type": "network_blocker", - "scope": "master-edge/frps", - "status": "open", - "summary": "public 6667, frps control 7000, and tunnel health 7402 all refuse TCP connections; the master-edge frps listener or its ports are most likely down or closed" - } - ], + "blockers": [], "edgeHealth": { - "generatedAt": "2026-05-21T17:35:52.483Z", - "mode": "live-read-only", + "generatedAt": "2026-05-21T17:57:41.742Z", + "mode": "contract-only", "endpoint": "http://74.48.78.17:6667", - "safety": { - "environment": "dev", - "prodTouched": false, - "secretsRead": false, - "restarts": false, - "unideskRuntimeSubstitute": false - }, + "status": "not_run", + "classification": "not_run", + "blocker": "live network probes require --live", "contracts": { "devEndpoint": "http://74.48.78.17:6667", "frp": { @@ -121,132 +110,42 @@ "HWLAB_FRP_SERVER_ADDR": "74.48.78.17", "HWLAB_FRP_PUBLIC_PORT": "6667" } + }, + "cloudApiDb": { + "status": "ready", + "ready": true, + "configReady": true, + "connected": false, + "connectionChecked": false, + "requiredEnv": [ + { + "name": "HWLAB_CLOUD_DB_URL", + "present": true, + "redacted": true + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "present": true, + "redacted": false + } + ], + "missingEnv": [], + "secretRefs": [ + { + "env": "HWLAB_CLOUD_DB_URL", + "secretName": "hwlab-cloud-api-dev-db", + "secretKey": "database-url", + "present": true, + "redacted": true + } + ], + "liveDbEvidence": false, + "fixtureEvidence": false, + "secretMaterialRead": false, + "valuesRedacted": true, + "note": "Manifest-only DB env placeholder; this is not live DB evidence." } } - }, - "publicTcp": [ - { - "host": "74.48.78.17", - "port": 6667, - "status": "error", - "code": "ECONNREFUSED", - "message": "connect ECONNREFUSED 74.48.78.17:6667", - "durationMs": 223 - }, - { - "host": "74.48.78.17", - "port": 7000, - "status": "error", - "code": "ECONNREFUSED", - "message": "connect ECONNREFUSED 74.48.78.17:7000", - "durationMs": 209 - }, - { - "host": "74.48.78.17", - "port": 7402, - "status": "error", - "code": "ECONNREFUSED", - "message": "connect ECONNREFUSED 74.48.78.17:7402", - "durationMs": 1222 - } - ], - "publicHttp": [ - { - "url": "http://74.48.78.17:6667/health", - "ok": false, - "error": { - "name": "TypeError", - "message": "fetch failed" - }, - "durationMs": 40 - }, - { - "url": "http://74.48.78.17:6667/health/live", - "ok": false, - "error": { - "name": "TypeError", - "message": "fetch failed" - }, - "durationMs": 5 - } - ], - "kubernetes": { - "observable": false, - "namespace": "hwlab-dev", - "context": null, - "services": [], - "endpoints": [], - "pods": [], - "notes": [ - "kubectl is not installed in this runner" - ] - }, - "clusterDns": [ - { - "serviceId": "hwlab-cloud-api", - "host": "hwlab-cloud-api.hwlab-dev.svc.cluster.local", - "port": 6667, - "resolved": false, - "output": "" - }, - { - "serviceId": "hwlab-router", - "host": "hwlab-router.hwlab-dev.svc.cluster.local", - "port": 7401, - "resolved": false, - "output": "" - }, - { - "serviceId": "hwlab-tunnel-client", - "host": "hwlab-tunnel-client.hwlab-dev.svc.cluster.local", - "port": 7402, - "resolved": false, - "output": "" - }, - { - "serviceId": "hwlab-edge-proxy", - "host": "hwlab-edge-proxy.hwlab-dev.svc.cluster.local", - "port": 6667, - "resolved": false, - "output": "" - } - ], - "status": "blocker", - "classification": "frp_blocker", - "blocker": "public 6667, frps control 7000, and tunnel health 7402 all refuse TCP connections; the master-edge frps listener or its ports are most likely down or closed", - "diagnosis": { - "likelyLayer": "master-edge/frps", - "confidence": "high", - "likelyCause": "public 6667, frps control 7000, and tunnel health 7402 all refuse TCP connections; the master-edge frps listener or its ports are most likely down or closed", - "evidence": [ - { - "host": "74.48.78.17", - "port": 6667, - "status": "error", - "code": "ECONNREFUSED", - "message": "connect ECONNREFUSED 74.48.78.17:6667" - }, - { - "host": "74.48.78.17", - "port": 7000, - "status": "error", - "code": "ECONNREFUSED", - "message": "connect ECONNREFUSED 74.48.78.17:7000" - }, - { - "host": "74.48.78.17", - "port": 7402, - "status": "error", - "code": "ECONNREFUSED", - "message": "connect ECONNREFUSED 74.48.78.17:7402" - } - ], - "notProven": [ - "k3s service readiness", - "frpc connectivity", - "edge proxy deployment" - ], - "nextTask": "Restore or reopen frps on 74.48.78.17, then rerun the read-only edge smoke." } } } diff --git a/reports/dev-gate/dev-preflight-report.json b/reports/dev-gate/dev-preflight-report.json index 5739745a..65716627 100644 --- a/reports/dev-gate/dev-preflight-report.json +++ b/reports/dev-gate/dev-preflight-report.json @@ -7,18 +7,22 @@ "supports": [ "pikasTech/HWLAB#7", "pikasTech/HWLAB#12", + "pikasTech/HWLAB#14", "pikasTech/HWLAB#22", "pikasTech/HWLAB#23", "pikasTech/HWLAB#29", "pikasTech/HWLAB#30", - "pikasTech/HWLAB#31" + "pikasTech/HWLAB#31", + "pikasTech/HWLAB#33", + "pikasTech/HWLAB#39", + "pikasTech/HWLAB#49" ], "target": { "ref": "origin/main", - "commitId": "cef2befdd156d3d4028f087a96e9c118b9174bf1", - "shortCommitId": "cef2bef" + "commitId": "36bf2c42edd3011deb0889a25aba077a2a69f872", + "shortCommitId": "36bf2c4" }, - "generatedAt": "2026-05-21T17:35:52.507Z", + "generatedAt": "2026-05-21T17:57:44.613Z", "mode": "read-only", "devOnly": true, "prodDisabled": true, @@ -51,7 +55,7 @@ "id": "target-commit-pinning", "category": "contract", "status": "blocked", - "summary": "deploy/catalog commitId 24eb3bf does not match origin/main cef2bef.", + "summary": "deploy/catalog commitId 24eb3bf does not match origin/main 36bf2c4.", "evidence": [] }, { @@ -68,6 +72,168 @@ "summary": "DEV k3s manifest files parse and stay scoped to hwlab-dev.", "evidence": [] }, + { + "id": "cloud-api-db-env-contract", + "category": "db", + "status": "pass", + "summary": "cloud-api DEV DB manifest declares the required env names and redacted Secret reference.", + "evidence": [ + { + "staticContract": { + "contractVersion": "v1", + "environment": "dev", + "ready": true, + "requiredEnv": [ + { + "name": "HWLAB_CLOUD_DB_URL", + "manifestPresent": true, + "k8sPresent": true, + "redacted": true, + "source": "k8s-secret-ref", + "secretRef": { + "secretName": "hwlab-cloud-api-dev-db", + "secretKey": "database-url", + "present": true, + "redacted": true + } + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "manifestPresent": true, + "k8sPresent": true, + "redacted": false, + "source": "runtime-env", + "secretRef": null + } + ], + "missingDeployEnv": [], + "missingK8sEnv": [], + "missingSecretRefs": [], + "secretMaterialRead": false, + "valuesRedacted": true, + "liveDbEvidence": false, + "fixtureEvidence": false + }, + "healthContract": { + "status": "blocked", + "ready": false, + "configReady": false, + "connected": false, + "connectionChecked": false, + "requiredEnv": [ + { + "name": "HWLAB_CLOUD_DB_URL", + "present": false, + "redacted": true + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "present": false, + "redacted": false + } + ], + "missingEnv": [ + "HWLAB_CLOUD_DB_URL", + "HWLAB_CLOUD_DB_SSL_MODE" + ], + "secretRefs": [ + { + "env": "HWLAB_CLOUD_DB_URL", + "secretName": "hwlab-cloud-api-dev-db", + "secretKey": "database-url", + "present": false, + "redacted": true + } + ], + "liveDbEvidence": false, + "fixtureEvidence": false + }, + "fixtureEvidence": false, + "liveDbEvidence": false + } + ] + }, + { + "id": "cloud-api-db-health-gate", + "category": "db", + "status": "blocked", + "summary": "cloud-api DB health gate is missing HWLAB_CLOUD_DB_URL, HWLAB_CLOUD_DB_SSL_MODE.", + "evidence": [ + { + "staticContract": { + "contractVersion": "v1", + "environment": "dev", + "ready": true, + "requiredEnv": [ + { + "name": "HWLAB_CLOUD_DB_URL", + "manifestPresent": true, + "k8sPresent": true, + "redacted": true, + "source": "k8s-secret-ref", + "secretRef": { + "secretName": "hwlab-cloud-api-dev-db", + "secretKey": "database-url", + "present": true, + "redacted": true + } + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "manifestPresent": true, + "k8sPresent": true, + "redacted": false, + "source": "runtime-env", + "secretRef": null + } + ], + "missingDeployEnv": [], + "missingK8sEnv": [], + "missingSecretRefs": [], + "secretMaterialRead": false, + "valuesRedacted": true, + "liveDbEvidence": false, + "fixtureEvidence": false + }, + "healthContract": { + "status": "blocked", + "ready": false, + "configReady": false, + "connected": false, + "connectionChecked": false, + "requiredEnv": [ + { + "name": "HWLAB_CLOUD_DB_URL", + "present": false, + "redacted": true + }, + { + "name": "HWLAB_CLOUD_DB_SSL_MODE", + "present": false, + "redacted": false + } + ], + "missingEnv": [ + "HWLAB_CLOUD_DB_URL", + "HWLAB_CLOUD_DB_SSL_MODE" + ], + "secretRefs": [ + { + "env": "HWLAB_CLOUD_DB_URL", + "secretName": "hwlab-cloud-api-dev-db", + "secretKey": "database-url", + "present": false, + "redacted": true + } + ], + "liveDbEvidence": false, + "fixtureEvidence": false + }, + "fixtureEvidence": false, + "liveDbEvidence": false + } + ] + }, { "id": "dev-artifact-publish-report", "category": "registry", @@ -79,38 +245,13 @@ "id": "dev-edge-health-report", "category": "edge", "status": "blocked", - "summary": "Committed DEV edge health report is blocked with classification frp_blocker.", + "summary": "Committed DEV edge health report is not_run with classification not_run.", "evidence": [ { "report": "reports/dev-gate/dev-edge-health.json", - "status": "blocked", - "classification": "frp_blocker", - "publicTcp": [ - { - "host": "74.48.78.17", - "port": 6667, - "status": "error", - "code": "ECONNREFUSED", - "message": "connect ECONNREFUSED 74.48.78.17:6667", - "durationMs": 221 - }, - { - "host": "74.48.78.17", - "port": 7000, - "status": "error", - "code": "ECONNREFUSED", - "message": "connect ECONNREFUSED 74.48.78.17:7000", - "durationMs": 218 - }, - { - "host": "74.48.78.17", - "port": 7402, - "status": "error", - "code": "ECONNREFUSED", - "message": "connect ECONNREFUSED 74.48.78.17:7402", - "durationMs": 207 - } - ], + "status": "not_run", + "classification": "not_run", + "publicTcp": [], "kubernetesObservable": false } ] @@ -294,7 +435,7 @@ "status": "open", "type": "contract_blocker", "scope": "deploy-target", - "summary": "deploy/deploy.json and deploy/artifact-catalog.dev.json still target 24eb3bf, not origin/main cef2bef.", + "summary": "deploy/deploy.json and deploy/artifact-catalog.dev.json still target 24eb3bf, not origin/main 36bf2c4.", "nextTask": "Publish or select a DEV artifact set for the current origin/main commit and update deploy/deploy.json plus deploy/artifact-catalog.dev.json to that immutable commit/tag." }, { @@ -304,18 +445,25 @@ "summary": "deploy/artifact-catalog.dev.json has ciPublished=false, registryVerified=false, and not_published digests.", "nextTask": "Run the DEV image publishing workflow and record immutable GHCR digests before real deployment." }, + { + "status": "open", + "type": "runtime_blocker", + "scope": "cloud-api-db-health-gate", + "summary": "cloud-api DB runtime env is not ready; missing HWLAB_CLOUD_DB_URL, HWLAB_CLOUD_DB_SSL_MODE.", + "nextTask": "Configure DEV hwlab-cloud-api with Secret hwlab-cloud-api-dev-db/database-url and HWLAB_CLOUD_DB_SSL_MODE=require, then rerun health/preflight without printing the secret value." + }, { "status": "open", "type": "runtime_blocker", "scope": "dev-artifact-publish", - "summary": "reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for origin/main cef2bef; current status is blocked with 0/13 published.", + "summary": "reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for origin/main 36bf2c4; current status is blocked with 0/13 published.", "nextTask": "Complete DEV artifact publishing for every frozen HWLAB service at the current origin/main commit and record immutable registry digests." }, { "status": "open", "type": "network_blocker", "scope": "dev-edge-health", - "summary": "public 6667 and frps control 7000 both refuse TCP connections; frps is not reachable on the master edge", + "summary": "live network probes require --live", "nextTask": "Repair the master frps/public DEV edge route and rerun the read-only DEV edge health smoke before real deployment." }, { diff --git a/scripts/m1-contract-smoke.mjs b/scripts/m1-contract-smoke.mjs index 992d36e2..2fa5ec98 100644 --- a/scripts/m1-contract-smoke.mjs +++ b/scripts/m1-contract-smoke.mjs @@ -139,7 +139,9 @@ async function smokeCloudApi() { const port = await freePort(); const service = startNode("cmd/hwlab-cloud-api/main.mjs", { HWLAB_CLOUD_API_HOST: "127.0.0.1", - HWLAB_CLOUD_API_PORT: String(port) + HWLAB_CLOUD_API_PORT: String(port), + HWLAB_CLOUD_DB_URL: "", + HWLAB_CLOUD_DB_SSL_MODE: "" }); const baseUrl = `http://127.0.0.1:${port}`; @@ -150,10 +152,11 @@ async function smokeCloudApi() { assert.equal(health.body.serviceId, "hwlab-cloud-api"); assert.equal(health.body.environment, ENVIRONMENT_DEV); assert.equal(health.body.status, "ok"); - assert.deepEqual(health.body.db, { - connected: false, - mode: "not_configured" - }); + assert.equal(health.body.db.connected, false); + assert.equal(health.body.db.status, "blocked"); + assert.deepEqual(health.body.db.missingEnv, ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"]); + assert.equal(health.body.db.secretRefs[0].secretName, "hwlab-cloud-api-dev-db"); + assert.equal(health.body.db.secretRefs[0].redacted, true); const live = await requestJson(`${baseUrl}/live`); assert.equal(live.response.status, 200); @@ -173,6 +176,7 @@ async function smokeCloudApi() { assert.equal(rpcHealth.body.jsonrpc, "2.0"); assert.equal(rpcHealth.body.result.status, "ok"); assert.equal(rpcHealth.body.result.serviceId, "hwlab-cloud-api"); + assert.equal(rpcHealth.body.result.db.status, "blocked"); assert.equal(rpcHealth.body.meta.serviceId, "hwlab-cloud-api"); const rpcHardware = await requestJson(`${baseUrl}/rpc`, { diff --git a/scripts/m2-dev-deploy-smoke.mjs b/scripts/m2-dev-deploy-smoke.mjs index c4666cd6..ac0b0f03 100644 --- a/scripts/m2-dev-deploy-smoke.mjs +++ b/scripts/m2-dev-deploy-smoke.mjs @@ -98,6 +98,13 @@ function assertAllServiceIds(entries) { function assertCloudSurface(entry) { const healthArtifacts = entry.health.artifacts ?? []; assert.equal(entry.serviceId, "hwlab-cloud-api", "cloud surface entry must start at cloud-api"); + assert.equal(entry.health.db?.fixtureOnly, true, "cloud surface DB note must be fixture-only"); + assert.equal(entry.health.db?.liveDbEvidence, false, "cloud surface fixture must not claim live DB evidence"); + assert.deepEqual( + entry.health.db?.missingEnv, + ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"], + "cloud surface fixture must name missing DB env without values" + ); assert.equal(healthArtifacts.length, 2, "cloud surface must include cloud-api and cloud-web artifacts"); assert.deepEqual( healthArtifacts.map((artifact) => artifact.serviceId), diff --git a/scripts/src/dev-deploy-apply.mjs b/scripts/src/dev-deploy-apply.mjs index a2313dc3..9c419ba1 100755 --- a/scripts/src/dev-deploy-apply.mjs +++ b/scripts/src/dev-deploy-apply.mjs @@ -6,6 +6,10 @@ import path from "node:path"; import { fileURLToPath, pathToFileURL } from "node:url"; import { spawn } from "node:child_process"; +import { + DEV_DB_ENV_CONTRACT, + summarizeDbContract +} 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)), "../.."); @@ -207,7 +211,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 connection through health output, without reading secret values."; + return "Configure and verify the DEV cloud-api DB env readiness through health output, without reading secret values."; } return blocker.summary; } @@ -411,29 +415,87 @@ function validateK8s(devKustomization, namespaceDoc, workloads, services, health } async function checkCloudApiDb(deploy, workloads, blockers) { - const envNames = new Set(Object.keys(deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {})); + const deployEnv = deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {}; + const envNames = new Set(Object.keys(deployEnv)); + const workloadEnv = new Map(); for (const item of listItems(workloads)) { for (const container of containersFor(item)) { if (serviceIdFor(item, container) === "hwlab-cloud-api") { - for (const env of container.env ?? []) envNames.add(env.name); + for (const env of container.env ?? []) { + envNames.add(env.name); + workloadEnv.set(env.name, env); + } } } } - const dbEnvNames = [...envNames].filter((name) => /DATABASE|POSTGRES|MYSQL|SQLITE|MONGO|_DB(_|$)/u.test(name)); - if (dbEnvNames.length === 0) { - addBlocker(blockers, "runtime_blocker", "cloud-api-db-config", "cloud-api has no DEV DB connection env configured"); + const requiredEnv = DEV_DB_ENV_CONTRACT.requiredEnv.map((name) => { + const secretRef = DEV_DB_ENV_CONTRACT.secretRefs.find((ref) => ref.env === name) ?? null; + const workloadEntry = workloadEnv.get(name) ?? null; + const secretRefPresent = secretRef + ? deployEnv[name] === `secretRef:${secretRef.secretName}/${secretRef.secretKey}` && + workloadEntry?.valueFrom?.secretKeyRef?.name === secretRef.secretName && + workloadEntry?.valueFrom?.secretKeyRef?.key === secretRef.secretKey + : true; + return { + name, + manifestPresent: Object.hasOwn(deployEnv, name), + k8sPresent: workloadEnv.has(name), + redacted: Boolean(secretRef), + source: secretRef ? "k8s-secret-ref" : "runtime-env", + secretRef: secretRef + ? { + secretName: secretRef.secretName, + secretKey: secretRef.secretKey, + present: secretRefPresent, + redacted: true + } + : null + }; + }); + const missingManifest = requiredEnv.filter((env) => !env.manifestPresent).map((env) => env.name); + const missingK8s = requiredEnv.filter((env) => !env.k8sPresent).map((env) => env.name); + 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) { + addBlocker( + blockers, + "runtime_blocker", + "cloud-api-db-config", + `cloud-api DEV DB env contract is incomplete; missing ${[...missingManifest, ...missingK8s, ...missingSecretRefs].join(", ")}` + ); } + let health = null; try { const modulePath = pathToFileURL(path.join(repoRoot, "internal/cloud/server.mjs")).href; const { buildHealthPayload } = await import(modulePath); - const health = buildHealthPayload(); - if (health.db?.connected !== true) { - addBlocker(blockers, "runtime_blocker", "cloud-api-db", "cloud-api health reports DB not connected in this artifact"); + health = buildHealthPayload(); + if (health.db?.ready !== true) { + addBlocker( + blockers, + "runtime_blocker", + "cloud-api-db", + `cloud-api health reports DB config blocked; missing ${health.db?.missingEnv?.join(", ") ?? "unknown DB env"}` + ); } } catch (error) { addBlocker(blockers, "observability_blocker", "cloud-api-health", `Cannot inspect cloud-api health payload: ${error.message}`); } + + return { + status: missingManifest.length === 0 && missingK8s.length === 0 && missingSecretRefs.length === 0 ? "contract-ready" : "contract-blocked", + requiredEnv, + missingManifest, + missingK8s, + missingSecretRefs, + runtimeHealth: health?.db ? summarizeDbContract(health.db) : null, + secretMaterialRead: false, + valuesRedacted: true, + liveDbEvidence: false, + fixtureEvidence: false, + note: "This check records DB env/Secret reference presence only. It is not live DB evidence." + }; } async function probeDevEndpoint(blockers) { @@ -451,8 +513,8 @@ async function probeDevEndpoint(blockers) { if (json?.serviceId !== "hwlab-cloud-api" || json?.environment !== ENVIRONMENT_DEV) { addBlocker(blockers, "runtime_blocker", "dev-health-identity", "DEV health did not identify hwlab-cloud-api in dev"); } - if (json?.db?.connected !== true) { - addBlocker(blockers, "runtime_blocker", "dev-health-db", "DEV health did not confirm cloud-api DB connectivity"); + if (json?.db?.ready !== true && json?.db?.configReady !== true) { + addBlocker(blockers, "runtime_blocker", "dev-health-db", "DEV health did not confirm cloud-api DB config readiness"); } return { status: response.statusCode >= 200 && response.statusCode < 300 ? "pass" : "blocked", @@ -551,7 +613,7 @@ export async function runDevDeployApply(argv, io = {}) { const artifactEvidence = validateDeployAndCatalog(deploy, catalog, commitId, blockers); const k8sManifest = validateK8s(devKustomization, namespaceDoc, workloads, services, healthContract, deploy, blockers); - await checkCloudApiDb(deploy, workloads, blockers); + const cloudApiDb = await checkCloudApiDb(deploy, workloads, 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"); } @@ -622,7 +684,7 @@ export async function runDevDeployApply(argv, io = {}) { requirements: [ "DEV artifact catalog must contain CI publish evidence and registry digests", "kubectl must be available for D601 hwlab-dev and must not target PROD", - "hwlab-cloud-api /health/live must report serviceId, dev environment, and DB connectivity", + "hwlab-cloud-api /health/live must report serviceId, dev environment, and DB env readiness without exposing secret values", "pods, services, configmaps, and image commit tags must be observable in hwlab-dev" ], summary: status === "blocked" ? "One or more required DEV deploy preconditions are blocked." : "DEV deploy preconditions are satisfied." @@ -646,6 +708,7 @@ export async function runDevDeployApply(argv, io = {}) { rollbackHint: buildRollbackHint(workloads), remainingBlockers, artifactEvidence, + cloudApiDb, k8sManifest, clusterObservation, liveProbe diff --git a/scripts/src/dev-edge-health-smoke-lib.mjs b/scripts/src/dev-edge-health-smoke-lib.mjs index 9e5ff071..c4b132c2 100644 --- a/scripts/src/dev-edge-health-smoke-lib.mjs +++ b/scripts/src/dev-edge-health-smoke-lib.mjs @@ -5,6 +5,7 @@ import { mkdir, readFile, writeFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; +import { summarizeDbContract } from "../../internal/cloud/db-contract.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV } from "../../internal/protocol/index.mjs"; const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); @@ -235,11 +236,54 @@ async function inspectContracts() { namespace: deploy.namespace, 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 + tunnelClient: deploy.services?.find((service) => service.serviceId === "hwlab-tunnel-client") ?? null, + cloudApiDb: inspectCloudApiDbContract(deploy) } }; } +function inspectCloudApiDbContract(deploy) { + const env = deploy.services?.find((service) => service.serviceId === "hwlab-cloud-api")?.env ?? {}; + const db = { + status: env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE ? "ready" : "blocked", + ready: Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE), + configReady: Boolean(env.HWLAB_CLOUD_DB_URL && env.HWLAB_CLOUD_DB_SSL_MODE), + connected: false, + connectionChecked: false, + fields: [ + { + name: "HWLAB_CLOUD_DB_URL", + present: Boolean(env.HWLAB_CLOUD_DB_URL), + redacted: true + }, + { + name: "HWLAB_CLOUD_DB_SSL_MODE", + present: Boolean(env.HWLAB_CLOUD_DB_SSL_MODE), + redacted: false + } + ], + missingEnv: ["HWLAB_CLOUD_DB_URL", "HWLAB_CLOUD_DB_SSL_MODE"].filter((name) => !env[name]), + secretRefs: [ + { + env: "HWLAB_CLOUD_DB_URL", + secretName: "hwlab-cloud-api-dev-db", + secretKey: "database-url", + present: env.HWLAB_CLOUD_DB_URL === "secretRef:hwlab-cloud-api-dev-db/database-url", + redacted: true + } + ], + safety: { + liveDbEvidence: false + } + }; + return { + ...summarizeDbContract(db), + secretMaterialRead: false, + valuesRedacted: true, + note: "Manifest-only DB env placeholder; this is not live DB evidence." + }; +} + async function inspectKubernetes() { const hasKubectl = await commandExists("kubectl"); const result = { diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs index 235d46bb..fbd560e1 100644 --- a/scripts/src/dev-gate-preflight.mjs +++ b/scripts/src/dev-gate-preflight.mjs @@ -5,13 +5,18 @@ import path from "node:path"; import { promisify } from "node:util"; import { fileURLToPath } from "node:url"; +import { + DEV_DB_ENV_CONTRACT, + buildDbHealthContract, + summarizeDbContract +} from "../../internal/cloud/db-contract.mjs"; import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs"; const execFileAsync = promisify(execFile); const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "../.."); const defaultReportPath = "reports/dev-gate/dev-preflight-report.json"; const issue = "pikasTech/HWLAB#34"; -const supports = ["#7", "#12", "#22", "#23", "#29", "#30", "#31"].map( +const supports = ["#7", "#12", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#39", "#49"].map( (id) => `pikasTech/HWLAB${id}` ); const forbiddenActions = [ @@ -437,6 +442,152 @@ function validateEdgeHealthReport(reporter, edgeReport) { }); } +function validateCloudApiDbContract(reporter, deploy, workloads) { + const staticContract = inspectCloudApiDbStaticContract(deploy, workloads); + const healthContract = summarizeDbContract(buildDbHealthContract()); + const evidence = [ + { + staticContract, + healthContract, + fixtureEvidence: false, + liveDbEvidence: false + } + ]; + + if (staticContract.ready) { + reporter.check( + "cloud-api-db-env-contract", + "db", + "pass", + "cloud-api DEV DB manifest declares the required env names and redacted Secret reference.", + evidence + ); + } else { + const missing = [ + ...staticContract.missingDeployEnv, + ...staticContract.missingK8sEnv, + ...staticContract.missingSecretRefs + ]; + reporter.check( + "cloud-api-db-env-contract", + "db", + "blocked", + `cloud-api DEV DB manifest is missing ${missing.join(", ")}.`, + evidence + ); + reporter.block({ + type: "contract_blocker", + scope: "cloud-api-db-env-contract", + summary: `cloud-api DEV DB contract is incomplete; missing ${missing.join(", ")}.`, + nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so they expose only the required DB env names and redacted Secret reference." + }); + } + + if (healthContract.ready) { + reporter.check( + "cloud-api-db-health-gate", + "db", + "pass", + "cloud-api DB health gate reports required env presence with redacted values.", + evidence + ); + return; + } + + reporter.check( + "cloud-api-db-health-gate", + "db", + "blocked", + `cloud-api DB health gate is missing ${healthContract.missingEnv.join(", ")}.`, + evidence + ); + reporter.block({ + type: "runtime_blocker", + scope: "cloud-api-db-health-gate", + summary: `cloud-api DB runtime env is not ready; missing ${healthContract.missingEnv.join(", ")}.`, + nextTask: "Configure DEV hwlab-cloud-api with Secret hwlab-cloud-api-dev-db/database-url and HWLAB_CLOUD_DB_SSL_MODE=require, then rerun health/preflight without printing the secret value." + }); +} + +function inspectCloudApiDbStaticContract(deploy, workloads) { + const cloudApi = deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api") ?? {}; + const deployEnv = cloudApi.env ?? {}; + const workloadEnv = getWorkloadEnvByServiceId(workloads, "hwlab-cloud-api"); + const secretEnvNames = new Set(DEV_DB_ENV_CONTRACT.secretRefs.map((ref) => ref.env)); + const fields = DEV_DB_ENV_CONTRACT.requiredEnv.map((name) => { + const secretRef = DEV_DB_ENV_CONTRACT.secretRefs.find((ref) => ref.env === name) ?? null; + const workloadEntry = workloadEnv.get(name) ?? null; + const manifestPresent = Object.hasOwn(deployEnv, name); + const k8sPresent = workloadEnv.has(name); + const secretRefMatches = secretRef + ? deployEnv[name] === `secretRef:${secretRef.secretName}/${secretRef.secretKey}` && + workloadEntry?.valueFrom?.secretKeyRef?.name === secretRef.secretName && + workloadEntry?.valueFrom?.secretKeyRef?.key === secretRef.secretKey + : true; + + return { + name, + manifestPresent, + k8sPresent, + redacted: secretEnvNames.has(name), + source: secretEnvNames.has(name) ? "k8s-secret-ref" : "runtime-env", + secretRef: secretRef + ? { + secretName: secretRef.secretName, + secretKey: secretRef.secretKey, + present: secretRefMatches, + redacted: true + } + : null + }; + }); + const missingDeployEnv = fields.filter((field) => !field.manifestPresent).map((field) => field.name); + const missingK8sEnv = fields.filter((field) => !field.k8sPresent).map((field) => field.name); + const missingSecretRefs = fields + .filter((field) => field.secretRef && !field.secretRef.present) + .map((field) => `${field.secretRef.secretName}/${field.secretRef.secretKey}`); + + return { + contractVersion: DEV_DB_ENV_CONTRACT.contractVersion, + environment: DEV_DB_ENV_CONTRACT.environment, + ready: missingDeployEnv.length === 0 && missingK8sEnv.length === 0 && missingSecretRefs.length === 0, + requiredEnv: fields, + missingDeployEnv, + missingK8sEnv, + missingSecretRefs, + secretMaterialRead: false, + valuesRedacted: true, + liveDbEvidence: false, + fixtureEvidence: false + }; +} + +function getWorkloadEnvByServiceId(workloads, serviceId) { + const envByName = new Map(); + for (const item of listManifestItems(workloads)) { + for (const container of item?.spec?.template?.spec?.containers ?? []) { + const itemServiceId = + item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || + item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] || + container?.name; + if (itemServiceId !== serviceId) { + continue; + } + for (const env of container.env ?? []) { + envByName.set(env.name, env); + } + } + } + return envByName; +} + +function listManifestItems(document) { + if (!document) { + return []; + } + return document.kind === "List" ? document.items ?? [] : [document]; +} + async function validateEdgeContracts(reporter, masterEdge) { try { const [frpc, frps] = await Promise.all([ @@ -634,6 +785,7 @@ export async function runPreflight(argv) { args.targetRef ); + validateCloudApiDbContract(reporter, deploy, contracts[3]); validateArtifactPublishReport(reporter, optionalReports.artifactPublish, 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 df05be41..a5d6a2be 100644 --- a/scripts/validate-contract.mjs +++ b/scripts/validate-contract.mjs @@ -136,5 +136,8 @@ assert.equal(cloudApi.healthPath, "/health/live", "cloud-api health path"); assert.equal(cloudApi.env.HWLAB_COMMIT_ID, deployManifest.commitId, "cloud-api health commit evidence"); assert.equal(cloudApi.env.HWLAB_IMAGE, cloudApi.image, "cloud-api health image evidence"); assert.equal(cloudApi.env.HWLAB_IMAGE_TAG, deployManifest.commitId.slice(0, 7), "cloud-api health image tag evidence"); +assert.equal(cloudApi.env.HWLAB_CLOUD_DB_URL, "secretRef:hwlab-cloud-api-dev-db/database-url", "cloud-api DB URL must be a Secret reference placeholder"); +assert.equal(cloudApi.env.HWLAB_CLOUD_DB_SSL_MODE, "require", "cloud-api DB SSL mode"); +assert.equal(cloudApi.env.HWLAB_CLOUD_DB_CONTRACT, "dev-redacted-presence-only", "cloud-api DB contract marker"); console.log(`validated ${schemas.length + deploySchemas.length} JSON files and ${SERVICE_IDS.length} service ids`);