feat: add dev db health contract
This commit is contained in:
@@ -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.
|
||||
|
||||
+4
-1
@@ -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"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -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": {
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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 |
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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()
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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()));
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
+1
-1
@@ -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",
|
||||
|
||||
@@ -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."
|
||||
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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."
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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`, {
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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`);
|
||||
|
||||
Reference in New Issue
Block a user