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