import assert from "node:assert/strict"; import { execFile } from "node:child_process"; import { mkdir, readFile, writeFile } from "node:fs/promises"; 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"; import { probeRegistryCapabilities } from "./registry-capabilities.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", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#39", "#49", "#66"].map( (id) => `pikasTech/HWLAB${id}` ).concat(["pikasTech/HWLAB#33", "pikasTech/HWLAB#35", "pikasTech/HWLAB#48"]); const forbiddenActions = [ "prod-deploy", "secret-material-read", "unidesk-runtime-substitute", "heavy-e2e", "browser-e2e", "force-push", "runtime-restart" ]; const blockerTypes = new Set([ "contract_blocker", "environment_blocker", "network_blocker", "runtime_blocker", "agent_blocker", "observability_blocker", "safety_blocker" ]); const digestPattern = /^sha256:[a-f0-9]{64}$/; function parseArgs(argv) { const args = { targetRef: "origin/main", reportPath: defaultReportPath, timeoutMs: 5000, writeReport: true, failOnBlocked: false }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--target-ref") { args.targetRef = argv[++index]; } else if (arg === "--report") { args.reportPath = argv[++index]; } else if (arg === "--timeout-ms") { args.timeoutMs = Number.parseInt(argv[++index], 10); } else if (arg === "--no-write") { args.writeReport = false; } else if (arg === "--fail-on-blocked") { args.failOnBlocked = true; } else if (arg === "--help") { args.help = true; } else { throw new Error(`unknown argument ${arg}`); } } assert.ok(Number.isInteger(args.timeoutMs) && args.timeoutMs > 0, "--timeout-ms must be positive"); return args; } function usage() { return "Usage: node scripts/dev-gate-preflight.mjs [--target-ref origin/main] [--report reports/dev-gate/dev-preflight-report.json] [--timeout-ms 5000] [--no-write] [--fail-on-blocked]"; } function commandLine(command, args) { return [command, ...args].join(" "); } async function run(command, args = [], options = {}) { const commandText = commandLine(command, args); try { const result = await execFileAsync(command, args, { cwd: repoRoot, timeout: options.timeoutMs ?? 5000, maxBuffer: 1024 * 1024 }); return { ok: true, command: commandText, exitCode: 0, stdout: result.stdout.trim(), stderr: result.stderr.trim() }; } catch (error) { return { ok: false, command: commandText, exitCode: typeof error.code === "number" ? error.code : 1, stdout: String(error.stdout ?? "").trim(), stderr: String(error.stderr ?? "").trim(), error: error.message }; } } async function commandExists(command) { return (await run("which", [command])).ok; } async function readJson(relativePath) { const raw = await readFile(path.join(repoRoot, relativePath), "utf8"); return JSON.parse(raw); } async function readOptionalJson(relativePath) { try { return await readJson(relativePath); } catch (error) { if (error && typeof error === "object" && "code" in error && error.code === "ENOENT") { return null; } throw error; } } async function parseToml(relativePath) { const absolutePath = path.join(repoRoot, relativePath); const code = [ "import json,sys,tomllib", "with open(sys.argv[1], 'rb') as handle:", " print(json.dumps(tomllib.load(handle), sort_keys=True))" ].join("\n"); const result = await run("python3", ["-c", code, absolutePath]); if (!result.ok) { throw new Error(`could not parse ${relativePath}: ${result.stderr || result.error}`); } return JSON.parse(result.stdout); } function fetchErrorMessage(error) { if (!(error instanceof Error)) { return String(error); } const cause = error.cause; if (cause && typeof cause === "object") { const code = "code" in cause ? String(cause.code) : ""; const address = "address" in cause ? String(cause.address) : ""; const port = "port" in cause ? String(cause.port) : ""; return [error.message, code, address, port].filter(Boolean).join(" "); } return error.message; } async function httpProbe(url, { method = "GET", timeoutMs = 5000, headers = {} } = {}) { const controller = new AbortController(); const timer = setTimeout(() => controller.abort(), timeoutMs); try { const response = await fetch(url, { method, headers, signal: controller.signal }); const body = method === "HEAD" ? "" : await response.text(); return { ok: response.ok, url, method, status: response.status, statusText: response.statusText, body: body.slice(0, 500) }; } catch (error) { return { ok: false, url, method, error: fetchErrorMessage(error) }; } finally { clearTimeout(timer); } } function unique(values) { return new Set(values).size === values.length; } function addBlocker(blockers, blocker) { assert.ok(blockerTypes.has(blocker.type), `unknown blocker type ${blocker.type}`); const key = `${blocker.type}:${blocker.scope}`; if (!blockers.some((item) => `${item.type}:${item.scope}` === key)) { blockers.push({ status: "open", ...blocker }); } } function imageToManifestUrl(image) { const match = image.match(/^ghcr\.io\/pikastech\/([^:@]+):([^:@]+)$/); if (match) return `https://ghcr.io/v2/pikastech/${match[1]}/manifests/${match[2]}`; const localMatch = image.match(/^(127\.0\.0\.1:5000|localhost:5000)\/hwlab\/([^:@]+):([^:@]+)$/); if (localMatch) return `http://${localMatch[1]}/v2/hwlab/${localMatch[2]}/manifests/${localMatch[3]}`; return null; } function matchesTargetCommit(value, targetCommit, targetShortCommit) { return value === targetCommit || value === targetShortCommit; } function uniqueSorted(values) { return [...new Set(values)].sort(); } function catalogDigestCounts(catalog) { const counts = { sha256: 0, notPublished: 0, invalid: 0 }; for (const service of catalog.services ?? []) { if (digestPattern.test(service.digest)) counts.sha256 += 1; else if (service.digest === "not_published") counts.notPublished += 1; else counts.invalid += 1; } return counts; } function requiredCatalogServices(catalog) { return (catalog.services ?? []).filter((service) => service.artifactRequired !== false); } function disabledCatalogServices(catalog) { return (catalog.services ?? []).filter((service) => service.artifactRequired === false); } function artifactIdentityFor({ deploy, catalog, targetCommit, targetShortCommit, targetRef }) { const serviceCommitIds = uniqueSorted((catalog.services ?? []).map((service) => service.commitId)); const digestCounts = catalogDigestCounts(catalog); const requiredServices = requiredCatalogServices(catalog); const disabledServices = disabledCatalogServices(catalog); const catalogMatchesSource = matchesTargetCommit(catalog.commitId, targetCommit, targetShortCommit); const deployMatchesSource = matchesTargetCommit(deploy.commitId, targetCommit, targetShortCommit); const servicesMatchSource = serviceCommitIds.length === 1 && matchesTargetCommit(serviceCommitIds[0], targetCommit, targetShortCommit); const allRequiredDigestsPublished = requiredServices.length > 0 && requiredServices.every((service) => digestPattern.test(service.digest) && service.publishState === "published") && disabledServices.every((service) => service.digest === "not_published" && service.publishState === "skeleton-only") && digestCounts.invalid === 0; const publishVerified = catalog.publish?.ciPublished === true && catalog.publish?.registryVerified === true && catalog.artifactState === "published" && allRequiredDigestsPublished; return { source: { ref: targetRef, commitId: targetCommit, shortCommitId: targetShortCommit }, deployManifest: { path: "deploy/deploy.json", commitId: deploy.commitId, matchesSource: deployMatchesSource }, artifactCatalog: { path: "deploy/artifact-catalog.dev.json", commitId: catalog.commitId, artifactState: catalog.artifactState, ciPublished: catalog.publish?.ciPublished === true, registryVerified: catalog.publish?.registryVerified === true, provenance: catalog.publish?.provenance ?? "unknown", digestCounts, requiredServiceCount: requiredServices.length, disabledServiceCount: disabledServices.length, matchesSource: catalogMatchesSource }, services: (catalog.services ?? []).map((service) => ({ serviceId: service.serviceId, commitId: service.commitId, matchesSource: matchesTargetCommit(service.commitId, targetCommit, targetShortCommit), image: service.image, imageTag: service.imageTag, digest: service.digest, publishState: service.publishState, artifactRequired: service.artifactRequired !== false, notPublishedReason: service.notPublishedReason ?? null })), serviceCommitIds, matchesSource: deployMatchesSource && catalogMatchesSource && servicesMatchSource, publishVerified, refreshCommands: { blocked: `node scripts/refresh-artifact-catalog.mjs --target-ref ${targetRef} --blocked`, published: `node scripts/refresh-artifact-catalog.mjs --target-ref ${targetRef} --publish-report reports/dev-gate/dev-artifacts.json` } }; } function assertStaticContract(deploy, catalog) { assert.equal(deploy.environment, ENVIRONMENT_DEV, "deploy environment must be dev"); assert.equal(deploy.namespace, "hwlab-dev", "deploy namespace must be hwlab-dev"); assert.equal(deploy.endpoint, DEV_ENDPOINT, "deploy endpoint must stay frozen"); assert.equal(deploy.profiles.dev.enabled, true, "dev profile must be enabled"); assert.equal(deploy.profiles.prod.enabled, false, "prod profile must stay disabled"); assert.deepEqual(deploy.services.map((service) => service.serviceId), SERVICE_IDS); assert.equal(catalog.environment, ENVIRONMENT_DEV, "catalog environment must be dev"); assert.equal(catalog.namespace, "hwlab-dev", "catalog namespace must be hwlab-dev"); assert.equal(catalog.endpoint, DEV_ENDPOINT, "catalog endpoint must stay frozen"); assert.deepEqual(catalog.allowedProfiles, [ENVIRONMENT_DEV]); assert.ok(catalog.forbiddenProfiles.includes("prod"), "catalog must forbid prod"); assert.deepEqual(catalog.services.map((service) => service.serviceId), SERVICE_IDS); assert.ok(unique(catalog.services.map((service) => service.serviceId)), "catalog service IDs must be unique"); assert.equal(catalog.commitId, deploy.commitId, "catalog commitId must match deploy commitId"); const deployByService = new Map(deploy.services.map((service) => [service.serviceId, service])); for (const service of catalog.services) { const deployService = deployByService.get(service.serviceId); assert.ok(deployService, `${service.serviceId} missing from deploy manifest`); assert.equal(service.image, deployService.image, `${service.serviceId} image mismatch`); assert.equal(service.namespace, "hwlab-dev", `${service.serviceId} namespace mismatch`); assert.equal(service.profile, ENVIRONMENT_DEV, `${service.serviceId} profile mismatch`); assert.equal(service.healthPath, "/health/live", `${service.serviceId} health path mismatch`); } } function assertK8sStatic(namespace, workloads, services, devKustomization, healthContract) { assert.equal(namespace.kind, "Namespace"); assert.equal(namespace.metadata.name, "hwlab-dev"); assert.deepEqual(devKustomization.resources, ["../base", "health-contract.yaml"]); assert.equal(devKustomization.namespace, "hwlab-dev"); assert.equal(healthContract.kind, "ConfigMap"); assert.equal(healthContract.metadata.namespace, "hwlab-dev"); assert.equal(healthContract.data.endpoint, DEV_ENDPOINT); assert.ok(healthContract.data["runtime-substitute-policy"].includes("Do not replace HWLAB runtime")); const resources = [...workloads.items, ...services.items, healthContract]; assert.ok(resources.length > SERVICE_IDS.length, "k8s resources must cover DEV services"); for (const resource of resources) { assert.notEqual(resource.kind, "Secret", "preflight contract must not require Secret resources"); if (resource.metadata?.namespace) { assert.equal(resource.metadata.namespace, "hwlab-dev", `${resource.kind}/${resource.metadata.name} namespace`); } } } function assertFrpStatic(frpc, frps, masterEdge) { assert.equal(frpc.serverAddr, "74.48.78.17"); assert.equal(frpc.serverPort, 7000); assert.equal(frps.bindPort, 7000); assert.equal(Object.hasOwn(frps, "vhostHTTPPort"), false, "frps must not bind vhostHTTPPort because 16667 is reserved for the TCP proxy"); assert.ok(frpc.proxies.some((proxy) => proxy.name === "hwlab-dev-edge-proxy" && proxy.remotePort === 16667)); assert.ok(frpc.proxies.some((proxy) => proxy.name === "hwlab-dev-cloud-web" && proxy.remotePort === 16666)); assert.ok(frps.allowPorts.some((port) => port.start === 16667 && port.end === 16667)); assert.ok(frps.allowPorts.some((port) => port.start === 16666 && port.end === 16666)); assert.equal(masterEdge.environment, ENVIRONMENT_DEV); assert.equal(masterEdge.endpoint, DEV_ENDPOINT); assert.equal(masterEdge.reverseLink.mode, "frp"); assert.equal(masterEdge.reverseLink.direction, "d601-to-master"); assert.equal(masterEdge.prodAcceptance, false); } async function loadContracts() { return Promise.all([ readJson("deploy/deploy.json"), readJson("deploy/artifact-catalog.dev.json"), readJson("deploy/k8s/base/namespace.yaml"), readJson("deploy/k8s/base/workloads.yaml"), readJson("deploy/k8s/base/services.yaml"), readJson("deploy/k8s/dev/kustomization.yaml"), readJson("deploy/k8s/dev/health-contract.yaml"), readJson("deploy/master-edge/health-contract.json") ]); } async function loadOptionalReports() { return { artifactPublish: await readOptionalJson("reports/dev-gate/dev-artifacts.json"), edgeHealth: await readOptionalJson("reports/dev-gate/dev-edge-health.json") }; } function makeReporter() { const checks = []; const blockers = []; return { checks, blockers, check(id, category, status, summary, evidence = []) { checks.push({ id, category, status, summary, evidence }); }, block(blocker) { addBlocker(blockers, blocker); } }; } function validateLocalContracts(reporter, contracts, targetShortCommit, targetCommit, targetRef) { const [deploy, catalog, namespace, workloads, services, devKustomization, healthContract, masterEdge] = contracts; const artifactIdentity = artifactIdentityFor({ deploy, catalog, targetCommit, targetShortCommit, targetRef }); try { assertStaticContract(deploy, catalog); reporter.check("source-contract-static", "contract", "pass", "Local deploy manifest and artifact catalog are internally consistent."); } catch (error) { reporter.check("source-contract-static", "contract", "blocked", error.message); reporter.block({ type: "contract_blocker", scope: "source-contract", summary: error.message, nextTask: "Repair the local DEV deploy manifest and artifact catalog contract before any live deploy." }); } if (artifactIdentity.matchesSource) { reporter.check( "target-commit-pinning", "contract", "pass", `Source ${targetRef} ${targetShortCommit} matches deploy/catalog artifact commit ${artifactIdentity.artifactCatalog.commitId}.`, [artifactIdentity] ); } else { reporter.check( "target-commit-pinning", "contract", "blocked", `Source ${targetRef} ${targetShortCommit} is not covered by deploy commit ${deploy.commitId}, catalog commit ${catalog.commitId}, service commits ${artifactIdentity.serviceCommitIds.join(", ")}.`, [artifactIdentity] ); reporter.block({ type: "contract_blocker", scope: "artifact-source-commit", summary: `source commit ${targetRef} ${targetShortCommit} does not match deploy/catalog artifact identity: deploy=${deploy.commitId}, catalog=${catalog.commitId}, services=${artifactIdentity.serviceCommitIds.join(", ")}.`, nextTask: `Refresh without fake digests using \`${artifactIdentity.refreshCommands.blocked}\`, or after a successful publish run \`${artifactIdentity.refreshCommands.published}\`.` }); } if (artifactIdentity.publishVerified) { reporter.check( "artifact-catalog-publish-state", "registry", "pass", "Artifact catalog records CI publish, registry verification, and immutable service digests.", [artifactIdentity.artifactCatalog] ); } else { reporter.check( "artifact-catalog-publish-state", "registry", "blocked", `Artifact catalog publish state is ${catalog.artifactState}; ciPublished=${artifactIdentity.artifactCatalog.ciPublished}, registryVerified=${artifactIdentity.artifactCatalog.registryVerified}, digests sha256=${artifactIdentity.artifactCatalog.digestCounts.sha256}, not_published=${artifactIdentity.artifactCatalog.digestCounts.notPublished}, invalid=${artifactIdentity.artifactCatalog.digestCounts.invalid}.`, [artifactIdentity.artifactCatalog] ); reporter.block({ type: "runtime_blocker", scope: "artifact-catalog", summary: `deploy/artifact-catalog.dev.json does not prove published artifacts for ${targetRef} ${targetShortCommit}; ciPublished=${artifactIdentity.artifactCatalog.ciPublished}, registryVerified=${artifactIdentity.artifactCatalog.registryVerified}, not_published=${artifactIdentity.artifactCatalog.digestCounts.notPublished}.`, nextTask: `Run the DEV artifact publish workflow, then record only real sha256 digests with \`${artifactIdentity.refreshCommands.published}\`; if publish is still blocked, keep not_published via \`${artifactIdentity.refreshCommands.blocked}\`.` }); } try { assertK8sStatic(namespace, workloads, services, devKustomization, healthContract); reporter.check("k8s-manifest-static", "k3s", "pass", "DEV k3s manifest files parse and stay scoped to hwlab-dev."); } catch (error) { reporter.check("k8s-manifest-static", "k3s", "blocked", error.message); reporter.block({ type: "contract_blocker", scope: "deploy/k8s/dev", summary: error.message, nextTask: "Repair the DEV k3s manifests so they parse and target only hwlab-dev." }); } return { deploy, catalog, masterEdge, artifactIdentity }; } function validateArtifactPublishReport(reporter, artifactReport, targetShortCommit, targetCommit, targetRef) { if (!artifactReport) { reporter.check("dev-artifact-publish-report", "registry", "blocked", "No DEV artifact publish report is present."); reporter.block({ type: "runtime_blocker", scope: "dev-artifact-publish", summary: "reports/dev-gate/dev-artifacts.json is missing, so the preflight has no publish evidence for HWLAB runtime artifacts.", nextTask: "Run the DEV artifact publish workflow and record reports/dev-gate/dev-artifacts.json before real deployment." }); return; } const artifactPublish = artifactReport.artifactPublish; const services = artifactPublish?.services ?? []; const sourcePresentCount = services.filter((service) => service.sourceState === "source-present").length; const intentionallyDisabledCount = services.filter((service) => service.sourceState === "intentionally-disabled").length; const sourcePresenceResolved = services.length === SERVICE_IDS.length && services.every((service) => service.sourceState === "source-present" || service.sourceState === "intentionally-disabled"); const requiredServices = services.filter((service) => service.artifactRequired !== false); const disabledServices = services.filter((service) => service.artifactRequired === false); const allServicesAccountedFor = services.length === SERVICE_IDS.length && [...services].map((service) => service.serviceId).sort().join("\n") === [...SERVICE_IDS].sort().join("\n"); const allRequiredServicesPublished = requiredServices.length > 0 && requiredServices.every((service) => service.status === "published" && /^sha256:[a-f0-9]{64}$/.test(service.digest)); const disabledServicesNotPublished = disabledServices.every( (service) => service.digest === "not_published" && service.notPublishedReason?.startsWith("disabled_") ); const sourceMatchesTarget = [targetCommit, targetShortCommit].includes(artifactPublish?.sourceCommitId) || [targetCommit, targetShortCommit].includes(artifactReport.commitId); const publishReady = artifactPublish?.status === "published" && artifactPublish.publishedCount === requiredServices.length && allServicesAccountedFor && allRequiredServicesPublished && disabledServicesNotPublished && sourceMatchesTarget; if (publishReady) { reporter.check("dev-artifact-publish-report", "registry", "pass", "DEV artifact publish report covers all required enabled service IDs with immutable digests for the target commit, and records disabled service reasons."); return; } const publishedCount = artifactPublish?.publishedCount ?? 0; const serviceCount = artifactPublish?.serviceCount ?? SERVICE_IDS.length; const sourceSummary = sourcePresenceResolved ? `source states resolved: ${sourcePresentCount} source-present, ${intentionallyDisabledCount} intentionally-disabled` : "source states are not fully resolved"; reporter.check( "dev-artifact-publish-report", "registry", "blocked", `DEV artifact publish report status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${requiredServices.length || serviceCount} required services published for source ${artifactPublish?.sourceCommitId ?? artifactReport.commitId ?? "unknown"}; ${sourceSummary}.` ); reporter.block({ type: "runtime_blocker", scope: "dev-artifact-publish", summary: `reports/dev-gate/dev-artifacts.json does not prove all required HWLAB service artifacts for ${targetRef} ${targetShortCommit}; current status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${requiredServices.length || serviceCount} required services published and ${sourceSummary}.`, nextTask: "Complete DEV artifact publishing for every enabled required HWLAB service at the current origin/main commit and record immutable registry digests; keep disabled services marked not_published with reasons." }); } function validateRegistryCapabilities(reporter, registryCapabilities) { for (const dimension of Object.values(registryCapabilities.dimensions)) { reporter.check( `registry-capability-${dimension.id}`, "registry", dimension.status, dimension.summary, [dimension] ); } reporter.check( "registry-capability-classification", "registry", registryCapabilities.classification, `Registry capability classification is ${registryCapabilities.classification}: process HTTP, Docker daemon push path, and k3s pull path are reported separately.`, [registryCapabilities] ); const dockerDaemonPushAccess = registryCapabilities.dimensions.dockerDaemonPushAccess; if (dockerDaemonPushAccess.status === "blocked") { reporter.block({ type: "network_blocker", scope: "docker-daemon-push-access", summary: dockerDaemonPushAccess.summary, nextTask: "Restore Docker daemon access to the D601 local/internal registry before claiming DEV artifact publish readiness." }); } const k3sPullAccess = registryCapabilities.dimensions.k3sPullAccess; if (k3sPullAccess.status === "blocked") { reporter.block({ type: "network_blocker", scope: "k3s-pull-access", summary: k3sPullAccess.summary, nextTask: "Repair read-only hwlab-dev k3s access or image pull configuration before claiming deploy-path registry readiness." }); } } function manifestReadStatus(registryEvidence) { if (registryEvidence.every((probe) => probe.ok)) return "pass"; if (registryEvidence.some((probe) => probe.error || probe.status === 401 || probe.status === 403 || probe.status === 404)) { return "degraded"; } return "blocked"; } function validateEdgeHealthReport(reporter, edgeReport) { if (!edgeReport) { reporter.check("dev-edge-health-report", "edge", "blocked", "No DEV edge health report is present."); reporter.block({ type: "network_blocker", scope: "dev-edge-health-report", summary: "reports/dev-gate/dev-edge-health.json is missing, so the preflight has no committed edge/frp health evidence.", nextTask: "Run the read-only DEV edge health smoke and commit reports/dev-gate/dev-edge-health.json before real deployment." }); return; } const edgeHealth = edgeReport.edgeHealth; const pass = edgeReport.status === "pass" && edgeHealth?.status === "pass"; const evidence = [ { report: "reports/dev-gate/dev-edge-health.json", status: edgeReport.status, classification: edgeHealth?.classification ?? "unknown", publicTcp: edgeHealth?.publicTcp ?? [], kubernetesObservable: edgeHealth?.kubernetes?.observable ?? false } ]; if (pass) { reporter.check("dev-edge-health-report", "edge", "pass", "Committed DEV edge health report shows public edge health passing.", evidence); return; } const classification = edgeHealth?.classification ?? "unknown"; const summary = edgeHealth?.blocker ?? edgeReport.devPreconditions?.summary ?? "DEV edge health report is not passing."; reporter.check("dev-edge-health-report", "edge", "blocked", `Committed DEV edge health report is ${edgeReport.status} with classification ${classification}.`, evidence); reporter.block({ type: "network_blocker", scope: edgeHealth?.diagnosis?.likelyLayer ?? "dev-edge-health", summary, nextTask: edgeHealth?.diagnosis?.nextTask ?? "Repair the master frps/public DEV edge route and rerun the read-only DEV edge health smoke before real deployment." }); } 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([ parseToml("deploy/frp/frpc.dev.toml"), parseToml("deploy/frp/frps.dev.toml") ]); assertFrpStatic(frpc, frps, masterEdge); reporter.check("frp-master-edge-static", "edge", "pass", "FRP and master-edge contracts describe D601-to-master DEV on public ports 16666/16667."); } catch (error) { reporter.check("frp-master-edge-static", "edge", "blocked", error.message); reporter.block({ type: "contract_blocker", scope: "deploy/frp", summary: error.message, nextTask: "Repair FRP and master-edge DEV contracts before live reachability checks." }); } } async function validateLiveProbes(reporter, catalog, timeoutMs) { const kubectlExists = await commandExists("kubectl"); if (!kubectlExists) { reporter.check("d601-k3s-read-access", "k3s", "blocked", "kubectl is not installed in this runner, so hwlab-dev live cluster evidence cannot be collected."); reporter.block({ type: "environment_blocker", scope: "d601-k3s", summary: "D601 runner lacks kubectl and no default kubeconfig was used by this preflight.", nextTask: "Provide a read-only kubectl/kubeconfig path for the real D601 hwlab-dev k3s cluster, then rerun this preflight." }); } else { const k3sEvidence = []; for (const probe of [ ["kubectl", ["version", "--client=true"]], ["kubectl", ["config", "current-context"]], ["kubectl", ["auth", "can-i", "get", "pods", "-n", "hwlab-dev"]], ["kubectl", ["get", "namespace", "hwlab-dev", "-o", "json"]], ["kubectl", ["-n", "hwlab-dev", "get", "deploy,svc,job,cm", "-o", "name"]] ]) { k3sEvidence.push(await run(probe[0], probe[1], { timeoutMs })); } const ok = k3sEvidence.every((probe) => probe.ok); reporter.check("d601-k3s-read-access", "k3s", ok ? "pass" : "blocked", ok ? "Read-only kubectl probes reached hwlab-dev." : "At least one read-only kubectl probe failed.", k3sEvidence); if (!ok) { reporter.block({ type: "environment_blocker", scope: "d601-k3s", summary: "Read-only kubectl probes could not prove hwlab-dev namespace access.", nextTask: "Fix read-only D601 k3s credentials and confirm kubectl can get namespace, pods, services, jobs, and ConfigMaps in hwlab-dev." }); } } const edgeProbe = await httpProbe(`${DEV_ENDPOINT}/health/live`, { timeoutMs }); if (edgeProbe.ok) { reporter.check("public-dev-edge-health", "edge", "pass", "Public DEV health endpoint responded successfully.", [edgeProbe]); } else { reporter.check("public-dev-edge-health", "edge", "blocked", "Public DEV health endpoint did not respond successfully.", [edgeProbe]); reporter.block({ type: "network_blocker", scope: "dev-edge", summary: `${DEV_ENDPOINT}/health/live is not reachable from this runner.`, nextTask: "Bring up or repair the D601-to-master frp route and hwlab-edge-proxy, then rerun the health probe." }); } const registryServices = requiredCatalogServices(catalog); const registryEvidence = await Promise.all(registryServices.map(async (service) => { const url = imageToManifestUrl(service.image); if (!url) { return { serviceId: service.serviceId, ok: false, image: service.image, error: "unsupported image format" }; } const probe = await httpProbe(url, { method: "HEAD", timeoutMs, headers: { Accept: "application/vnd.oci.image.manifest.v1+json, application/vnd.docker.distribution.manifest.v2+json" } }); return { serviceId: service.serviceId, image: service.image, ...probe }; })); const registryStatus = manifestReadStatus(registryEvidence); reporter.check( "catalog-image-manifest-read", "artifact-catalog", registryStatus, registryStatus === "pass" ? "All required catalog images were visible through manifest HEAD probes." : "One or more required catalog images could not be verified through manifest HEAD probes; this is runner-process catalog evidence, not Docker daemon push access.", registryEvidence ); if (registryStatus === "blocked") { reporter.block({ type: "runtime_blocker", scope: "catalog-image-manifest-read", summary: "This preflight could not verify registry manifests for the required DEV catalog images without reading credentials.", nextTask: "Publish required DEV images or provide a non-secret registry evidence artifact with immutable digests for each required HWLAB service." }); } } function validateRuntimeBoundary(reporter, deploy) { const substituteImages = deploy.services .map((service) => service.image) .filter((image) => /unidesk|provider-gateway|microservice-proxy/.test(image)); if (substituteImages.length === 0) { reporter.check("runtime-substitution-boundary", "safety", "pass", "Deploy images do not point at UniDesk/provider-gateway/microservice-proxy substitutes."); return; } reporter.check("runtime-substitution-boundary", "safety", "blocked", `Forbidden substitute images: ${substituteImages.join(", ")}`); reporter.block({ type: "safety_blocker", scope: "runtime-boundary", summary: "DEV deploy manifest references a forbidden non-HWLAB runtime substitute.", nextTask: "Replace substitute runtime references with HWLAB-owned service images." }); } async function writeReport(report, reportPath) { const absoluteReportPath = path.join(repoRoot, reportPath); await mkdir(path.dirname(absoluteReportPath), { recursive: true }); await writeFile(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`); } function makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity, registryCapabilities) { const blockingCheckStatuses = new Set(["blocked", "failed"]); const conclusion = reporter.blockers.length === 0 && reporter.checks.every((item) => !blockingCheckStatuses.has(item.status)) ? "ready" : "blocked"; return { $schema: "https://hwlab.pikastech.local/schemas/dev-gate-preflight-report.schema.json", $id: "https://hwlab.pikastech.local/reports/dev-gate/dev-preflight-report.json", reportVersion: "v1", reportKind: "dev-gate-preflight", issue, supports, target: { ref: args.targetRef, commitId: targetCommit, shortCommitId: targetShortCommit }, generatedAt: new Date().toISOString(), mode: "read-only", devOnly: true, prodDisabled: true, forbiddenActions, validationCommands: [ "node --check scripts/dev-gate-preflight.mjs", "node --check scripts/src/dev-gate-preflight.mjs", "node --check scripts/src/registry-capabilities.mjs", "node --check scripts/refresh-artifact-catalog.mjs", "node scripts/dev-gate-preflight.mjs", "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" ], artifactIdentity, registryCapabilities, conclusion, checks: reporter.checks, blockers: reporter.blockers, notes: "No PROD action, secret read, UniDesk runtime substitution, heavy e2e, browser e2e, runtime restart, or force push was performed." }; } function printSummary(args, report) { console.log(JSON.stringify({ issue, targetRef: args.targetRef, targetCommit: report.target.commitId, targetShortCommit: report.target.shortCommitId, artifactCatalogCommit: report.artifactIdentity?.artifactCatalog?.commitId, catalogDigests: report.artifactIdentity?.artifactCatalog?.digestCounts, registryCapabilities: { classification: report.registryCapabilities?.classification, dimensions: Object.fromEntries(Object.entries(report.registryCapabilities?.dimensions ?? {}).map(([key, value]) => [ key, value.status ])) }, conclusion: report.conclusion, report: args.writeReport ? args.reportPath : null, checks: report.checks.reduce((counts, item) => { counts[item.status] = (counts[item.status] ?? 0) + 1; return counts; }, {}), blockers: report.blockers.map((blocker) => ({ type: blocker.type, scope: blocker.scope, nextTask: blocker.nextTask })) }, null, 2)); } export async function runPreflight(argv) { const args = parseArgs(argv); if (args.help) { console.log(usage()); return; } const targetCommit = (await run("git", ["rev-parse", args.targetRef])).stdout; const targetShortCommit = (await run("git", ["rev-parse", "--short=7", args.targetRef])).stdout; assert.match(targetCommit, /^[a-f0-9]{40}$/, `target ref ${args.targetRef} must resolve to a full SHA`); const reporter = makeReporter(); const contracts = await loadContracts(); const optionalReports = await loadOptionalReports(); const registryPrefix = optionalReports.artifactPublish?.artifactPublish?.registryPrefix ?? "127.0.0.1:5000/hwlab"; const registryCapabilities = await probeRegistryCapabilities({ registryPrefix, timeoutMs: args.timeoutMs }); const { deploy, catalog, masterEdge, artifactIdentity } = validateLocalContracts( reporter, contracts, targetShortCommit, targetCommit, args.targetRef ); validateRegistryCapabilities(reporter, registryCapabilities); validateCloudApiDbContract(reporter, deploy, contracts[3]); validateArtifactPublishReport(reporter, optionalReports.artifactPublish, targetShortCommit, targetCommit, args.targetRef); validateEdgeHealthReport(reporter, optionalReports.edgeHealth); await validateEdgeContracts(reporter, masterEdge); validateRuntimeBoundary(reporter, deploy); await validateLiveProbes(reporter, catalog, args.timeoutMs); const report = makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity, registryCapabilities); if (args.writeReport) { await writeReport(report, args.reportPath); } printSummary(args, report); if (report.conclusion === "blocked" && args.failOnBlocked) { process.exitCode = 2; } }