1363 lines
55 KiB
JavaScript
1363 lines
55 KiB
JavaScript
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_DB_ENV_CONTRACT,
|
|
buildDbHealthContract,
|
|
summarizeDbContract
|
|
} from "../../internal/cloud/db-contract.mjs";
|
|
import {
|
|
DEV_CODE_AGENT_PROVIDER_CONTRACT,
|
|
codeAgentSecretRefPlaceholder
|
|
} from "../../internal/cloud/code-agent-contract.mjs";
|
|
import { activeReportLifecycle } from "../../internal/dev-report-lifecycle.mjs";
|
|
import { DEV_ENDPOINT, ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs";
|
|
import { probeRegistryCapabilities } from "./registry-capabilities.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", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#35", "#39", "#43", "#48", "#49", "#57", "#66", "#143", "#164"].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"
|
|
]);
|
|
const digestPattern = /^sha256:[a-f0-9]{64}$/;
|
|
const artifactBuildInputPrefixes = [
|
|
"cmd/",
|
|
"internal/",
|
|
"skills/",
|
|
"tools/",
|
|
"web/"
|
|
];
|
|
const artifactBuildInputFiles = new Set([
|
|
"package.json",
|
|
"package-lock.json",
|
|
"npm-shrinkwrap.json",
|
|
"scripts/dev-artifact-publish.mjs",
|
|
"scripts/src/dev-artifact-services.mjs"
|
|
]);
|
|
|
|
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\/([^:@]+):([^:@]+)$/);
|
|
if (match) return `https://ghcr.io/v2/pikastech/${match[1]}/manifests/${match[2]}`;
|
|
|
|
const localMatch = image.match(/^(127\.0\.0\.1:5000|localhost:5000)\/hwlab\/([^:@]+):([^:@]+)$/);
|
|
if (localMatch) return `http://${localMatch[1]}/v2/hwlab/${localMatch[2]}/manifests/${localMatch[3]}`;
|
|
return null;
|
|
}
|
|
|
|
function matchesTargetCommit(value, targetCommit, targetShortCommit) {
|
|
return value === targetCommit || value === targetShortCommit;
|
|
}
|
|
|
|
function isArtifactBuildInputPath(relativePath) {
|
|
return artifactBuildInputFiles.has(relativePath) ||
|
|
artifactBuildInputPrefixes.some((prefix) => relativePath.startsWith(prefix));
|
|
}
|
|
|
|
async function resolveCommit(ref) {
|
|
if (!ref) return null;
|
|
const result = await run("git", ["rev-parse", `${ref}^{commit}`]);
|
|
if (!result.ok || !/^[a-f0-9]{40}$/u.test(result.stdout)) return null;
|
|
return result.stdout;
|
|
}
|
|
|
|
async function sourceCoverageFor({ catalog, artifactReport, targetCommit, targetShortCommit, targetRef }) {
|
|
const reportSource = artifactReport?.artifactPublish?.sourceCommitId ?? artifactReport?.commitId ?? null;
|
|
const sourceRef = reportSource ?? catalog.commitId;
|
|
const sourceKind = reportSource ? "artifact-publish-report" : "artifact-catalog";
|
|
const sourceCommit = await resolveCommit(sourceRef);
|
|
|
|
if (!sourceCommit) {
|
|
return {
|
|
sourceKind,
|
|
sourceRef,
|
|
sourceCommitId: null,
|
|
sourceShortCommitId: null,
|
|
targetRef,
|
|
targetCommitId: targetCommit,
|
|
targetShortCommitId: targetShortCommit,
|
|
mode: "unresolved-source",
|
|
covered: false,
|
|
artifactBuildInputChanged: true,
|
|
changedPathCount: 0,
|
|
changedArtifactInputPaths: [],
|
|
changedNonArtifactPathCount: 0,
|
|
reason: `artifact source ${sourceRef ?? "unknown"} does not resolve to a local git commit`
|
|
};
|
|
}
|
|
|
|
if (matchesTargetCommit(sourceCommit, targetCommit, targetShortCommit)) {
|
|
return {
|
|
sourceKind,
|
|
sourceRef,
|
|
sourceCommitId: sourceCommit,
|
|
sourceShortCommitId: sourceCommit.slice(0, 7),
|
|
targetRef,
|
|
targetCommitId: targetCommit,
|
|
targetShortCommitId: targetShortCommit,
|
|
mode: "exact-target",
|
|
covered: true,
|
|
artifactBuildInputChanged: false,
|
|
changedPathCount: 0,
|
|
changedArtifactInputPaths: [],
|
|
changedNonArtifactPathCount: 0,
|
|
reason: "artifact source commit exactly matches the target ref"
|
|
};
|
|
}
|
|
|
|
const ancestor = await run("git", ["merge-base", "--is-ancestor", sourceCommit, targetCommit]);
|
|
if (!ancestor.ok) {
|
|
return {
|
|
sourceKind,
|
|
sourceRef,
|
|
sourceCommitId: sourceCommit,
|
|
sourceShortCommitId: sourceCommit.slice(0, 7),
|
|
targetRef,
|
|
targetCommitId: targetCommit,
|
|
targetShortCommitId: targetShortCommit,
|
|
mode: "non-ancestor-source",
|
|
covered: false,
|
|
artifactBuildInputChanged: true,
|
|
changedPathCount: 0,
|
|
changedArtifactInputPaths: [],
|
|
changedNonArtifactPathCount: 0,
|
|
reason: `artifact source ${sourceCommit.slice(0, 12)} is not an ancestor of ${targetRef} ${targetShortCommit}`
|
|
};
|
|
}
|
|
|
|
const diff = await run("git", ["diff", "--name-only", `${sourceCommit}..${targetCommit}`]);
|
|
if (!diff.ok) {
|
|
return {
|
|
sourceKind,
|
|
sourceRef,
|
|
sourceCommitId: sourceCommit,
|
|
sourceShortCommitId: sourceCommit.slice(0, 7),
|
|
targetRef,
|
|
targetCommitId: targetCommit,
|
|
targetShortCommitId: targetShortCommit,
|
|
mode: "diff-unavailable",
|
|
covered: false,
|
|
artifactBuildInputChanged: true,
|
|
changedPathCount: 0,
|
|
changedArtifactInputPaths: [],
|
|
changedNonArtifactPathCount: 0,
|
|
reason: diff.stderr || diff.error || "git diff failed"
|
|
};
|
|
}
|
|
|
|
const changedPaths = diff.stdout.split("\n").map((line) => line.trim()).filter(Boolean);
|
|
const changedArtifactInputPaths = changedPaths.filter(isArtifactBuildInputPath);
|
|
const covered = changedArtifactInputPaths.length === 0;
|
|
return {
|
|
sourceKind,
|
|
sourceRef,
|
|
sourceCommitId: sourceCommit,
|
|
sourceShortCommitId: sourceCommit.slice(0, 7),
|
|
targetRef,
|
|
targetCommitId: targetCommit,
|
|
targetShortCommitId: targetShortCommit,
|
|
mode: covered ? "report-only-target" : "artifact-input-drift",
|
|
covered,
|
|
artifactBuildInputChanged: changedArtifactInputPaths.length > 0,
|
|
changedPathCount: changedPaths.length,
|
|
changedArtifactInputPaths,
|
|
changedNonArtifactPathCount: changedPaths.length - changedArtifactInputPaths.length,
|
|
reason: covered
|
|
? "target changes since the artifact source do not touch artifact build inputs"
|
|
: "target changes since the artifact source touch artifact build inputs"
|
|
};
|
|
}
|
|
|
|
function uniqueSorted(values) {
|
|
return [...new Set(values)].sort();
|
|
}
|
|
|
|
function catalogDigestCounts(catalog) {
|
|
const counts = {
|
|
sha256: 0,
|
|
notPublished: 0,
|
|
invalid: 0
|
|
};
|
|
|
|
for (const service of catalog.services ?? []) {
|
|
if (digestPattern.test(service.digest)) counts.sha256 += 1;
|
|
else if (service.digest === "not_published") counts.notPublished += 1;
|
|
else counts.invalid += 1;
|
|
}
|
|
|
|
return counts;
|
|
}
|
|
|
|
function requiredCatalogServices(catalog) {
|
|
return (catalog.services ?? []).filter((service) => service.artifactRequired !== false);
|
|
}
|
|
|
|
function disabledCatalogServices(catalog) {
|
|
return (catalog.services ?? []).filter((service) => service.artifactRequired === false);
|
|
}
|
|
|
|
async function artifactIdentityFor({ deploy, catalog, artifactReport, targetCommit, targetShortCommit, targetRef }) {
|
|
const targetCoverage = await sourceCoverageFor({
|
|
catalog,
|
|
artifactReport,
|
|
targetCommit,
|
|
targetShortCommit,
|
|
targetRef
|
|
});
|
|
const artifactSourceCommit = targetCoverage.sourceCommitId ?? targetCommit;
|
|
const artifactSourceShortCommit = targetCoverage.sourceShortCommitId ?? targetShortCommit;
|
|
const serviceCommitIds = uniqueSorted((catalog.services ?? []).map((service) => service.commitId));
|
|
const digestCounts = catalogDigestCounts(catalog);
|
|
const requiredServices = requiredCatalogServices(catalog);
|
|
const disabledServices = disabledCatalogServices(catalog);
|
|
const catalogMatchesSource = matchesTargetCommit(catalog.commitId, artifactSourceCommit, artifactSourceShortCommit);
|
|
const deployMatchesSource = matchesTargetCommit(deploy.commitId, artifactSourceCommit, artifactSourceShortCommit);
|
|
const servicesMatchSource = serviceCommitIds.length === 1 &&
|
|
matchesTargetCommit(serviceCommitIds[0], artifactSourceCommit, artifactSourceShortCommit);
|
|
const allRequiredDigestsPublished = requiredServices.length > 0 &&
|
|
requiredServices.every((service) => digestPattern.test(service.digest) && service.publishState === "published") &&
|
|
disabledServices.every((service) => service.digest === "not_published" && service.publishState === "skeleton-only") &&
|
|
digestCounts.invalid === 0;
|
|
const publishVerified = catalog.publish?.ciPublished === true &&
|
|
catalog.publish?.registryVerified === true &&
|
|
catalog.artifactState === "published" &&
|
|
allRequiredDigestsPublished;
|
|
|
|
return {
|
|
source: {
|
|
ref: targetRef,
|
|
commitId: targetCommit,
|
|
shortCommitId: targetShortCommit
|
|
},
|
|
artifactSource: {
|
|
source: targetCoverage.sourceKind,
|
|
ref: targetCoverage.sourceRef,
|
|
commitId: targetCoverage.sourceCommitId,
|
|
shortCommitId: targetCoverage.sourceShortCommitId
|
|
},
|
|
targetCoverage,
|
|
deployManifest: {
|
|
path: "deploy/deploy.json",
|
|
commitId: deploy.commitId,
|
|
matchesSource: deployMatchesSource,
|
|
matchesTarget: matchesTargetCommit(deploy.commitId, targetCommit, targetShortCommit)
|
|
},
|
|
artifactCatalog: {
|
|
path: "deploy/artifact-catalog.dev.json",
|
|
commitId: catalog.commitId,
|
|
artifactState: catalog.artifactState,
|
|
ciPublished: catalog.publish?.ciPublished === true,
|
|
registryVerified: catalog.publish?.registryVerified === true,
|
|
provenance: catalog.publish?.provenance ?? "unknown",
|
|
digestCounts,
|
|
requiredServiceCount: requiredServices.length,
|
|
disabledServiceCount: disabledServices.length,
|
|
matchesSource: catalogMatchesSource,
|
|
matchesTarget: matchesTargetCommit(catalog.commitId, targetCommit, targetShortCommit)
|
|
},
|
|
services: (catalog.services ?? []).map((service) => ({
|
|
serviceId: service.serviceId,
|
|
commitId: service.commitId,
|
|
matchesSource: matchesTargetCommit(service.commitId, artifactSourceCommit, artifactSourceShortCommit),
|
|
matchesTarget: matchesTargetCommit(service.commitId, targetCommit, targetShortCommit),
|
|
image: service.image,
|
|
imageTag: service.imageTag,
|
|
digest: service.digest,
|
|
publishState: service.publishState,
|
|
artifactRequired: service.artifactRequired !== false,
|
|
notPublishedReason: service.notPublishedReason ?? null
|
|
})),
|
|
serviceCommitIds,
|
|
matchesSource: targetCoverage.covered && deployMatchesSource && catalogMatchesSource && servicesMatchSource,
|
|
publishVerified,
|
|
refreshCommands: {
|
|
blocked: `node scripts/refresh-artifact-catalog.mjs --target-ref ${targetRef} --blocked`,
|
|
published: `node scripts/refresh-artifact-catalog.mjs --target-ref ${targetRef} --publish-report reports/dev-gate/dev-artifacts.json`
|
|
}
|
|
};
|
|
}
|
|
|
|
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(Object.hasOwn(frps, "vhostHTTPPort"), false, "frps must not bind vhostHTTPPort because 16667 is reserved for the TCP proxy");
|
|
assert.ok(frpc.proxies.some((proxy) => proxy.name === "hwlab-dev-edge-proxy" && proxy.remotePort === 16667));
|
|
assert.ok(frpc.proxies.some((proxy) => proxy.name === "hwlab-dev-cloud-web" && proxy.remotePort === 16666));
|
|
assert.ok(frps.allowPorts.some((port) => port.start === 16667 && port.end === 16667));
|
|
assert.ok(frps.allowPorts.some((port) => port.start === 16666 && port.end === 16666));
|
|
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"),
|
|
edgeHealth: await readOptionalJson("reports/dev-gate/dev-edge-health.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);
|
|
}
|
|
};
|
|
}
|
|
|
|
async function validateLocalContracts(reporter, contracts, optionalReports, targetShortCommit, targetCommit, targetRef) {
|
|
const [deploy, catalog, namespace, workloads, services, devKustomization, healthContract, masterEdge] = contracts;
|
|
const artifactIdentity = await artifactIdentityFor({
|
|
deploy,
|
|
catalog,
|
|
artifactReport: optionalReports.artifactPublish,
|
|
targetCommit,
|
|
targetShortCommit,
|
|
targetRef
|
|
});
|
|
|
|
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."
|
|
});
|
|
}
|
|
|
|
if (artifactIdentity.matchesSource) {
|
|
const coverage = artifactIdentity.targetCoverage;
|
|
const coverageSuffix = coverage.mode === "exact-target"
|
|
? "exact target commit"
|
|
: `${coverage.mode}; ${coverage.changedPathCount} changed path(s), ${coverage.changedArtifactInputPaths.length} artifact input change(s)`;
|
|
reporter.check(
|
|
"target-commit-pinning",
|
|
"contract",
|
|
"pass",
|
|
`Source ${targetRef} ${targetShortCommit} is covered by artifact source ${artifactIdentity.artifactSource.shortCommitId}; ${coverageSuffix}.`,
|
|
[artifactIdentity]
|
|
);
|
|
} else {
|
|
const coverage = artifactIdentity.targetCoverage;
|
|
reporter.check(
|
|
"target-commit-pinning",
|
|
"contract",
|
|
"blocked",
|
|
`Source ${targetRef} ${targetShortCommit} is not covered by artifact source ${artifactIdentity.artifactSource.shortCommitId ?? "unknown"}: ${coverage.reason}.`,
|
|
[artifactIdentity]
|
|
);
|
|
reporter.block({
|
|
type: "contract_blocker",
|
|
scope: "artifact-source-commit",
|
|
summary: `source commit ${targetRef} ${targetShortCommit} is not covered by artifact source ${artifactIdentity.artifactSource.shortCommitId ?? "unknown"}; ${coverage.reason}.`,
|
|
nextTask: `Refresh without fake digests using \`${artifactIdentity.refreshCommands.blocked}\`, or after a successful publish run \`${artifactIdentity.refreshCommands.published}\`.`
|
|
});
|
|
}
|
|
|
|
if (artifactIdentity.publishVerified) {
|
|
reporter.check(
|
|
"artifact-catalog-publish-state",
|
|
"registry",
|
|
"pass",
|
|
"Artifact catalog records CI publish, registry verification, and immutable service digests.",
|
|
[artifactIdentity.artifactCatalog]
|
|
);
|
|
} else {
|
|
reporter.check(
|
|
"artifact-catalog-publish-state",
|
|
"registry",
|
|
"blocked",
|
|
`Artifact catalog publish state is ${catalog.artifactState}; ciPublished=${artifactIdentity.artifactCatalog.ciPublished}, registryVerified=${artifactIdentity.artifactCatalog.registryVerified}, digests sha256=${artifactIdentity.artifactCatalog.digestCounts.sha256}, not_published=${artifactIdentity.artifactCatalog.digestCounts.notPublished}, invalid=${artifactIdentity.artifactCatalog.digestCounts.invalid}.`,
|
|
[artifactIdentity.artifactCatalog]
|
|
);
|
|
reporter.block({
|
|
type: "runtime_blocker",
|
|
scope: "artifact-catalog",
|
|
summary: `deploy/artifact-catalog.dev.json does not prove published artifacts for ${targetRef} ${targetShortCommit}; ciPublished=${artifactIdentity.artifactCatalog.ciPublished}, registryVerified=${artifactIdentity.artifactCatalog.registryVerified}, not_published=${artifactIdentity.artifactCatalog.digestCounts.notPublished}.`,
|
|
nextTask: `Run the DEV artifact publish workflow, then record only real sha256 digests with \`${artifactIdentity.refreshCommands.published}\`; if publish is still blocked, keep not_published via \`${artifactIdentity.refreshCommands.blocked}\`.`
|
|
});
|
|
}
|
|
|
|
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, artifactIdentity };
|
|
}
|
|
|
|
function validateArtifactPublishReport(reporter, artifactReport, artifactIdentity, 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 sourcePresentCount = services.filter((service) => service.sourceState === "source-present").length;
|
|
const intentionallyDisabledCount = services.filter((service) => service.sourceState === "intentionally-disabled").length;
|
|
const sourcePresenceResolved =
|
|
services.length === SERVICE_IDS.length &&
|
|
services.every((service) => service.sourceState === "source-present" || service.sourceState === "intentionally-disabled");
|
|
const requiredServices = services.filter((service) => service.artifactRequired !== false);
|
|
const disabledServices = services.filter((service) => service.artifactRequired === false);
|
|
const allServicesAccountedFor = services.length === SERVICE_IDS.length &&
|
|
[...services].map((service) => service.serviceId).sort().join("\n") === [...SERVICE_IDS].sort().join("\n");
|
|
const allRequiredServicesPublished = requiredServices.length > 0 &&
|
|
requiredServices.every((service) => service.status === "published" && /^sha256:[a-f0-9]{64}$/.test(service.digest));
|
|
const disabledServicesNotPublished = disabledServices.every(
|
|
(service) => service.digest === "not_published" && service.notPublishedReason?.startsWith("disabled_")
|
|
);
|
|
const sourceMatchesTarget = [targetCommit, targetShortCommit].includes(artifactPublish?.sourceCommitId) ||
|
|
[targetCommit, targetShortCommit].includes(artifactReport.commitId);
|
|
const sourceCoversTarget = artifactIdentity?.targetCoverage?.covered === true &&
|
|
artifactIdentity?.artifactSource?.commitId &&
|
|
(
|
|
artifactPublish?.sourceCommitId === artifactIdentity.artifactSource.commitId ||
|
|
artifactPublish?.sourceCommitId === artifactIdentity.artifactSource.shortCommitId ||
|
|
artifactReport.commitId === artifactIdentity.artifactSource.commitId ||
|
|
artifactReport.commitId === artifactIdentity.artifactSource.shortCommitId
|
|
);
|
|
const publishReady = artifactPublish?.status === "published" &&
|
|
artifactPublish.publishedCount === requiredServices.length &&
|
|
allServicesAccountedFor &&
|
|
allRequiredServicesPublished &&
|
|
disabledServicesNotPublished &&
|
|
(sourceMatchesTarget || sourceCoversTarget);
|
|
|
|
if (publishReady) {
|
|
const summary = sourceMatchesTarget
|
|
? "DEV artifact publish report covers all required enabled service IDs with immutable digests for the target commit, and records disabled service reasons."
|
|
: `DEV artifact publish report covers artifact source ${artifactIdentity.artifactSource.shortCommitId}; target ${targetRef} ${targetShortCommit} has no artifact build input changes since that source.`;
|
|
reporter.check("dev-artifact-publish-report", "registry", "pass", summary, [artifactIdentity?.targetCoverage].filter(Boolean));
|
|
return;
|
|
}
|
|
|
|
const publishedCount = artifactPublish?.publishedCount ?? 0;
|
|
const serviceCount = artifactPublish?.serviceCount ?? SERVICE_IDS.length;
|
|
const sourceSummary = sourcePresenceResolved
|
|
? `source states resolved: ${sourcePresentCount} source-present, ${intentionallyDisabledCount} intentionally-disabled`
|
|
: "source states are not fully resolved";
|
|
reporter.check(
|
|
"dev-artifact-publish-report",
|
|
"registry",
|
|
"blocked",
|
|
`DEV artifact publish report status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${requiredServices.length || serviceCount} required services published for source ${artifactPublish?.sourceCommitId ?? artifactReport.commitId ?? "unknown"}; ${sourceSummary}.`
|
|
);
|
|
reporter.block({
|
|
type: "runtime_blocker",
|
|
scope: "dev-artifact-publish",
|
|
summary: `reports/dev-gate/dev-artifacts.json does not prove all required HWLAB service artifacts for ${targetRef} ${targetShortCommit}; current status is ${artifactPublish?.status ?? "missing"} with ${publishedCount}/${requiredServices.length || serviceCount} required services published and ${sourceSummary}.`,
|
|
nextTask: "Complete DEV artifact publishing for every enabled required HWLAB service at the artifact source commit, or prove the target commit has no artifact build input changes; keep disabled services marked not_published with reasons."
|
|
});
|
|
}
|
|
|
|
function validateRegistryCapabilities(reporter, registryCapabilities) {
|
|
for (const dimension of Object.values(registryCapabilities.dimensions)) {
|
|
reporter.check(
|
|
`registry-capability-${dimension.id}`,
|
|
"registry",
|
|
dimension.status,
|
|
dimension.summary,
|
|
[dimension]
|
|
);
|
|
}
|
|
|
|
reporter.check(
|
|
"registry-capability-classification",
|
|
"registry",
|
|
registryCapabilities.classification,
|
|
`Registry capability classification is ${registryCapabilities.classification}: process HTTP, Docker daemon push path, and k3s pull path are reported separately.`,
|
|
[registryCapabilities]
|
|
);
|
|
|
|
const dockerDaemonPushAccess = registryCapabilities.dimensions.dockerDaemonPushAccess;
|
|
if (dockerDaemonPushAccess.status === "blocked") {
|
|
reporter.block({
|
|
type: "network_blocker",
|
|
scope: "docker-daemon-push-access",
|
|
summary: dockerDaemonPushAccess.summary,
|
|
nextTask: "Restore Docker daemon access to the D601 local/internal registry before claiming DEV artifact publish readiness."
|
|
});
|
|
}
|
|
|
|
const k3sPullAccess = registryCapabilities.dimensions.k3sPullAccess;
|
|
if (k3sPullAccess.status === "blocked") {
|
|
reporter.block({
|
|
type: "network_blocker",
|
|
scope: "k3s-pull-access",
|
|
summary: k3sPullAccess.summary,
|
|
nextTask: "Repair read-only hwlab-dev k3s access or image pull configuration before claiming deploy-path registry readiness."
|
|
});
|
|
}
|
|
}
|
|
|
|
function manifestReadStatus(registryEvidence) {
|
|
if (registryEvidence.every((probe) => probe.ok)) return "pass";
|
|
if (registryEvidence.some((probe) => probe.error || probe.status === 401 || probe.status === 403 || probe.status === 404)) {
|
|
return "degraded";
|
|
}
|
|
return "blocked";
|
|
}
|
|
|
|
function validateEdgeHealthReport(reporter, edgeReport) {
|
|
if (!edgeReport) {
|
|
reporter.check("dev-edge-health-report", "edge", "blocked", "No DEV edge health report is present.");
|
|
reporter.block({
|
|
type: "network_blocker",
|
|
scope: "dev-edge-health-report",
|
|
summary: "reports/dev-gate/dev-edge-health.json is missing, so the preflight has no committed edge/frp health evidence.",
|
|
nextTask: "Run the read-only DEV edge health smoke and commit reports/dev-gate/dev-edge-health.json before real deployment."
|
|
});
|
|
return;
|
|
}
|
|
|
|
const edgeHealth = edgeReport.edgeHealth;
|
|
const pass = edgeReport.status === "pass" && edgeHealth?.status === "pass";
|
|
const evidence = [
|
|
{
|
|
report: "reports/dev-gate/dev-edge-health.json",
|
|
status: edgeReport.status,
|
|
classification: edgeHealth?.classification ?? "unknown",
|
|
publicTcp: edgeHealth?.publicTcp ?? [],
|
|
kubernetesObservable: edgeHealth?.kubernetes?.observable ?? false
|
|
}
|
|
];
|
|
|
|
if (pass) {
|
|
reporter.check("dev-edge-health-report", "edge", "pass", "Committed DEV edge health report shows public edge health passing.", evidence);
|
|
return;
|
|
}
|
|
|
|
const classification = edgeHealth?.classification ?? "unknown";
|
|
const summary = edgeHealth?.blocker ?? edgeReport.devPreconditions?.summary ?? "DEV edge health report is not passing.";
|
|
reporter.check("dev-edge-health-report", "edge", "blocked", `Committed DEV edge health report is ${edgeReport.status} with classification ${classification}.`, evidence);
|
|
reporter.block({
|
|
type: "network_blocker",
|
|
scope: edgeHealth?.diagnosis?.likelyLayer ?? "dev-edge-health",
|
|
summary,
|
|
nextTask: edgeHealth?.diagnosis?.nextTask ?? "Repair the master frps/public DEV edge route and rerun the read-only DEV edge health smoke before real deployment."
|
|
});
|
|
}
|
|
|
|
function validateCloudApiDbContract(reporter, deploy, workloads, services) {
|
|
const staticContract = inspectCloudApiDbStaticContract(deploy, workloads, services);
|
|
const healthContract = summarizeDbContract(buildDbHealthContract());
|
|
const evidence = [
|
|
{
|
|
staticContract,
|
|
healthContract,
|
|
fixtureEvidence: false,
|
|
liveDbEvidence: false
|
|
}
|
|
];
|
|
|
|
if (staticContract.ready) {
|
|
reporter.check(
|
|
"cloud-api-db-env-contract",
|
|
"db",
|
|
"pass",
|
|
"cloud-api DEV DB manifest declares the required env names and redacted Secret reference; optional cloud-api-db alias is not a readiness gate.",
|
|
evidence
|
|
);
|
|
} else {
|
|
const missing = [
|
|
...staticContract.missingDeployEnv,
|
|
...staticContract.missingK8sEnv,
|
|
...staticContract.missingSecretRefs,
|
|
...staticContract.missingDnsContract
|
|
];
|
|
reporter.check(
|
|
"cloud-api-db-env-contract",
|
|
"db",
|
|
"blocked",
|
|
`cloud-api DEV DB manifest is missing ${missing.join(", ")}.`,
|
|
evidence
|
|
);
|
|
reporter.block({
|
|
type: "contract_blocker",
|
|
scope: "cloud-api-db-env-contract",
|
|
summary: `cloud-api DEV DB contract is incomplete; missing ${missing.join(", ")}.`,
|
|
nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so they expose the required DB env names and redacted Secret reference. Do not make cloud-api-db a hard readiness gate unless this repo also owns its Service/Endpoint rollout contract."
|
|
});
|
|
}
|
|
|
|
if (healthContract.ready) {
|
|
reporter.check(
|
|
"cloud-api-db-health-gate",
|
|
"db",
|
|
"pass",
|
|
"cloud-api DB health gate reports required env presence with redacted values.",
|
|
evidence
|
|
);
|
|
return;
|
|
}
|
|
|
|
reporter.check(
|
|
"cloud-api-db-health-gate",
|
|
"db",
|
|
"blocked",
|
|
`cloud-api DB health gate is missing ${healthContract.missingEnv.join(", ")}.`,
|
|
evidence
|
|
);
|
|
reporter.block({
|
|
type: "runtime_blocker",
|
|
scope: "cloud-api-db-health-gate",
|
|
summary: `cloud-api DB runtime env is not ready; missing ${healthContract.missingEnv.join(", ")}.`,
|
|
nextTask: "Configure DEV hwlab-cloud-api with Secret hwlab-cloud-api-dev-db/database-url and HWLAB_CLOUD_DB_SSL_MODE=require, then rerun health/preflight without printing the secret value."
|
|
});
|
|
}
|
|
|
|
function inspectCloudApiDbStaticContract(deploy, workloads, services) {
|
|
const cloudApi = deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api") ?? {};
|
|
const deployEnv = cloudApi.env ?? {};
|
|
const workloadEnv = getWorkloadEnvByServiceId(workloads, "hwlab-cloud-api");
|
|
const secretEnvNames = new Set(DEV_DB_ENV_CONTRACT.secretRefs.map((ref) => ref.env));
|
|
const fields = DEV_DB_ENV_CONTRACT.requiredEnv.map((name) => {
|
|
const secretRef = DEV_DB_ENV_CONTRACT.secretRefs.find((ref) => ref.env === name) ?? null;
|
|
const workloadEntry = workloadEnv.get(name) ?? null;
|
|
const manifestPresent = Object.hasOwn(deployEnv, name);
|
|
const k8sPresent = workloadEnv.has(name);
|
|
const secretRefMatches = secretRef
|
|
? deployEnv[name] === `secretRef:${secretRef.secretName}/${secretRef.secretKey}` &&
|
|
workloadEntry?.valueFrom?.secretKeyRef?.name === secretRef.secretName &&
|
|
workloadEntry?.valueFrom?.secretKeyRef?.key === secretRef.secretKey
|
|
: true;
|
|
|
|
return {
|
|
name,
|
|
manifestPresent,
|
|
k8sPresent,
|
|
redacted: secretEnvNames.has(name),
|
|
source: secretEnvNames.has(name) ? "k8s-secret-ref" : "runtime-env",
|
|
secretRef: secretRef
|
|
? {
|
|
secretName: secretRef.secretName,
|
|
secretKey: secretRef.secretKey,
|
|
present: secretRefMatches,
|
|
redacted: true
|
|
}
|
|
: null
|
|
};
|
|
});
|
|
const missingDeployEnv = fields.filter((field) => !field.manifestPresent).map((field) => field.name);
|
|
const missingK8sEnv = fields.filter((field) => !field.k8sPresent).map((field) => field.name);
|
|
const missingSecretRefs = fields
|
|
.filter((field) => field.secretRef && !field.secretRef.present)
|
|
.map((field) => `${field.secretRef.secretName}/${field.secretRef.secretKey}`);
|
|
const dnsContract = inspectCloudApiDbOptionalAliasContract(deployEnv, workloadEnv, services);
|
|
|
|
return {
|
|
contractVersion: DEV_DB_ENV_CONTRACT.contractVersion,
|
|
environment: DEV_DB_ENV_CONTRACT.environment,
|
|
ready:
|
|
missingDeployEnv.length === 0 &&
|
|
missingK8sEnv.length === 0 &&
|
|
missingSecretRefs.length === 0 &&
|
|
dnsContract.ready,
|
|
requiredEnv: fields,
|
|
dns: dnsContract,
|
|
missingDeployEnv,
|
|
missingK8sEnv,
|
|
missingSecretRefs,
|
|
missingDnsContract: dnsContract.missing,
|
|
secretMaterialRead: false,
|
|
valuesRedacted: true,
|
|
liveDbEvidence: false,
|
|
fixtureEvidence: false
|
|
};
|
|
}
|
|
|
|
function inspectCloudApiDbOptionalAliasContract(deployEnv, workloadEnv, services) {
|
|
const alias = DEV_DB_ENV_CONTRACT.dns;
|
|
const service = listManifestItems(services).find((item) => item?.metadata?.name === alias.serviceName) ?? null;
|
|
const actual = {
|
|
deployServiceName: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAME,
|
|
deployServiceNamespace: deployEnv.HWLAB_CLOUD_DB_SERVICE_NAMESPACE,
|
|
deployHost: deployEnv.HWLAB_CLOUD_DB_HOST,
|
|
deployPort: deployEnv.HWLAB_CLOUD_DB_PORT,
|
|
workloadServiceName: workloadEnv.get("HWLAB_CLOUD_DB_SERVICE_NAME")?.value,
|
|
workloadServiceNamespace: workloadEnv.get("HWLAB_CLOUD_DB_SERVICE_NAMESPACE")?.value,
|
|
workloadHost: workloadEnv.get("HWLAB_CLOUD_DB_HOST")?.value,
|
|
workloadPort: workloadEnv.get("HWLAB_CLOUD_DB_PORT")?.value,
|
|
serviceName: service?.metadata?.name
|
|
};
|
|
const missing = [];
|
|
for (const [name, value] of Object.entries(actual)) {
|
|
if (value !== undefined) {
|
|
missing.push(`${name} must be absent because ${alias.serviceName} is not the readiness authority`);
|
|
}
|
|
}
|
|
|
|
return {
|
|
ready: missing.length === 0,
|
|
source: alias.source,
|
|
serviceName: alias.serviceName,
|
|
namespace: alias.namespace,
|
|
port: alias.port,
|
|
portName: alias.portName,
|
|
requiredForReadiness: alias.requiredForReadiness,
|
|
usedForProbe: alias.usedForProbe,
|
|
servicePresent: Boolean(service),
|
|
secretMaterialRead: false,
|
|
liveDbEvidence: false,
|
|
missing,
|
|
actual
|
|
};
|
|
}
|
|
|
|
function getWorkloadEnvByServiceId(workloads, serviceId) {
|
|
const envByName = new Map();
|
|
for (const item of listManifestItems(workloads)) {
|
|
for (const container of item?.spec?.template?.spec?.containers ?? []) {
|
|
const itemServiceId =
|
|
item?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
|
|
item?.spec?.template?.metadata?.labels?.["hwlab.pikastech.local/service-id"] ||
|
|
container?.name;
|
|
if (itemServiceId !== serviceId) {
|
|
continue;
|
|
}
|
|
for (const env of container.env ?? []) {
|
|
envByName.set(env.name, env);
|
|
}
|
|
}
|
|
}
|
|
return envByName;
|
|
}
|
|
|
|
function listManifestItems(document) {
|
|
if (!document) {
|
|
return [];
|
|
}
|
|
return document.kind === "List" ? document.items ?? [] : [document];
|
|
}
|
|
|
|
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 public ports 16666/16667.");
|
|
} 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 registryServices = requiredCatalogServices(catalog);
|
|
const registryEvidence = await Promise.all(registryServices.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 registryStatus = manifestReadStatus(registryEvidence);
|
|
reporter.check(
|
|
"catalog-image-manifest-read",
|
|
"artifact-catalog",
|
|
registryStatus,
|
|
registryStatus === "pass"
|
|
? "All required catalog images were visible through manifest HEAD probes."
|
|
: "One or more required catalog images could not be verified through manifest HEAD probes; this is runner-process catalog evidence, not Docker daemon push access.",
|
|
registryEvidence
|
|
);
|
|
if (registryStatus === "blocked") {
|
|
reporter.block({
|
|
type: "runtime_blocker",
|
|
scope: "registry-manifest-read",
|
|
summary: "This preflight could not verify catalog image manifests through the runner process path; this is a #66 registry reachability dimension, not evidence that publish digests are missing.",
|
|
nextTask: "Resolve the #66 registry reachability path or provide a non-secret registry reachability artifact; do not rerun heavy publish solely for this manifest-read probe."
|
|
});
|
|
}
|
|
}
|
|
|
|
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, artifactIdentity, registryCapabilities) {
|
|
const blockingCheckStatuses = new Set(["blocked", "failed"]);
|
|
const conclusion = reporter.blockers.length === 0 &&
|
|
reporter.checks.every((item) => !blockingCheckStatuses.has(item.status)) ? "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,
|
|
reportLifecycle: activeReportLifecycle("Current read-only DEV preflight report; source, local, and dry-run checks are not DEV-LIVE acceptance."),
|
|
forbiddenActions,
|
|
validationCommands: [
|
|
"node --check scripts/dev-gate-preflight.mjs",
|
|
"node --check scripts/src/dev-gate-preflight.mjs",
|
|
"node --check scripts/src/registry-capabilities.mjs",
|
|
"node --check scripts/refresh-artifact-catalog.mjs",
|
|
"node scripts/dev-gate-preflight.mjs",
|
|
"node --check scripts/validate-dev-gate-report.mjs",
|
|
"node scripts/validate-dev-gate-report.mjs"
|
|
],
|
|
artifactIdentity,
|
|
registryCapabilities,
|
|
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.commitId,
|
|
targetShortCommit: report.target.shortCommitId,
|
|
artifactCatalogCommit: report.artifactIdentity?.artifactCatalog?.commitId,
|
|
catalogDigests: report.artifactIdentity?.artifactCatalog?.digestCounts,
|
|
registryCapabilities: {
|
|
classification: report.registryCapabilities?.classification,
|
|
dimensions: Object.fromEntries(Object.entries(report.registryCapabilities?.dimensions ?? {}).map(([key, value]) => [
|
|
key,
|
|
value.status
|
|
]))
|
|
},
|
|
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 registryPrefix = optionalReports.artifactPublish?.artifactPublish?.registryPrefix ?? "127.0.0.1:5000/hwlab";
|
|
const registryCapabilities = await probeRegistryCapabilities({
|
|
registryPrefix,
|
|
timeoutMs: args.timeoutMs
|
|
});
|
|
const { deploy, catalog, masterEdge, artifactIdentity } = await validateLocalContracts(
|
|
reporter,
|
|
contracts,
|
|
optionalReports,
|
|
targetShortCommit,
|
|
targetCommit,
|
|
args.targetRef
|
|
);
|
|
|
|
validateRegistryCapabilities(reporter, registryCapabilities);
|
|
validateCloudApiDbContract(reporter, deploy, contracts[3], contracts[4]);
|
|
validateCodeAgentProviderContract(reporter, deploy, contracts[3]);
|
|
validateArtifactPublishReport(reporter, optionalReports.artifactPublish, artifactIdentity, targetShortCommit, targetCommit, args.targetRef);
|
|
validateEdgeHealthReport(reporter, optionalReports.edgeHealth);
|
|
await validateEdgeContracts(reporter, masterEdge);
|
|
validateRuntimeBoundary(reporter, deploy);
|
|
await validateLiveProbes(reporter, catalog, args.timeoutMs);
|
|
|
|
const report = makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity, registryCapabilities);
|
|
if (args.writeReport) {
|
|
await writeReport(report, args.reportPath);
|
|
}
|
|
printSummary(args, report);
|
|
|
|
if (report.conclusion === "blocked" && args.failOnBlocked) {
|
|
process.exitCode = 2;
|
|
}
|
|
}
|
|
|
|
function validateCodeAgentProviderContract(reporter, deploy, workloads) {
|
|
const staticContract = inspectCodeAgentProviderStaticContract(deploy, workloads);
|
|
const evidence = [
|
|
{
|
|
staticContract,
|
|
fixtureEvidence: false,
|
|
providerConnected: false
|
|
}
|
|
];
|
|
|
|
if (staticContract.ready) {
|
|
reporter.check(
|
|
"code-agent-provider-env-contract",
|
|
"agent",
|
|
"pass",
|
|
"cloud-api DEV Code Agent manifest declares the OpenAI provider Secret reference and DEV egress/proxy base URL without secret values.",
|
|
evidence
|
|
);
|
|
return;
|
|
}
|
|
|
|
const missing = [
|
|
...staticContract.missingDeployEnv,
|
|
...staticContract.missingK8sEnv,
|
|
...staticContract.missingSecretRefs,
|
|
...staticContract.missingEgressContract
|
|
];
|
|
reporter.check(
|
|
"code-agent-provider-env-contract",
|
|
"agent",
|
|
"blocked",
|
|
`cloud-api DEV Code Agent provider contract is missing ${missing.join(", ")}.`,
|
|
evidence
|
|
);
|
|
reporter.block({
|
|
type: "agent_blocker",
|
|
scope: "code-agent-provider-env-contract",
|
|
summary: `cloud-api DEV Code Agent provider contract is incomplete; missing ${missing.join(", ")}.`,
|
|
nextTask: "Repair deploy/deploy.json and deploy/k8s/base/workloads.yaml so hwlab-cloud-api has OPENAI_API_KEY from hwlab-code-agent-provider/openai-api-key and HWLAB_CODE_AGENT_OPENAI_BASE_URL through DEV egress/proxy; do not print the Secret value."
|
|
});
|
|
}
|
|
|
|
function inspectCodeAgentProviderStaticContract(deploy, workloads) {
|
|
const contract = DEV_CODE_AGENT_PROVIDER_CONTRACT;
|
|
const cloudApi = deploy?.services?.find((service) => service.serviceId === "hwlab-cloud-api") ?? {};
|
|
const deployEnv = cloudApi.env ?? {};
|
|
const workloadEnv = getWorkloadEnvByServiceId(workloads, "hwlab-cloud-api");
|
|
const secretRef = contract.secretRefs[0];
|
|
const expectedSecretRef = codeAgentSecretRefPlaceholder();
|
|
const workloadSecretRef = workloadEnv.get(secretRef.env)?.valueFrom?.secretKeyRef;
|
|
const fields = contract.requiredEnv.map((name) => ({
|
|
name,
|
|
manifestPresent: Object.hasOwn(deployEnv, name),
|
|
k8sPresent: workloadEnv.has(name),
|
|
redacted: name === secretRef.env,
|
|
source: name === secretRef.env ? "k8s-secret-ref" : "runtime-env"
|
|
}));
|
|
const secretRefMatches =
|
|
deployEnv[secretRef.env] === expectedSecretRef &&
|
|
workloadSecretRef?.name === secretRef.secretName &&
|
|
workloadSecretRef?.key === secretRef.secretKey;
|
|
const deployBaseUrl = deployEnv[contract.egress.env];
|
|
const workloadBaseUrl = workloadEnv.get(contract.egress.env)?.value;
|
|
const egressChecks = [
|
|
["deployEnv.HWLAB_CODE_AGENT_PROVIDER", deployEnv.HWLAB_CODE_AGENT_PROVIDER, contract.provider],
|
|
["deployEnv.HWLAB_CODE_AGENT_MODEL", deployEnv.HWLAB_CODE_AGENT_MODEL, contract.model],
|
|
["deployEnv.HWLAB_CODE_AGENT_OPENAI_BASE_URL", deployBaseUrl, contract.egress.defaultBaseUrl],
|
|
["workloadEnv.HWLAB_CODE_AGENT_PROVIDER", workloadEnv.get("HWLAB_CODE_AGENT_PROVIDER")?.value, contract.provider],
|
|
["workloadEnv.HWLAB_CODE_AGENT_MODEL", workloadEnv.get("HWLAB_CODE_AGENT_MODEL")?.value, contract.model],
|
|
["workloadEnv.HWLAB_CODE_AGENT_OPENAI_BASE_URL", workloadBaseUrl, contract.egress.defaultBaseUrl]
|
|
];
|
|
const missingEgressContract = egressChecks
|
|
.filter(([, actualValue, expectedValue]) => !Object.is(actualValue, expectedValue))
|
|
.map(([name, actualValue, expectedValue]) => `${name} expected ${expectedValue} got ${actualValue ?? "missing"}`);
|
|
if (deployBaseUrl === contract.egress.forbiddenDirectBaseUrl || workloadBaseUrl === contract.egress.forbiddenDirectBaseUrl) {
|
|
missingEgressContract.push("HWLAB_CODE_AGENT_OPENAI_BASE_URL must not point directly at public api.openai.com");
|
|
}
|
|
const missingDeployEnv = fields.filter((field) => !field.manifestPresent).map((field) => field.name);
|
|
const missingK8sEnv = fields.filter((field) => !field.k8sPresent).map((field) => field.name);
|
|
const missingSecretRefs = secretRefMatches ? [] : [`${secretRef.secretName}/${secretRef.secretKey}`];
|
|
|
|
return {
|
|
contractVersion: contract.contractVersion,
|
|
environment: contract.environment,
|
|
provider: contract.runtimeProvider,
|
|
model: contract.model,
|
|
ready:
|
|
missingDeployEnv.length === 0 &&
|
|
missingK8sEnv.length === 0 &&
|
|
missingSecretRefs.length === 0 &&
|
|
missingEgressContract.length === 0,
|
|
requiredEnv: fields,
|
|
secretRef: {
|
|
env: secretRef.env,
|
|
secretName: secretRef.secretName,
|
|
secretKey: secretRef.secretKey,
|
|
present: secretRefMatches,
|
|
redacted: true
|
|
},
|
|
egress: {
|
|
env: contract.egress.env,
|
|
target: contract.egress.target,
|
|
deployPresent: Boolean(deployBaseUrl),
|
|
workloadPresent: Boolean(workloadBaseUrl),
|
|
directPublicOpenAi: deployBaseUrl === contract.egress.forbiddenDirectBaseUrl || workloadBaseUrl === contract.egress.forbiddenDirectBaseUrl,
|
|
valueRedacted: true
|
|
},
|
|
missingDeployEnv,
|
|
missingK8sEnv,
|
|
missingSecretRefs,
|
|
missingEgressContract,
|
|
secretMaterialRead: false,
|
|
valuesRedacted: true,
|
|
providerConnected: false,
|
|
fixtureEvidence: false
|
|
};
|
|
}
|