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 reportsDir = path.join(repoRoot, "reports/dev-gate");
|
||||||
|
|
||||||
const allowedIssues = new Set(["pikasTech/HWLAB#31", "pikasTech/HWLAB#33"]);
|
const allowedIssues = new Set(["pikasTech/HWLAB#31", "pikasTech/HWLAB#33"]);
|
||||||
|
const requiredPreflightIssue = "pikasTech/HWLAB#34";
|
||||||
const requiredValidationCommands = [
|
const requiredValidationCommands = [
|
||||||
"node --check scripts/validate-dev-gate-report.mjs",
|
"node --check scripts/validate-dev-gate-report.mjs",
|
||||||
"node 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 requiredSmokeCommand = "node scripts/m1-contract-smoke.mjs";
|
||||||
const requiredDryRunCommand =
|
const requiredDryRunCommand =
|
||||||
"node tools/hwlab-cli/bin/hwlab-cli.mjs test e2e --env dev --mvp --dry-run";
|
"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"
|
"safety_blocker"
|
||||||
]);
|
]);
|
||||||
const blockerStates = new Set(["open", "acknowledged", "closed"]);
|
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) {
|
function assertObject(value, label) {
|
||||||
assert.ok(value && typeof value === "object" && !Array.isArray(value), `${label} must be an object`);
|
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) {
|
async function validateReport(relativePath) {
|
||||||
const report = await readJsonFile(relativePath);
|
const report = await readJsonFile(relativePath);
|
||||||
|
if (report.reportKind === "dev-gate-preflight") {
|
||||||
|
await validatePreflightReport(relativePath, report);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const label = relativePath;
|
const label = relativePath;
|
||||||
|
|
||||||
assertObject(report, label);
|
assertObject(report, label);
|
||||||
@@ -388,6 +409,110 @@ async function validateDevM3Report(report, label) {
|
|||||||
assertString(report.summary.result, `${label}.summary.result`);
|
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() {
|
async function main() {
|
||||||
const reportFiles = await collectReportFiles();
|
const reportFiles = await collectReportFiles();
|
||||||
assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file");
|
assert.ok(reportFiles.length >= 1, "expected at least one dev-gate report JSON file");
|
||||||
|
|||||||
Reference in New Issue
Block a user