chore: add dev gate preflight
This commit is contained in:
@@ -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.
|
||||
@@ -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."
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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");
|
||||
|
||||
Reference in New Issue
Block a user