From 245a236ed078d9836637bd891e93fa2b39a2c823 Mon Sep 17 00:00:00 2001 From: HWLAB Code Queue Date: Thu, 21 May 2026 16:49:11 +0000 Subject: [PATCH] chore: add dev gate preflight --- docs/dev-gate-preflight.md | 62 +++ reports/dev-gate/dev-preflight-report.json | 297 ++++++++++ scripts/dev-gate-preflight.mjs | 15 + scripts/src/dev-gate-preflight.mjs | 610 +++++++++++++++++++++ scripts/validate-dev-gate-report.mjs | 125 +++++ 5 files changed, 1109 insertions(+) create mode 100644 docs/dev-gate-preflight.md create mode 100644 reports/dev-gate/dev-preflight-report.json create mode 100644 scripts/dev-gate-preflight.mjs create mode 100644 scripts/src/dev-gate-preflight.mjs diff --git a/docs/dev-gate-preflight.md b/docs/dev-gate-preflight.md new file mode 100644 index 00000000..0e09ab64 --- /dev/null +++ b/docs/dev-gate-preflight.md @@ -0,0 +1,62 @@ +# HWLAB DEV Gate Preflight + +This preflight is the read-only gate before a real DEV deploy or DEV smoke on +D601. It decides whether the current `origin/main` can enter the real +`hwlab-dev` runtime path, or whether it is blocked by missing contract, +artifact, cluster, registry, edge, or safety evidence. + +It does not authorize PROD, restart runtime services, read secrets or tokens, +substitute UniDesk services for HWLAB runtime, or run heavyweight/browser e2e. + +## Command + +Run from the repository root: + +```sh +node --check scripts/dev-gate-preflight.mjs +node --check scripts/src/dev-gate-preflight.mjs +node scripts/dev-gate-preflight.mjs +``` + +The command writes `reports/dev-gate/dev-preflight-report.json` and prints a +short JSON summary. A `blocked` conclusion means the preflight ran correctly but +found blockers; the command exits zero by default so the report can be committed +as evidence. Use `--fail-on-blocked` only in CI jobs that should fail on an open +gate. + +Useful options: + +```sh +node scripts/dev-gate-preflight.mjs --target-ref origin/main +node scripts/dev-gate-preflight.mjs --report reports/dev-gate/dev-preflight-report.json +node scripts/dev-gate-preflight.mjs --timeout-ms 5000 +node scripts/dev-gate-preflight.mjs --no-write +``` + +## Read-Only Scope + +The preflight checks: + +- `deploy/deploy.json` and `deploy/artifact-catalog.dev.json` are internally + consistent and DEV-only. +- The deploy manifest and catalog target the selected `origin/main` commit. +- The artifact catalog has real publish and registry digest evidence. +- `reports/dev-gate/dev-artifacts.json`, when present, proves all frozen DEV + service artifacts were published for the selected `origin/main` commit. +- `deploy/k8s/base` and `deploy/k8s/dev` parse and remain scoped to + `hwlab-dev`. +- `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`. +- `http://74.48.78.17:6667/health/live` responds. +- GHCR manifests are visible without reading credentials. +- Deploy images do not point at UniDesk, provider-gateway, or microservice-proxy + substitutes. + +## Verdict Rules + +`ready` requires all checks to pass and no open blocker. + +`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. diff --git a/reports/dev-gate/dev-preflight-report.json b/reports/dev-gate/dev-preflight-report.json new file mode 100644 index 00000000..222c41dd --- /dev/null +++ b/reports/dev-gate/dev-preflight-report.json @@ -0,0 +1,297 @@ +{ + "$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": "pikasTech/HWLAB#34", + "supports": [ + "pikasTech/HWLAB#7", + "pikasTech/HWLAB#12", + "pikasTech/HWLAB#22", + "pikasTech/HWLAB#23", + "pikasTech/HWLAB#29", + "pikasTech/HWLAB#30", + "pikasTech/HWLAB#31" + ], + "target": { + "ref": "origin/main", + "commitId": "eddfe3ba70db5725682cba2008b10a7a786bef92", + "shortCommitId": "eddfe3b" + }, + "generatedAt": "2026-05-21T16:48:18.417Z", + "mode": "read-only", + "devOnly": true, + "prodDisabled": true, + "forbiddenActions": [ + "prod-deploy", + "secret-material-read", + "unidesk-runtime-substitute", + "heavy-e2e", + "browser-e2e", + "force-push", + "runtime-restart" + ], + "validationCommands": [ + "node --check scripts/dev-gate-preflight.mjs", + "node --check scripts/src/dev-gate-preflight.mjs", + "node scripts/dev-gate-preflight.mjs", + "node --check scripts/validate-dev-gate-report.mjs", + "node scripts/validate-dev-gate-report.mjs" + ], + "conclusion": "blocked", + "checks": [ + { + "id": "source-contract-static", + "category": "contract", + "status": "pass", + "summary": "Local deploy manifest and artifact catalog are internally consistent.", + "evidence": [] + }, + { + "id": "target-commit-pinning", + "category": "contract", + "status": "blocked", + "summary": "deploy/catalog commitId 24eb3bf does not match origin/main eddfe3b.", + "evidence": [] + }, + { + "id": "artifact-catalog-publish-state", + "category": "registry", + "status": "blocked", + "summary": "Artifact catalog is still a skeleton and does not carry registry digests.", + "evidence": [] + }, + { + "id": "k8s-manifest-static", + "category": "k3s", + "status": "pass", + "summary": "DEV k3s manifest files parse and stay scoped to hwlab-dev.", + "evidence": [] + }, + { + "id": "dev-artifact-publish-report", + "category": "registry", + "status": "blocked", + "summary": "DEV artifact publish report status is blocked with 9/13 published for source 1e8d009e9531d71cef0762998663b4e3731032c6.", + "evidence": [] + }, + { + "id": "frp-master-edge-static", + "category": "edge", + "status": "pass", + "summary": "FRP and master-edge contracts describe D601-to-master DEV on port 6667.", + "evidence": [] + }, + { + "id": "runtime-substitution-boundary", + "category": "safety", + "status": "pass", + "summary": "Deploy images do not point at UniDesk/provider-gateway/microservice-proxy substitutes.", + "evidence": [] + }, + { + "id": "d601-k3s-read-access", + "category": "k3s", + "status": "blocked", + "summary": "kubectl is not installed in this runner, so hwlab-dev live cluster evidence cannot be collected.", + "evidence": [] + }, + { + "id": "public-dev-edge-health", + "category": "edge", + "status": "blocked", + "summary": "Public DEV health endpoint did not respond successfully.", + "evidence": [ + { + "ok": false, + "url": "http://74.48.78.17:6667/health/live", + "method": "GET", + "error": "fetch failed" + } + ] + }, + { + "id": "ghcr-anonymous-manifest-read", + "category": "registry", + "status": "blocked", + "summary": "One or more catalog images could not be verified through anonymous GHCR manifest HEAD probes.", + "evidence": [ + { + "serviceId": "hwlab-cloud-api", + "image": "ghcr.io/pikastech/hwlab-cloud-api:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-cloud-api/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-cloud-web", + "image": "ghcr.io/pikastech/hwlab-cloud-web:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-cloud-web/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-agent-mgr", + "image": "ghcr.io/pikastech/hwlab-agent-mgr:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-agent-mgr/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-agent-worker", + "image": "ghcr.io/pikastech/hwlab-agent-worker:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-agent-worker/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-gateway", + "image": "ghcr.io/pikastech/hwlab-gateway:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-gateway/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-gateway-simu", + "image": "ghcr.io/pikastech/hwlab-gateway-simu:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-gateway-simu/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-box-simu", + "image": "ghcr.io/pikastech/hwlab-box-simu:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-box-simu/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-patch-panel", + "image": "ghcr.io/pikastech/hwlab-patch-panel:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-patch-panel/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-router", + "image": "ghcr.io/pikastech/hwlab-router:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-router/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-tunnel-client", + "image": "ghcr.io/pikastech/hwlab-tunnel-client:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-tunnel-client/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-edge-proxy", + "image": "ghcr.io/pikastech/hwlab-edge-proxy:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-edge-proxy/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-cli", + "image": "ghcr.io/pikastech/hwlab-cli:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-cli/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + }, + { + "serviceId": "hwlab-agent-skills", + "image": "ghcr.io/pikastech/hwlab-agent-skills:24eb3bf", + "ok": false, + "url": "https://ghcr.io/v2/pikastech/hwlab-agent-skills/manifests/24eb3bf", + "method": "HEAD", + "status": 401, + "statusText": "Unauthorized", + "body": "" + } + ] + } + ], + "blockers": [ + { + "status": "open", + "type": "contract_blocker", + "scope": "deploy-target", + "summary": "deploy/deploy.json and deploy/artifact-catalog.dev.json still target 24eb3bf, not origin/main eddfe3b.", + "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." + }, + { + "status": "open", + "type": "runtime_blocker", + "scope": "artifact-catalog", + "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": "dev-artifact-publish", + "summary": "reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for origin/main eddfe3b; current status is blocked with 9/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": "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." + }, + { + "status": "open", + "type": "network_blocker", + "scope": "dev-edge", + "summary": "http://74.48.78.17:6667/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." + }, + { + "status": "open", + "type": "runtime_blocker", + "scope": "ghcr", + "summary": "This preflight could not verify GHCR manifests for the DEV catalog images without reading credentials.", + "nextTask": "Publish public DEV images or provide a non-secret registry evidence artifact with immutable digests for each HWLAB service." + } + ], + "notes": "No PROD action, secret read, UniDesk runtime substitution, heavy e2e, browser e2e, runtime restart, or force push was performed." +} diff --git a/scripts/dev-gate-preflight.mjs b/scripts/dev-gate-preflight.mjs new file mode 100644 index 00000000..2275508e --- /dev/null +++ b/scripts/dev-gate-preflight.mjs @@ -0,0 +1,15 @@ +#!/usr/bin/env node +import { runPreflight } from "./src/dev-gate-preflight.mjs"; + +const issue = "pikasTech/HWLAB#34"; + +try { + await runPreflight(process.argv.slice(2)); +} catch (error) { + console.error(JSON.stringify({ + issue, + conclusion: "blocked", + error: error instanceof Error ? error.message : String(error) + }, null, 2)); + process.exitCode = 1; +} diff --git a/scripts/src/dev-gate-preflight.mjs b/scripts/src/dev-gate-preflight.mjs new file mode 100644 index 00000000..87791cdc --- /dev/null +++ b/scripts/src/dev-gate-preflight.mjs @@ -0,0 +1,610 @@ +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_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( + (id) => `pikasTech/HWLAB${id}` +); +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" +]); + +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\/([^:@]+):([^:@]+)$/); + return match ? `https://ghcr.io/v2/pikastech/${match[1]}/manifests/${match[2]}` : null; +} + +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(frps.vhostHTTPPort, 6667); + assert.ok(frpc.proxies.some((proxy) => proxy.name === "hwlab-dev-edge-proxy" && proxy.remotePort === 6667)); + assert.ok(frps.allowPorts.some((port) => port.start === 6667 && port.end === 6667)); + 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") + }; +} + +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; + + 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." + }); + } + + const pinnedToTarget = [targetCommit, targetShortCommit].includes(deploy.commitId) && + [targetCommit, targetShortCommit].includes(catalog.commitId); + if (pinnedToTarget) { + reporter.check("target-commit-pinning", "contract", "pass", `deploy/deploy.json and artifact catalog target ${targetShortCommit}.`); + } else { + reporter.check("target-commit-pinning", "contract", "blocked", `deploy/catalog commitId ${deploy.commitId} does not match ${targetRef} ${targetShortCommit}.`); + reporter.block({ + type: "contract_blocker", + scope: "deploy-target", + summary: `deploy/deploy.json and deploy/artifact-catalog.dev.json still target ${deploy.commitId}, not ${targetRef} ${targetShortCommit}.`, + 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." + }); + } + + const catalogClaimsPublished = catalog.publish?.ciPublished === true && + catalog.publish?.registryVerified === true && + catalog.services.every((service) => /^sha256:[a-f0-9]{64}$/.test(service.digest)); + if (catalogClaimsPublished) { + reporter.check("artifact-catalog-publish-state", "registry", "pass", "Artifact catalog claims published images with registry digests."); + } else { + reporter.check("artifact-catalog-publish-state", "registry", "blocked", "Artifact catalog is still a skeleton and does not carry registry digests."); + reporter.block({ + type: "runtime_blocker", + scope: "artifact-catalog", + 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." + }); + } + + 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 }; +} + +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 allServicesPublished = services.length === SERVICE_IDS.length && + services.every((service) => service.status === "published" && /^sha256:[a-f0-9]{64}$/.test(service.digest)); + const sourceMatchesTarget = [targetCommit, targetShortCommit].includes(artifactPublish?.sourceCommitId) || + [targetCommit, targetShortCommit].includes(artifactReport.commitId); + const publishReady = artifactPublish?.status === "published" && + artifactPublish.publishedCount === SERVICE_IDS.length && + allServicesPublished && + sourceMatchesTarget; + + if (publishReady) { + reporter.check("dev-artifact-publish-report", "registry", "pass", "DEV artifact publish report covers all frozen service IDs with immutable digests for the target commit."); + return; + } + + const publishedCount = artifactPublish?.publishedCount ?? 0; + const serviceCount = artifactPublish?.serviceCount ?? SERVICE_IDS.length; + reporter.check( + "dev-artifact-publish-report", + "registry", + "blocked", + `DEV artifact publish report status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${serviceCount} published for source ${artifactPublish?.sourceCommitId ?? artifactReport.commitId ?? "unknown"}.` + ); + reporter.block({ + type: "runtime_blocker", + scope: "dev-artifact-publish", + summary: `reports/dev-gate/dev-artifacts.json does not prove all HWLAB service artifacts for ${targetRef} ${targetShortCommit}; current status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${serviceCount} published.`, + nextTask: "Complete DEV artifact publishing for every frozen HWLAB service at the current origin/main commit and record immutable registry digests." + }); +} + +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 port 6667."); + } 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 registryEvidence = await Promise.all(catalog.services.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 registryOk = registryEvidence.every((probe) => probe.ok); + reporter.check( + "ghcr-anonymous-manifest-read", + "registry", + registryOk ? "pass" : "blocked", + registryOk ? "All catalog images were visible through anonymous GHCR manifest HEAD probes." : "One or more catalog images could not be verified through anonymous GHCR manifest HEAD probes.", + registryEvidence + ); + if (!registryOk) { + reporter.block({ + type: "runtime_blocker", + scope: "ghcr", + summary: "This preflight could not verify GHCR manifests for the DEV catalog images without reading credentials.", + nextTask: "Publish public DEV images or provide a non-secret registry evidence artifact with immutable digests for each 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) { + const conclusion = reporter.blockers.length === 0 && + reporter.checks.every((item) => item.status === "pass") ? "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 scripts/dev-gate-preflight.mjs", + "node --check scripts/validate-dev-gate-report.mjs", + "node scripts/validate-dev-gate-report.mjs" + ], + 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.shortCommitId, + 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 { deploy, catalog, masterEdge } = validateLocalContracts( + reporter, + contracts, + targetShortCommit, + targetCommit, + args.targetRef + ); + + validateArtifactPublishReport(reporter, optionalReports.artifactPublish, targetShortCommit, targetCommit, args.targetRef); + await validateEdgeContracts(reporter, masterEdge); + validateRuntimeBoundary(reporter, deploy); + await validateLiveProbes(reporter, catalog, args.timeoutMs); + + const report = makeReport(args, targetCommit, targetShortCommit, reporter); + if (args.writeReport) { + await writeReport(report, args.reportPath); + } + printSummary(args, report); + + if (report.conclusion === "blocked" && args.failOnBlocked) { + process.exitCode = 2; + } +} diff --git a/scripts/validate-dev-gate-report.mjs b/scripts/validate-dev-gate-report.mjs index 6f0bd591..f758fffe 100644 --- a/scripts/validate-dev-gate-report.mjs +++ b/scripts/validate-dev-gate-report.mjs @@ -9,10 +9,16 @@ const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), ".." const reportsDir = path.join(repoRoot, "reports/dev-gate"); const allowedIssues = new Set(["pikasTech/HWLAB#31", "pikasTech/HWLAB#33"]); +const requiredPreflightIssue = "pikasTech/HWLAB#34"; const requiredValidationCommands = [ "node --check scripts/validate-dev-gate-report.mjs", "node scripts/validate-dev-gate-report.mjs" ]; +const requiredPreflightValidationCommands = [ + "node --check scripts/dev-gate-preflight.mjs", + "node --check scripts/src/dev-gate-preflight.mjs", + "node scripts/dev-gate-preflight.mjs" +]; const requiredSmokeCommand = "node scripts/m1-contract-smoke.mjs"; const requiredDryRunCommand = "node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run"; @@ -41,6 +47,16 @@ const blockerTypes = new Set([ "safety_blocker" ]); const blockerStates = new Set(["open", "acknowledged", "closed"]); +const preflightConclusions = new Set(["ready", "blocked"]); +const requiredPreflightSupports = [ + "pikasTech/HWLAB#7", + "pikasTech/HWLAB#12", + "pikasTech/HWLAB#22", + "pikasTech/HWLAB#23", + "pikasTech/HWLAB#29", + "pikasTech/HWLAB#30", + "pikasTech/HWLAB#31" +]; function assertObject(value, label) { assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`); @@ -107,6 +123,11 @@ async function collectReportFiles() { async function validateReport(relativePath) { const report = await readJsonFile(relativePath); + if (report.reportKind === "dev-gate-preflight") { + await validatePreflightReport(relativePath, report); + return; + } + const label = relativePath; assertObject(report, label); @@ -388,6 +409,110 @@ async function validateDevM3Report(report, label) { assertString(report.summary.result, `${label}.summary.result`); } +async function validatePreflightReport(relativePath, report) { + const label = relativePath; + + assertObject(report, label); + for (const field of [ + "$schema", + "$id", + "reportVersion", + "reportKind", + "issue", + "supports", + "target", + "generatedAt", + "mode", + "devOnly", + "prodDisabled", + "forbiddenActions", + "validationCommands", + "conclusion", + "checks", + "blockers" + ]) { + assert.ok(Object.hasOwn(report, field), `${label} missing ${field}`); + } + + assertString(report.$schema, `${label}.$schema`); + assertString(report.$id, `${label}.$id`); + assert.equal(report.reportVersion, "v1", `${label}.reportVersion`); + assert.equal(report.reportKind, "dev-gate-preflight", `${label}.reportKind`); + assert.equal(report.issue, requiredPreflightIssue, `${label}.issue`); + assert.equal(report.mode, "read-only", `${label}.mode`); + assert.equal(report.devOnly, true, `${label}.devOnly`); + assert.equal(report.prodDisabled, true, `${label}.prodDisabled`); + assert.ok(preflightConclusions.has(report.conclusion), `${label}.conclusion must be ready or blocked`); + assert.match(report.generatedAt, /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}\.\d{3}Z$/, `${label}.generatedAt`); + + assertStringArray(report.supports, `${label}.supports`, { minLength: requiredPreflightSupports.length }); + for (const supportedIssue of requiredPreflightSupports) { + assert.ok(report.supports.includes(supportedIssue), `${label}.supports missing ${supportedIssue}`); + } + + assertObject(report.target, `${label}.target`); + assert.equal(report.target.ref, "origin/main", `${label}.target.ref`); + assert.match(report.target.commitId, /^[a-f0-9]{40}$/, `${label}.target.commitId`); + assert.equal(report.target.shortCommitId, report.target.commitId.slice(0, 7), `${label}.target.shortCommitId`); + + assertStringArray(report.forbiddenActions, `${label}.forbiddenActions`, { minLength: 1 }); + for (const forbiddenAction of ["prod-deploy", "secret-material-read", "unidesk-runtime-substitute", "force-push"]) { + assert.ok(report.forbiddenActions.includes(forbiddenAction), `${label}.forbiddenActions missing ${forbiddenAction}`); + } + + assertStringArray(report.validationCommands, `${label}.validationCommands`, { minLength: 2 }); + for (const requiredCommand of requiredPreflightValidationCommands) { + assert.ok( + report.validationCommands.includes(requiredCommand), + `${label}.validationCommands missing ${requiredCommand}` + ); + } + + assertArray(report.checks, `${label}.checks`); + assert.ok(report.checks.length >= 1, `${label}.checks must not be empty`); + assertUnique(report.checks.map((check) => check.id), `${label}.checks`); + for (const [index, check] of report.checks.entries()) { + const checkLabel = `${label}.checks[${index}]`; + assertObject(check, checkLabel); + for (const field of ["id", "category", "status", "summary", "evidence"]) { + assert.ok(Object.hasOwn(check, field), `${checkLabel} missing ${field}`); + } + assert.match(check.id, /^[a-z][a-z0-9-]*$/, `${checkLabel}.id`); + assertString(check.category, `${checkLabel}.category`); + assertStatus(check.status, `${checkLabel}.status`); + assertString(check.summary, `${checkLabel}.summary`); + assertArray(check.evidence, `${checkLabel}.evidence`); + } + + assertArray(report.blockers, `${label}.blockers`); + if (report.conclusion === "blocked") { + assert.ok(report.blockers.length >= 1, `${label}.blockers required when conclusion is blocked`); + } + assertUnique( + report.blockers.map((blocker) => `${blocker.type}::${blocker.scope}`), + `${label}.blockers` + ); + for (const [index, blocker] of report.blockers.entries()) { + const blockerLabel = `${label}.blockers[${index}]`; + assertObject(blocker, blockerLabel); + for (const field of ["type", "scope", "status", "summary", "nextTask"]) { + assert.ok(Object.hasOwn(blocker, field), `${blockerLabel} missing ${field}`); + } + assert.ok(blockerTypes.has(blocker.type), `${blockerLabel}.type must be a known blocker type`); + assertString(blocker.scope, `${blockerLabel}.scope`); + assert.ok(blockerStates.has(blocker.status), `${blockerLabel}.status must be open, acknowledged, or closed`); + assertString(blocker.summary, `${blockerLabel}.summary`); + assertString(blocker.nextTask, `${blockerLabel}.nextTask`); + } + + if (report.conclusion === "ready") { + assert.equal(report.blockers.length, 0, `${label}.blockers must be empty when ready`); + } + if (Object.hasOwn(report, "notes")) { + assertString(report.notes, `${label}.notes`); + } +} + async function main() { const reportFiles = await collectReportFiles(); assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file");