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
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user