feat: add dev db health contract
This commit is contained in:
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user