chore: add dev gate preflight

This commit is contained in:
HWLAB Code Queue
2026-05-21 16:49:11 +00:00
parent eddfe3ba70
commit 245a236ed0
5 changed files with 1109 additions and 0 deletions
+62
View File
@@ -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.
+297
View File
@@ -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."
}
+15
View File
@@ -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;
}
+610
View File
@@ -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;
}
}
+125
View File
@@ -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");