fix: split registry capability probes
Refs #66. Merged by commander after reviewing Code Queue task codex_1779422778882_1. Separates process HTTP, Docker daemon push, and k3s pull registry capabilities so runner loopback HTTP degradation does not block artifact publish when Docker/k3s paths are proven.
This commit is contained in:
@@ -16,6 +16,7 @@ import {
|
||||
serviceInventoryFromServices
|
||||
} from "./src/dev-artifact-services.mjs";
|
||||
import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.mjs";
|
||||
import { probeRegistryCapabilities } from "./src/registry-capabilities.mjs";
|
||||
|
||||
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
const defaultRegistryPrefix =
|
||||
@@ -152,6 +153,17 @@ function summarizeBaseImagePreflight(result) {
|
||||
};
|
||||
}
|
||||
|
||||
function summarizeRegistryCapabilities(result) {
|
||||
return {
|
||||
version: result.version,
|
||||
registryPrefix: result.registryPrefix,
|
||||
generatedAt: result.generatedAt,
|
||||
classification: result.classification,
|
||||
dimensions: result.dimensions,
|
||||
interpretation: result.interpretation
|
||||
};
|
||||
}
|
||||
|
||||
function baseImagePreflightBlocker(result) {
|
||||
return blocker({
|
||||
type: "environment_blocker",
|
||||
@@ -454,7 +466,7 @@ function sourceStateBlocker(service) {
|
||||
});
|
||||
}
|
||||
|
||||
async function preflight({ args, services, catalog, deployManifest, baseImagePreflight }) {
|
||||
async function preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities }) {
|
||||
const blockers = [];
|
||||
let registryPrefix = args.registryPrefix;
|
||||
|
||||
@@ -504,20 +516,15 @@ async function preflight({ args, services, catalog, deployManifest, baseImagePre
|
||||
}
|
||||
}
|
||||
|
||||
if (baseImagePreflight.containerEngine?.available) {
|
||||
if (registryPrefix.startsWith("127.0.0.1:5000/") || registryPrefix.startsWith("localhost:5000/")) {
|
||||
const ps = await run("docker", ["ps", "--format", "{{json .}}"]);
|
||||
if (ps.code !== 0 || !dockerPsShowsRegistry5000(ps.stdout)) {
|
||||
blockers.push(
|
||||
blocker({
|
||||
type: "network_blocker",
|
||||
scope: "registry",
|
||||
summary: "No Docker-visible registry container is publishing port 5000.",
|
||||
next: "Start or expose the D601 local registry before running --publish."
|
||||
})
|
||||
);
|
||||
}
|
||||
}
|
||||
if (registryCapabilities?.dimensions?.dockerDaemonPushAccess?.status === "blocked") {
|
||||
blockers.push(
|
||||
blocker({
|
||||
type: "network_blocker",
|
||||
scope: "docker-daemon-push-access",
|
||||
summary: registryCapabilities.dimensions.dockerDaemonPushAccess.summary,
|
||||
next: "Restore Docker daemon access to the D601 local/internal registry before running --publish."
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
for (const service of services) {
|
||||
@@ -561,22 +568,6 @@ function hasFatalBlocker(blockers) {
|
||||
return blockers.some((item) => fatalBlockerTypes.has(item.type));
|
||||
}
|
||||
|
||||
function dockerPsShowsRegistry5000(stdout) {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.filter(Boolean)
|
||||
.some((line) => {
|
||||
try {
|
||||
const row = JSON.parse(line);
|
||||
const image = String(row.Image ?? "");
|
||||
const ports = String(row.Ports ?? "");
|
||||
return image.startsWith("registry:") && ports.includes("5000->5000/tcp");
|
||||
} catch {
|
||||
return line.includes("registry:") && /5000(?:->|\\u003e)5000\/tcp/u.test(line);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
async function buildService({ args, repo, commitId, shortCommit, service }) {
|
||||
const tag = shortCommit;
|
||||
const ref = imageRef(args.registryPrefix, service.serviceId, tag);
|
||||
@@ -783,13 +774,14 @@ function artifactRecord({ args, service, shortCommit, status, digest = "not_publ
|
||||
};
|
||||
}
|
||||
|
||||
function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked }) {
|
||||
function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities }) {
|
||||
return {
|
||||
version: "v2",
|
||||
mode,
|
||||
sourceCommitId: commitId,
|
||||
imageTag: shortCommit,
|
||||
registryTarget: args.registryPrefix,
|
||||
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
|
||||
digestPlaceholder: "not_published",
|
||||
services: artifacts.map((artifact) => {
|
||||
const image = artifact.image ?? imageRef(args.registryPrefix, artifact.serviceId, shortCommit);
|
||||
@@ -813,12 +805,12 @@ function publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlo
|
||||
};
|
||||
}
|
||||
|
||||
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight }) {
|
||||
function createReport({ args, repo, commitId, shortCommit, mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities }) {
|
||||
const status = reportStatus(mode, artifacts, blockers);
|
||||
const fatalBlocked = hasFatalBlocker(blockers);
|
||||
const requiredArtifacts = artifacts.filter((artifact) => artifact.artifactRequired);
|
||||
const serviceInventory = serviceInventoryFromServices(services);
|
||||
const publishPlan = publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked });
|
||||
const publishPlan = publishPlanFor({ args, commitId, shortCommit, mode, artifacts, fatalBlocked, registryCapabilities });
|
||||
const publishedCount = requiredArtifacts.filter((artifact) => artifact.status === "published").length;
|
||||
const builtCount = requiredArtifacts.filter((artifact) => ["built", "published", "published_unverified_digest"].includes(artifact.status)).length;
|
||||
|
||||
@@ -846,6 +838,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
|
||||
validationCommands: [
|
||||
"node --check scripts/dev-artifact-publish.mjs",
|
||||
"node --check scripts/src/dev-artifact-services.mjs",
|
||||
"node --check scripts/src/registry-capabilities.mjs",
|
||||
"node --check scripts/preflight-dev-base-image.mjs",
|
||||
"node --check scripts/src/dev-base-image-preflight.mjs",
|
||||
"node scripts/preflight-dev-base-image.mjs",
|
||||
@@ -894,6 +887,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, services, artif
|
||||
repo,
|
||||
sourceCommitId: commitId,
|
||||
registryPrefix: args.registryPrefix,
|
||||
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
|
||||
baseImage: args.baseImage ?? null,
|
||||
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
|
||||
environment: ENVIRONMENT_DEV,
|
||||
@@ -942,9 +936,14 @@ async function main() {
|
||||
}
|
||||
|
||||
args.registryPrefix = validateRegistryPrefix(args.registryPrefix);
|
||||
const baseImagePreflight = await runDevBaseImagePreflight({
|
||||
env: preflightEnvForBaseImage(args.baseImage)
|
||||
});
|
||||
const [baseImagePreflight, registryCapabilities] = await Promise.all([
|
||||
runDevBaseImagePreflight({
|
||||
env: preflightEnvForBaseImage(args.baseImage)
|
||||
}),
|
||||
probeRegistryCapabilities({
|
||||
registryPrefix: args.registryPrefix
|
||||
})
|
||||
]);
|
||||
if (baseImagePreflight.publishUsable) {
|
||||
args.baseImage = baseImagePreflight.localTag;
|
||||
}
|
||||
@@ -958,7 +957,7 @@ async function main() {
|
||||
const repo = repoLabelFromRemote(remoteUrl);
|
||||
const services = await resolveServices(args.services, catalog);
|
||||
|
||||
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight });
|
||||
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight, registryCapabilities });
|
||||
let artifacts = services.map((service) => artifactRecord({
|
||||
args,
|
||||
service,
|
||||
@@ -995,7 +994,7 @@ async function main() {
|
||||
artifacts = services.map((service) => artifactByServiceId.get(service.serviceId));
|
||||
}
|
||||
|
||||
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight });
|
||||
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, services, artifacts, blockers, baseImagePreflight, registryCapabilities });
|
||||
if (args.emitReport) {
|
||||
await writeReport(args.reportPath, report);
|
||||
}
|
||||
@@ -1006,6 +1005,7 @@ async function main() {
|
||||
reportPath: args.emitReport ? args.reportPath : null,
|
||||
sourceCommitId: commitId,
|
||||
registryPrefix: args.registryPrefix,
|
||||
registryCapabilities: summarizeRegistryCapabilities(registryCapabilities),
|
||||
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
|
||||
publishPlan: report.artifactPublish.publishPlan,
|
||||
services: report.artifactPublish.services.map((service) => ({
|
||||
|
||||
@@ -11,12 +11,13 @@ import {
|
||||
summarizeDbContract
|
||||
} from "../../internal/cloud/db-contract.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", "#39", "#49"].map(
|
||||
const supports = ["#7", "#12", "#14", "#22", "#23", "#29", "#30", "#31", "#33", "#39", "#49", "#66"].map(
|
||||
(id) => `pikasTech/HWLAB${id}`
|
||||
).concat(["pikasTech/HWLAB#33", "pikasTech/HWLAB#35", "pikasTech/HWLAB#48"]);
|
||||
const forbiddenActions = [
|
||||
@@ -540,6 +541,54 @@ function validateArtifactPublishReport(reporter, artifactReport, targetShortComm
|
||||
});
|
||||
}
|
||||
|
||||
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.");
|
||||
@@ -806,18 +855,20 @@ async function validateLiveProbes(reporter, catalog, timeoutMs) {
|
||||
});
|
||||
return { serviceId: service.serviceId, image: service.image, ...probe };
|
||||
}));
|
||||
const registryOk = registryEvidence.every((probe) => probe.ok);
|
||||
const registryStatus = manifestReadStatus(registryEvidence);
|
||||
reporter.check(
|
||||
"registry-manifest-read",
|
||||
"registry",
|
||||
registryOk ? "pass" : "blocked",
|
||||
registryOk ? "All required catalog images were visible through registry manifest HEAD probes." : "One or more required catalog images could not be verified through registry manifest HEAD probes.",
|
||||
"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 (!registryOk) {
|
||||
if (registryStatus === "blocked") {
|
||||
reporter.block({
|
||||
type: "runtime_blocker",
|
||||
scope: "registry-manifest-read",
|
||||
scope: "catalog-image-manifest-read",
|
||||
summary: "This preflight could not verify registry manifests for the required DEV catalog images without reading credentials.",
|
||||
nextTask: "Publish required DEV images or provide a non-secret registry evidence artifact with immutable digests for each required HWLAB service."
|
||||
});
|
||||
@@ -848,9 +899,10 @@ async function writeReport(report, reportPath) {
|
||||
await writeFile(absoluteReportPath, `${JSON.stringify(report, null, 2)}\n`);
|
||||
}
|
||||
|
||||
function makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity) {
|
||||
function makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity, registryCapabilities) {
|
||||
const blockingCheckStatuses = new Set(["blocked", "failed"]);
|
||||
const conclusion = reporter.blockers.length === 0 &&
|
||||
reporter.checks.every((item) => item.status === "pass") ? "ready" : "blocked";
|
||||
reporter.checks.every((item) => !blockingCheckStatuses.has(item.status)) ? "ready" : "blocked";
|
||||
|
||||
return {
|
||||
$schema: "https://hwlab.pikastech.local/schemas/dev-gate-preflight-report.schema.json",
|
||||
@@ -872,12 +924,14 @@ function makeReport(args, targetCommit, targetShortCommit, reporter, artifactIde
|
||||
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,
|
||||
@@ -893,6 +947,13 @@ function printSummary(args, report) {
|
||||
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) => {
|
||||
@@ -921,6 +982,11 @@ export async function runPreflight(argv) {
|
||||
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 } = validateLocalContracts(
|
||||
reporter,
|
||||
contracts,
|
||||
@@ -929,6 +995,7 @@ export async function runPreflight(argv) {
|
||||
args.targetRef
|
||||
);
|
||||
|
||||
validateRegistryCapabilities(reporter, registryCapabilities);
|
||||
validateCloudApiDbContract(reporter, deploy, contracts[3]);
|
||||
validateArtifactPublishReport(reporter, optionalReports.artifactPublish, targetShortCommit, targetCommit, args.targetRef);
|
||||
validateEdgeHealthReport(reporter, optionalReports.edgeHealth);
|
||||
@@ -936,7 +1003,7 @@ export async function runPreflight(argv) {
|
||||
validateRuntimeBoundary(reporter, deploy);
|
||||
await validateLiveProbes(reporter, catalog, args.timeoutMs);
|
||||
|
||||
const report = makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity);
|
||||
const report = makeReport(args, targetCommit, targetShortCommit, reporter, artifactIdentity, registryCapabilities);
|
||||
if (args.writeReport) {
|
||||
await writeReport(report, args.reportPath);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,389 @@
|
||||
import { execFile } from "node:child_process";
|
||||
import { promisify } from "node:util";
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
const defaultRegistryPrefix = "127.0.0.1:5000/hwlab";
|
||||
const imagePullBackoffReasons = new Set(["ErrImagePull", "ImagePullBackOff", "InvalidImageName"]);
|
||||
|
||||
function commandLine(command, args) {
|
||||
return [command, ...args].join(" ");
|
||||
}
|
||||
|
||||
async function run(command, args = [], { timeoutMs = 5000 } = {}) {
|
||||
const commandText = commandLine(command, args);
|
||||
try {
|
||||
const result = await execFileAsync(command, args, {
|
||||
timeout: timeoutMs,
|
||||
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, timeoutMs) {
|
||||
return (await run("which", [command], { timeoutMs })).ok;
|
||||
}
|
||||
|
||||
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, { timeoutMs = 5000 } = {}) {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), timeoutMs);
|
||||
try {
|
||||
const response = await fetch(url, {
|
||||
method: "GET",
|
||||
signal: controller.signal
|
||||
});
|
||||
const body = await response.text();
|
||||
return {
|
||||
ok: response.ok,
|
||||
url,
|
||||
method: "GET",
|
||||
status: response.status,
|
||||
statusText: response.statusText,
|
||||
body: body.slice(0, 500)
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
ok: false,
|
||||
url,
|
||||
method: "GET",
|
||||
error: fetchErrorMessage(error)
|
||||
};
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
export function parseRegistryPrefix(prefix = defaultRegistryPrefix) {
|
||||
const normalized = prefix.replace(/\/+$/u, "");
|
||||
const [hostPort, ...pathParts] = normalized.split("/");
|
||||
const host = hostPort.split(":")[0].toLowerCase();
|
||||
const port = hostPort.includes(":") ? hostPort.split(":").at(-1) : null;
|
||||
return {
|
||||
normalized,
|
||||
hostPort,
|
||||
host,
|
||||
port,
|
||||
namespace: pathParts.join("/")
|
||||
};
|
||||
}
|
||||
|
||||
export function registryApiEndpoint(prefix = defaultRegistryPrefix) {
|
||||
const parsed = parseRegistryPrefix(prefix);
|
||||
return `http://${parsed.hostPort}/v2/`;
|
||||
}
|
||||
|
||||
function isLoopbackRegistry5000(prefix) {
|
||||
const parsed = parseRegistryPrefix(prefix);
|
||||
return (parsed.host === "127.0.0.1" || parsed.host === "localhost") && parsed.port === "5000";
|
||||
}
|
||||
|
||||
function summarizeCommand(result) {
|
||||
return {
|
||||
command: result.command,
|
||||
ok: result.ok,
|
||||
exitCode: result.exitCode,
|
||||
stdout: result.stdout.slice(0, 1000),
|
||||
stderr: result.stderr.slice(0, 1000),
|
||||
error: result.error ?? null
|
||||
};
|
||||
}
|
||||
|
||||
function parseDockerPs(stdout) {
|
||||
return stdout
|
||||
.split("\n")
|
||||
.map((line) => line.trim())
|
||||
.filter(Boolean)
|
||||
.map((line) => {
|
||||
try {
|
||||
return JSON.parse(line);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function registryContainersFromDockerPs(stdout) {
|
||||
return parseDockerPs(stdout)
|
||||
.filter((row) => {
|
||||
const image = String(row.Image ?? "");
|
||||
const ports = String(row.Ports ?? "");
|
||||
return image.startsWith("registry:") && ports.includes("5000->5000/tcp");
|
||||
})
|
||||
.map((row) => ({
|
||||
id: row.ID ?? null,
|
||||
image: row.Image ?? null,
|
||||
names: row.Names ?? null,
|
||||
ports: row.Ports ?? null,
|
||||
state: row.State ?? null,
|
||||
status: row.Status ?? null
|
||||
}));
|
||||
}
|
||||
|
||||
async function probeProcessHttpAccess(registryPrefix, timeoutMs) {
|
||||
const endpoint = registryApiEndpoint(registryPrefix);
|
||||
const probe = await httpProbe(endpoint, { timeoutMs });
|
||||
const passed = probe.ok || probe.status === 401;
|
||||
return {
|
||||
id: "process-http-access",
|
||||
status: passed ? "pass" : "degraded",
|
||||
requiredForPublish: false,
|
||||
requiredForDeploy: false,
|
||||
endpoint,
|
||||
probeKind: "runner-process-http-v2",
|
||||
summary: passed
|
||||
? `Runner process can reach ${endpoint}.`
|
||||
: `Runner process cannot reach ${endpoint}; this is process HTTP access only and does not prove Docker daemon push failure.`,
|
||||
evidence: {
|
||||
probe,
|
||||
publishFailure: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function probeDockerDaemonPushAccess(registryPrefix, timeoutMs) {
|
||||
const dockerVersion = await run("docker", ["--version"], { timeoutMs });
|
||||
if (!dockerVersion.ok) {
|
||||
return {
|
||||
id: "docker-daemon-push-access",
|
||||
status: "blocked",
|
||||
requiredForPublish: true,
|
||||
requiredForDeploy: false,
|
||||
endpoint: registryPrefix,
|
||||
probeKind: "docker-cli-read-only",
|
||||
summary: "Docker CLI is not available, so Docker daemon push access cannot be assessed.",
|
||||
evidence: {
|
||||
dockerVersion: summarizeCommand(dockerVersion),
|
||||
pushAttempted: false,
|
||||
mutationAttempted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const dockerPs = await run("docker", ["ps", "--format", "{{json .}}"], { timeoutMs });
|
||||
if (!dockerPs.ok) {
|
||||
return {
|
||||
id: "docker-daemon-push-access",
|
||||
status: "blocked",
|
||||
requiredForPublish: true,
|
||||
requiredForDeploy: false,
|
||||
endpoint: registryPrefix,
|
||||
probeKind: "docker-ps-read-only",
|
||||
summary: "Docker daemon is not readable, so Docker daemon push access cannot be assessed.",
|
||||
evidence: {
|
||||
dockerVersion: summarizeCommand(dockerVersion),
|
||||
dockerPs: summarizeCommand(dockerPs),
|
||||
pushAttempted: false,
|
||||
mutationAttempted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const matchingContainers = registryContainersFromDockerPs(dockerPs.stdout);
|
||||
const registryVisible = !isLoopbackRegistry5000(registryPrefix) || matchingContainers.length > 0;
|
||||
return {
|
||||
id: "docker-daemon-push-access",
|
||||
status: registryVisible ? "pass" : "blocked",
|
||||
requiredForPublish: true,
|
||||
requiredForDeploy: false,
|
||||
endpoint: registryPrefix,
|
||||
probeKind: "docker-ps-read-only",
|
||||
summary: registryVisible
|
||||
? "Docker daemon can see a local/internal registry target for publish preflight purposes; no push was attempted."
|
||||
: "Docker daemon did not show a registry container publishing port 5000.",
|
||||
evidence: {
|
||||
dockerVersion: summarizeCommand(dockerVersion),
|
||||
registryPrefix,
|
||||
loopbackPort5000: isLoopbackRegistry5000(registryPrefix),
|
||||
registryContainerVisible: registryVisible,
|
||||
matchingContainers,
|
||||
pushAttempted: false,
|
||||
mutationAttempted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function parsePodList(stdout) {
|
||||
try {
|
||||
const parsed = JSON.parse(stdout);
|
||||
return Array.isArray(parsed.items) ? parsed.items : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
function podPullEvidence(pods, registryPrefix) {
|
||||
const parsed = parseRegistryPrefix(registryPrefix);
|
||||
const hostNeedle = `${parsed.hostPort}/`;
|
||||
const pulledImages = [];
|
||||
const pullFailures = [];
|
||||
|
||||
for (const pod of pods) {
|
||||
const podName = pod?.metadata?.name ?? "unknown";
|
||||
for (const status of pod?.status?.containerStatuses ?? []) {
|
||||
const image = String(status.image ?? "");
|
||||
const imageID = String(status.imageID ?? "");
|
||||
const waitingReason = status.state?.waiting?.reason;
|
||||
if (waitingReason && imagePullBackoffReasons.has(waitingReason)) {
|
||||
pullFailures.push({
|
||||
pod: podName,
|
||||
container: status.name ?? null,
|
||||
image,
|
||||
reason: waitingReason
|
||||
});
|
||||
}
|
||||
if (image.includes(hostNeedle) && imageID.includes("sha256:")) {
|
||||
pulledImages.push({
|
||||
pod: podName,
|
||||
container: status.name ?? null,
|
||||
image,
|
||||
imageID
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
pulledImages,
|
||||
pullFailures
|
||||
};
|
||||
}
|
||||
|
||||
async function probeK3sPullAccess(registryPrefix, timeoutMs) {
|
||||
const kubectlExists = await commandExists("kubectl", timeoutMs);
|
||||
if (!kubectlExists) {
|
||||
return {
|
||||
id: "k3s-pull-access",
|
||||
status: "blocked",
|
||||
requiredForPublish: false,
|
||||
requiredForDeploy: true,
|
||||
endpoint: registryPrefix,
|
||||
probeKind: "kubectl-read-only",
|
||||
summary: "kubectl is not installed, so k3s pull access cannot be assessed from this runner.",
|
||||
evidence: {
|
||||
kubectlAvailable: false,
|
||||
pullAttempted: false,
|
||||
mutationAttempted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const [context, canGetPods, podsResult] = await Promise.all([
|
||||
run("kubectl", ["config", "current-context"], { timeoutMs }),
|
||||
run("kubectl", ["auth", "can-i", "get", "pods", "-n", "hwlab-dev"], { timeoutMs }),
|
||||
run("kubectl", ["-n", "hwlab-dev", "get", "pods", "-o", "json"], { timeoutMs })
|
||||
]);
|
||||
const canReadPods = canGetPods.ok && canGetPods.stdout === "yes" && podsResult.ok;
|
||||
if (!canReadPods) {
|
||||
return {
|
||||
id: "k3s-pull-access",
|
||||
status: "blocked",
|
||||
requiredForPublish: false,
|
||||
requiredForDeploy: true,
|
||||
endpoint: registryPrefix,
|
||||
probeKind: "kubectl-read-only",
|
||||
summary: "Read-only kubectl probes could not inspect hwlab-dev pods, so k3s pull access is blocked.",
|
||||
evidence: {
|
||||
kubectlAvailable: true,
|
||||
context: summarizeCommand(context),
|
||||
canGetPods: summarizeCommand(canGetPods),
|
||||
pods: summarizeCommand(podsResult),
|
||||
pullAttempted: false,
|
||||
mutationAttempted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const pods = parsePodList(podsResult.stdout);
|
||||
const evidence = podPullEvidence(pods, registryPrefix);
|
||||
const hasFailures = evidence.pullFailures.length > 0;
|
||||
const hasPulledImages = evidence.pulledImages.length > 0;
|
||||
return {
|
||||
id: "k3s-pull-access",
|
||||
status: hasFailures ? "blocked" : (hasPulledImages ? "pass" : "degraded"),
|
||||
requiredForPublish: false,
|
||||
requiredForDeploy: true,
|
||||
endpoint: registryPrefix,
|
||||
probeKind: "kubectl-read-only",
|
||||
summary: hasFailures
|
||||
? "Read-only kubectl observed image pull failures in hwlab-dev."
|
||||
: (hasPulledImages
|
||||
? "Read-only kubectl observed hwlab-dev pods with pulled registry images."
|
||||
: "Read-only kubectl can inspect hwlab-dev, but no current pod proves k3s pulled this registry prefix."),
|
||||
evidence: {
|
||||
kubectlAvailable: true,
|
||||
context: summarizeCommand(context),
|
||||
canGetPods: summarizeCommand(canGetPods),
|
||||
podCount: pods.length,
|
||||
pulledImages: evidence.pulledImages,
|
||||
pullFailures: evidence.pullFailures,
|
||||
pullAttempted: false,
|
||||
mutationAttempted: false
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
function capabilityClassification(dimensions) {
|
||||
const values = Object.values(dimensions).map((dimension) => dimension.status);
|
||||
if (values.includes("blocked")) return "blocked";
|
||||
if (values.includes("degraded")) return "degraded";
|
||||
return "pass";
|
||||
}
|
||||
|
||||
export async function probeRegistryCapabilities({
|
||||
registryPrefix = defaultRegistryPrefix,
|
||||
timeoutMs = 5000
|
||||
} = {}) {
|
||||
const [processHttpAccess, dockerDaemonPushAccess, k3sPullAccess] = await Promise.all([
|
||||
probeProcessHttpAccess(registryPrefix, timeoutMs),
|
||||
probeDockerDaemonPushAccess(registryPrefix, timeoutMs),
|
||||
probeK3sPullAccess(registryPrefix, timeoutMs)
|
||||
]);
|
||||
const dimensions = {
|
||||
processHttpAccess,
|
||||
dockerDaemonPushAccess,
|
||||
k3sPullAccess
|
||||
};
|
||||
|
||||
return {
|
||||
version: "v1",
|
||||
registryPrefix,
|
||||
generatedAt: new Date().toISOString(),
|
||||
classification: capabilityClassification(dimensions),
|
||||
dimensions,
|
||||
interpretation: {
|
||||
processHttpAccess: "Runner process HTTP access to /v2/ is diagnostic only and must not be treated as Docker daemon push access.",
|
||||
dockerDaemonPushAccess: "Docker daemon push access is the publish-path capability. This probe is read-only and does not push.",
|
||||
k3sPullAccess: "k3s pull access is the deploy-path capability. This probe is read-only and does not create pods or read secrets."
|
||||
}
|
||||
};
|
||||
}
|
||||
@@ -58,6 +58,7 @@ const requiredDevDeployApplyValidationCommands = [
|
||||
const requiredPreflightValidationCommands = [
|
||||
"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"
|
||||
];
|
||||
@@ -166,6 +167,8 @@ const reportFamilyTemplates = new Map([
|
||||
],
|
||||
requiredValidationCommands: [
|
||||
"node --check scripts/dev-artifact-publish.mjs",
|
||||
"node --check scripts/src/dev-artifact-services.mjs",
|
||||
"node --check scripts/src/registry-capabilities.mjs",
|
||||
"node --check scripts/preflight-dev-base-image.mjs",
|
||||
"node --check scripts/src/dev-base-image-preflight.mjs",
|
||||
"node scripts/preflight-dev-base-image.mjs",
|
||||
@@ -195,7 +198,7 @@ const reportFamilyTemplates = new Map([
|
||||
]
|
||||
]);
|
||||
|
||||
const statusValues = new Set(["pass", "blocked", "not_run", "not_applicable", "failed"]);
|
||||
const statusValues = new Set(["pass", "blocked", "degraded", "not_run", "not_applicable", "failed"]);
|
||||
const blockerTypes = new Set([
|
||||
"contract_blocker",
|
||||
"environment_blocker",
|
||||
@@ -208,6 +211,16 @@ const blockerTypes = new Set([
|
||||
const blockerStates = new Set(["open", "acknowledged", "closed"]);
|
||||
const artifactSourceStates = new Set(["source-present", "intentionally-disabled"]);
|
||||
const preflightConclusions = new Set(["ready", "blocked"]);
|
||||
const registryCapabilityDimensionIds = new Set([
|
||||
"process-http-access",
|
||||
"docker-daemon-push-access",
|
||||
"k3s-pull-access"
|
||||
]);
|
||||
const registryCapabilityDimensionKeys = new Set([
|
||||
"processHttpAccess",
|
||||
"dockerDaemonPushAccess",
|
||||
"k3sPullAccess"
|
||||
]);
|
||||
const requiredPreflightSupports = [
|
||||
"pikasTech/HWLAB#7",
|
||||
"pikasTech/HWLAB#12",
|
||||
@@ -215,7 +228,8 @@ const requiredPreflightSupports = [
|
||||
"pikasTech/HWLAB#23",
|
||||
"pikasTech/HWLAB#29",
|
||||
"pikasTech/HWLAB#30",
|
||||
"pikasTech/HWLAB#31"
|
||||
"pikasTech/HWLAB#31",
|
||||
"pikasTech/HWLAB#66"
|
||||
];
|
||||
|
||||
function assertObject(value, label) {
|
||||
@@ -461,6 +475,8 @@ async function validateReport(relativePath) {
|
||||
|
||||
if (Object.hasOwn(report, "artifactPublish")) {
|
||||
assertObject(report.artifactPublish, `${label}.artifactPublish`);
|
||||
assertRegistryCapabilities(report.artifactPublish.registryCapabilities, `${label}.artifactPublish.registryCapabilities`);
|
||||
assertRegistryCapabilities(report.artifactPublish.publishPlan?.registryCapabilities, `${label}.artifactPublish.publishPlan.registryCapabilities`);
|
||||
assertArray(report.artifactPublish.services ?? [], `${label}.artifactPublish.services`);
|
||||
for (const [index, service] of report.artifactPublish.services.entries()) {
|
||||
const serviceLabel = `${label}.artifactPublish.services[${index}]`;
|
||||
@@ -709,6 +725,65 @@ function assertShaOrNotPublished(value, label) {
|
||||
);
|
||||
}
|
||||
|
||||
function assertRegistryCapabilityDimension(dimension, label, expectedId) {
|
||||
assertObject(dimension, label);
|
||||
for (const field of [
|
||||
"id",
|
||||
"status",
|
||||
"requiredForPublish",
|
||||
"requiredForDeploy",
|
||||
"endpoint",
|
||||
"probeKind",
|
||||
"summary",
|
||||
"evidence"
|
||||
]) {
|
||||
assert.ok(Object.hasOwn(dimension, field), `${label} missing ${field}`);
|
||||
}
|
||||
assert.equal(dimension.id, expectedId, `${label}.id`);
|
||||
assert.ok(registryCapabilityDimensionIds.has(dimension.id), `${label}.id must be a known registry capability dimension`);
|
||||
assertStatus(dimension.status, `${label}.status`);
|
||||
assert.equal(typeof dimension.requiredForPublish, "boolean", `${label}.requiredForPublish`);
|
||||
assert.equal(typeof dimension.requiredForDeploy, "boolean", `${label}.requiredForDeploy`);
|
||||
assertString(dimension.endpoint, `${label}.endpoint`);
|
||||
assertString(dimension.probeKind, `${label}.probeKind`);
|
||||
assertString(dimension.summary, `${label}.summary`);
|
||||
assertObject(dimension.evidence, `${label}.evidence`);
|
||||
}
|
||||
|
||||
function assertRegistryCapabilities(capabilities, label) {
|
||||
assertObject(capabilities, label);
|
||||
for (const field of [
|
||||
"version",
|
||||
"registryPrefix",
|
||||
"generatedAt",
|
||||
"classification",
|
||||
"dimensions",
|
||||
"interpretation"
|
||||
]) {
|
||||
assert.ok(Object.hasOwn(capabilities, field), `${label} missing ${field}`);
|
||||
}
|
||||
assert.equal(capabilities.version, "v1", `${label}.version`);
|
||||
assertString(capabilities.registryPrefix, `${label}.registryPrefix`);
|
||||
assertTimestamp(capabilities.generatedAt, `${label}.generatedAt`);
|
||||
assertStatus(capabilities.classification, `${label}.classification`);
|
||||
assertObject(capabilities.dimensions, `${label}.dimensions`);
|
||||
assert.deepEqual(
|
||||
new Set(Object.keys(capabilities.dimensions)),
|
||||
registryCapabilityDimensionKeys,
|
||||
`${label}.dimensions must contain processHttpAccess, dockerDaemonPushAccess, and k3sPullAccess`
|
||||
);
|
||||
assertRegistryCapabilityDimension(capabilities.dimensions.processHttpAccess, `${label}.dimensions.processHttpAccess`, "process-http-access");
|
||||
assertRegistryCapabilityDimension(capabilities.dimensions.dockerDaemonPushAccess, `${label}.dimensions.dockerDaemonPushAccess`, "docker-daemon-push-access");
|
||||
assertRegistryCapabilityDimension(capabilities.dimensions.k3sPullAccess, `${label}.dimensions.k3sPullAccess`, "k3s-pull-access");
|
||||
assert.equal(capabilities.dimensions.processHttpAccess.requiredForPublish, false, `${label}.dimensions.processHttpAccess.requiredForPublish`);
|
||||
assert.equal(capabilities.dimensions.dockerDaemonPushAccess.requiredForPublish, true, `${label}.dimensions.dockerDaemonPushAccess.requiredForPublish`);
|
||||
assert.equal(capabilities.dimensions.k3sPullAccess.requiredForDeploy, true, `${label}.dimensions.k3sPullAccess.requiredForDeploy`);
|
||||
assertObject(capabilities.interpretation, `${label}.interpretation`);
|
||||
for (const key of registryCapabilityDimensionKeys) {
|
||||
assertString(capabilities.interpretation[key], `${label}.interpretation.${key}`);
|
||||
}
|
||||
}
|
||||
|
||||
function assertArtifactIdentity(identity, label, target) {
|
||||
assertObject(identity, label);
|
||||
for (const field of [
|
||||
@@ -1062,6 +1137,7 @@ async function validatePreflightReport(relativePath, report) {
|
||||
"forbiddenActions",
|
||||
"validationCommands",
|
||||
"artifactIdentity",
|
||||
"registryCapabilities",
|
||||
"conclusion",
|
||||
"checks",
|
||||
"blockers"
|
||||
@@ -1104,6 +1180,7 @@ async function validatePreflightReport(relativePath, report) {
|
||||
}
|
||||
|
||||
assertArtifactIdentity(report.artifactIdentity, `${label}.artifactIdentity`, report.target);
|
||||
assertRegistryCapabilities(report.registryCapabilities, `${label}.registryCapabilities`);
|
||||
|
||||
assertArray(report.checks, `${label}.checks`);
|
||||
assert.ok(report.checks.length >= 1, `${label}.checks must not be empty`);
|
||||
|
||||
Reference in New Issue
Block a user