From adaf7582b23445d60f8f2aab7f41678f796e5dd7 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 28 May 2026 18:27:58 +0800 Subject: [PATCH] fix: use v02 DB health contract --- internal/cloud/db-contract.mjs | 98 +++++++++++++++++++++++----------- internal/cloud/server.test.mjs | 66 ++++++++++++++++++++++- 2 files changed, 132 insertions(+), 32 deletions(-) diff --git a/internal/cloud/db-contract.mjs b/internal/cloud/db-contract.mjs index 9d9a094d..33e21db6 100644 --- a/internal/cloud/db-contract.mjs +++ b/internal/cloud/db-contract.mjs @@ -65,12 +65,31 @@ export const DEV_DB_ENV_CONTRACT = Object.freeze({ }) }); -const secretEnvNames = new Set(DEV_DB_ENV_CONTRACT.secretRefs.map((item) => item.env)); +export const V02_DB_ENV_CONTRACT = Object.freeze({ + ...DEV_DB_ENV_CONTRACT, + environment: "v02", + secretRefs: Object.freeze([ + Object.freeze({ + env: "HWLAB_CLOUD_DB_URL", + secretName: "hwlab-cloud-api-v02-db", + secretKey: "database-url" + }) + ]), + dns: Object.freeze({ + ...DEV_DB_ENV_CONTRACT.dns, + serviceName: "hwlab-v02-postgres", + namespace: "hwlab-v02", + host: "hwlab-v02-postgres.hwlab-v02.svc.cluster.local" + }) +}); + const supportedDbProtocols = new Set(["postgres:", "postgresql:"]); const defaultProbeTimeoutMs = 1200; export function buildDbHealthContract(env = process.env) { - const fields = DEV_DB_ENV_CONTRACT.requiredEnv.map((name) => { + const contract = dbEnvContractFor(env); + const secretEnvNames = secretEnvNamesForContract(contract); + const fields = contract.requiredEnv.map((name) => { const present = hasEnvValue(env, name); return { name, @@ -82,15 +101,15 @@ export function buildDbHealthContract(env = process.env) { }); const missingEnv = fields.filter((field) => !field.present).map((field) => field.name); const configReady = missingEnv.length === 0; - const connection = buildNotAttemptedConnection(configReady, missingEnv); + const connection = buildNotAttemptedConnection(configReady, missingEnv, contract); const connected = false; const connectionChecked = false; const status = configReady ? "degraded" : "blocked"; - const endpoint = buildEndpointDiagnostics(env); + const endpoint = buildEndpointDiagnostics(env, contract); return { - contractVersion: DEV_DB_ENV_CONTRACT.contractVersion, - environment: DEV_DB_ENV_CONTRACT.environment, + contractVersion: contract.contractVersion, + environment: contract.environment, connected, liveConnected: connected, liveDbEvidence: connected, @@ -106,7 +125,7 @@ export function buildDbHealthContract(env = process.env) { endpoint, endpointSource: endpoint.authoritative.source, optionalPublicDnsAlias: endpoint.optionalPublicDnsAlias, - secretRefs: DEV_DB_ENV_CONTRACT.secretRefs.map(({ env: envName, secretName, secretKey }) => ({ + secretRefs: contract.secretRefs.map(({ env: envName, secretName, secretKey }) => ({ env: envName, secretName, secretKey, @@ -119,8 +138,9 @@ export function buildDbHealthContract(env = process.env) { connection, redaction: buildRedactionSafety(connection), safety: { - devOnly: true, - prodAllowed: DEV_DB_ENV_CONTRACT.forbidden.prodAllowed, + environment: contract.environment, + devOnly: contract.environment === ENVIRONMENT_DEV, + prodAllowed: contract.forbidden.prodAllowed, secretsRead: false, secretMaterialRead: false, valuesRedacted: true, @@ -169,6 +189,7 @@ export function buildDbEnvManifestPlaceholder() { } export function summarizeDbContract(db = buildDbHealthContract()) { + const contract = dbEnvContractForDb(db); return { status: db.status, ready: db.ready, @@ -207,9 +228,9 @@ export function summarizeDbContract(db = buildDbHealthContract()) { redacted }) ), - endpointSource: db.endpointSource ?? db.endpoint?.authoritative?.source ?? db.connection?.endpointSource ?? DEV_DB_ENV_CONTRACT.endpointAuthority.source, - optionalPublicDnsAlias: summarizeOptionalPublicDnsAlias(db.optionalPublicDnsAlias ?? db.endpoint?.optionalPublicDnsAlias), - connection: summarizeConnection(db.connection), + endpointSource: db.endpointSource ?? db.endpoint?.authoritative?.source ?? db.connection?.endpointSource ?? contract.endpointAuthority.source, + optionalPublicDnsAlias: summarizeOptionalPublicDnsAlias(db.optionalPublicDnsAlias ?? db.endpoint?.optionalPublicDnsAlias, contract), + connection: summarizeConnection(db.connection, contract), redaction: summarizeRedaction(db.redaction), readinessLayers: db.readinessLayers ?? buildReadinessLayers(db.connection), blocker: db.blocker ?? null, @@ -245,17 +266,31 @@ function hasEnvValue(env, name) { return typeof env?.[name] === "string" && env[name].trim().length > 0; } +export function dbEnvContractFor(env = process.env) { + const value = env?.HWLAB_GITOPS_PROFILE || env?.HWLAB_ENVIRONMENT || ""; + const profile = typeof value === "string" ? value.trim().toLowerCase() : ""; + return profile === "v02" ? V02_DB_ENV_CONTRACT : DEV_DB_ENV_CONTRACT; +} + +function dbEnvContractForDb(db = {}) { + return db?.environment === V02_DB_ENV_CONTRACT.environment ? V02_DB_ENV_CONTRACT : DEV_DB_ENV_CONTRACT; +} + +function secretEnvNamesForContract(contract) { + return new Set(contract.secretRefs.map((item) => item.env)); +} + function probeDisabled(env, options) { return options.probe === false || env?.HWLAB_CLOUD_DB_PROBE_DISABLED === "1"; } -function buildNotAttemptedConnection(configReady, missingEnv) { +function buildNotAttemptedConnection(configReady, missingEnv, contract = DEV_DB_ENV_CONTRACT) { return { attempted: false, networkAttempted: false, result: configReady ? "not_attempted" : "not_attempted_missing_env", classification: configReady ? "configured_no_live_attempt" : "missing_runtime_env", - endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source, + endpointSource: contract.endpointAuthority.source, probeType: "tcp-connect", endpointRedacted: true, valueRedacted: true, @@ -277,21 +312,22 @@ function buildRedactionSafety(connection) { } async function probeDbConnection(env, options) { + const contract = dbEnvContractFor(env); const timeoutMs = normalizeTimeoutMs(options.timeoutMs ?? env?.HWLAB_CLOUD_DB_PROBE_TIMEOUT_MS); if (typeof options.probe === "function") { return options.probe({ - endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source, + endpointSource: contract.endpointAuthority.source, timeoutMs }); } - const parsed = parseDbUrlContract(env?.HWLAB_CLOUD_DB_URL); + const parsed = parseDbUrlContract(env?.HWLAB_CLOUD_DB_URL, contract); if (!parsed.ok) { return { attempted: true, networkAttempted: false, result: parsed.result, classification: parsed.classification, - endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source, + endpointSource: contract.endpointAuthority.source, probeType: "tcp-connect", endpointRedacted: true, valueRedacted: true, @@ -306,11 +342,11 @@ async function probeDbConnection(env, options) { host: parsed.host, port: parsed.port, timeoutMs, - endpointSource: DEV_DB_ENV_CONTRACT.endpointAuthority.source + endpointSource: contract.endpointAuthority.source }); } -export function parseDbUrlContract(rawUrl) { +export function parseDbUrlContract(rawUrl, contract = DEV_DB_ENV_CONTRACT) { try { const url = new URL(rawUrl); if (!supportedDbProtocols.has(url.protocol)) { @@ -329,7 +365,7 @@ export function parseDbUrlContract(rawUrl) { errorCode: "MISSING_HOST" }; } - if (isForbiddenRuntimeHost(url.hostname)) { + if (isForbiddenRuntimeHost(url.hostname, contract)) { return { ok: false, result: "forbidden_runtime_host", @@ -509,9 +545,9 @@ function normalizeTimeoutMs(value) { return Math.min(parsed, 5000); } -function isForbiddenRuntimeHost(hostname) { +function isForbiddenRuntimeHost(hostname, contract = DEV_DB_ENV_CONTRACT) { const normalized = String(hostname ?? "").toLowerCase(); - return DEV_DB_ENV_CONTRACT.forbidden.invalidRuntimeHosts.some((host) => + return contract.forbidden.invalidRuntimeHosts.some((host) => host.startsWith(".") ? normalized.endsWith(host) : normalized === host ); } @@ -631,7 +667,7 @@ function buildDbRuntimeReadinessSummary(runtime = {}, { dbLiveReady } = {}) { }; } -function summarizeConnection(connection) { +function summarizeConnection(connection, contract = DEV_DB_ENV_CONTRACT) { if (!connection) { return { attempted: false, @@ -645,7 +681,7 @@ function summarizeConnection(connection) { networkAttempted: Boolean(connection.networkAttempted), result: connection.result, classification: connection.classification, - endpointSource: connection.endpointSource ?? DEV_DB_ENV_CONTRACT.endpointAuthority.source, + endpointSource: connection.endpointSource ?? contract.endpointAuthority.source, probeType: connection.probeType, endpointRedacted: true, valueRedacted: true, @@ -655,8 +691,8 @@ function summarizeConnection(connection) { }; } -function buildEndpointDiagnostics(env) { - const authority = DEV_DB_ENV_CONTRACT.endpointAuthority; +function buildEndpointDiagnostics(env, contract = dbEnvContractFor(env)) { + const authority = contract.endpointAuthority; return { authoritative: { source: authority.source, @@ -666,12 +702,12 @@ function buildEndpointDiagnostics(env) { endpointRedacted: true, valueRedacted: true }, - optionalPublicDnsAlias: buildOptionalPublicDnsAlias(env) + optionalPublicDnsAlias: buildOptionalPublicDnsAlias(env, contract) }; } -function buildOptionalPublicDnsAlias(env) { - const alias = DEV_DB_ENV_CONTRACT.dns; +function buildOptionalPublicDnsAlias(env, contract = dbEnvContractFor(env)) { + const alias = contract.dns; const serviceNamePresent = env?.HWLAB_CLOUD_DB_SERVICE_NAME === alias.serviceName; const namespacePresent = env?.HWLAB_CLOUD_DB_SERVICE_NAMESPACE === alias.namespace; const hostPresent = env?.HWLAB_CLOUD_DB_HOST === alias.host; @@ -693,8 +729,8 @@ function buildOptionalPublicDnsAlias(env) { }; } -function summarizeOptionalPublicDnsAlias(alias) { - const fallback = buildOptionalPublicDnsAlias({}); +function summarizeOptionalPublicDnsAlias(alias, contract = DEV_DB_ENV_CONTRACT) { + const fallback = buildOptionalPublicDnsAlias({}, contract); const value = alias ?? fallback; return { source: value.source ?? fallback.source, diff --git a/internal/cloud/server.test.mjs b/internal/cloud/server.test.mjs index 23c89b08..a6afa0c8 100644 --- a/internal/cloud/server.test.mjs +++ b/internal/cloud/server.test.mjs @@ -272,7 +272,7 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep assert.equal(payload.latest.build.metadataSource, "deploy-env:HWLAB_BUILD_CREATED_AT"); assert.equal(payload.latest.build.liveMetadataMatch.metadata.imageTag, "skillsabcd"); assert.match(payload.latest.build.liveHealthMissingReason, /匹配的 deploy metadata/u); - assert.equal(payload.counts.total, 15); + assert.equal(payload.counts.total, payload.services.length); assert.equal(payload.counts.external, 2); assert.equal(payload.counts.withBuildTime, 3); const manager = payload.services.find((service) => service.serviceId === "hwlab-agent-mgr"); @@ -292,6 +292,7 @@ test("cloud api aggregates live HWLAB build times from health and controlled dep assert.match(worker.build.unavailableReason, /hwlab-agent-worker 当前 hwlab-dev desired replicas=0/u); assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-edge-proxy" && service.healthUrl.endsWith("/health"))); assert.ok(payload.services.some((service) => service.kind === "external" && service.serviceId === "hwlab-frpc" && service.image.tag === "v0.68.1")); + assert.ok(payload.services.some((service) => service.serviceId === "hwlab-device-pod")); } finally { await new Promise((resolve, reject) => { server.close((error) => (error ? reject(error) : resolve())); @@ -534,6 +535,69 @@ test("cloud api health reports DB env presence and live connection classificatio } }); +test("cloud api health reports the v02 DB contract under the v02 profile", async () => { + const originalEnv = { + HWLAB_ENVIRONMENT: process.env.HWLAB_ENVIRONMENT, + HWLAB_GITOPS_PROFILE: process.env.HWLAB_GITOPS_PROFILE, + HWLAB_CLOUD_DB_URL: process.env.HWLAB_CLOUD_DB_URL, + HWLAB_CLOUD_DB_SSL_MODE: process.env.HWLAB_CLOUD_DB_SSL_MODE, + HWLAB_CLOUD_DB_SERVICE_NAME: process.env.HWLAB_CLOUD_DB_SERVICE_NAME, + HWLAB_CLOUD_DB_SERVICE_NAMESPACE: process.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE, + HWLAB_CLOUD_DB_HOST: process.env.HWLAB_CLOUD_DB_HOST, + HWLAB_CLOUD_DB_PORT: process.env.HWLAB_CLOUD_DB_PORT + }; + const fakeDb = createTcpServer((socket) => socket.end()); + await new Promise((resolve) => fakeDb.listen(0, "127.0.0.1", resolve)); + const dbPort = fakeDb.address().port; + process.env.HWLAB_ENVIRONMENT = "v02"; + process.env.HWLAB_GITOPS_PROFILE = "v02"; + process.env.HWLAB_CLOUD_DB_URL = `postgres://hwlab_test@127.0.0.1:${dbPort}/hwlab_v02`; + process.env.HWLAB_CLOUD_DB_SSL_MODE = "disable"; + delete process.env.HWLAB_CLOUD_DB_SERVICE_NAME; + delete process.env.HWLAB_CLOUD_DB_SERVICE_NAMESPACE; + delete process.env.HWLAB_CLOUD_DB_HOST; + delete process.env.HWLAB_CLOUD_DB_PORT; + + 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(); + const dbJson = JSON.stringify(payload.db); + assert.equal(payload.environment, "v02"); + assert.equal(payload.db.environment, "v02"); + assert.equal(payload.db.secretRefs[0].secretName, "hwlab-cloud-api-v02-db"); + assert.equal(payload.db.secretRefs[0].secretKey, "database-url"); + assert.equal(payload.db.optionalPublicDnsAlias.serviceName, "hwlab-v02-postgres"); + assert.equal(payload.db.optionalPublicDnsAlias.namespace, "hwlab-v02"); + assert.equal(payload.db.optionalPublicDnsAlias.requiredForReadiness, false); + assert.equal(payload.db.optionalPublicDnsAlias.usedForProbe, false); + assert.equal(payload.db.safety.environment, "v02"); + assert.equal(payload.db.safety.devOnly, false); + assert.equal(payload.db.redaction.secretMaterialRead, false); + assert.equal(dbJson.includes("hwlab-cloud-api-dev-db"), false); + assert.equal(dbJson.includes("hwlab-dev"), false); + assert.equal(dbJson.includes("127.0.0.1"), false); + assert.equal(dbJson.includes(String(dbPort)), false); + } finally { + for (const [name, value] of Object.entries(originalEnv)) { + if (value === undefined) { + delete process.env[name]; + } else { + process.env[name] = value; + } + } + await new Promise((resolve, reject) => { + server.close((error) => (error ? reject(error) : resolve())); + }); + await new Promise((resolve, reject) => { + fakeDb.close((error) => (error ? reject(error) : resolve())); + }); + } +}); + test("cloud api health separates DB connected from durable runtime schema readiness", async () => { const originalUrl = process.env.HWLAB_CLOUD_DB_URL; const originalSslMode = process.env.HWLAB_CLOUD_DB_SSL_MODE;