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:
Lyon
2026-05-22 12:46:59 +08:00
committed by GitHub
parent a1c3cb58da
commit 7e29522b65
9 changed files with 1977 additions and 385 deletions
+39 -39
View File
@@ -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) => ({
+78 -11
View File
@@ -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);
}
+389
View File
@@ -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."
}
};
}
+79 -2
View File
@@ -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`);