7e29522b65
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.
390 lines
12 KiB
JavaScript
390 lines
12 KiB
JavaScript
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."
|
|
}
|
|
};
|
|
}
|