import { execFile } from "node:child_process"; import { promisify } from "node:util"; const execFileAsync = promisify(execFile); const defaultRegistryPrefix = "127.0.0.1:5000/hwlab"; const d601NativeKubeconfigPath = "/etc/rancher/k3s/k3s.yaml"; 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: true, requiredForDeploy: false, endpoint, probeKind: "runner-process-http-v2", summary: passed ? `Runner or BuildKit-side process can reach ${endpoint}.` : `Runner process cannot reach ${endpoint}; BuildKit publish may not be able to reach the registry from this execution surface.`, 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: "degraded", requiredForPublish: false, requiredForDeploy: false, endpoint: registryPrefix, probeKind: "docker-cli-read-only", summary: "Docker CLI is not available; legacy Docker daemon registry visibility is diagnostic only and is not required for BuildKit publish.", 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: "degraded", requiredForPublish: false, requiredForDeploy: false, endpoint: registryPrefix, probeKind: "docker-ps-read-only", summary: "Docker daemon is not readable; legacy Docker daemon registry visibility is diagnostic only and is not required for BuildKit publish.", evidence: { dockerVersion: summarizeCommand(dockerVersion), dockerPs: summarizeCommand(dockerPs), pushAttempted: false, mutationAttempted: false } }; } const matchingContainers = registryContainersFromDockerPs(dockerPs.stdout); const registryVisible = !isLoopbackRegistry5000(registryPrefix) || matchingContainers.length > 0; const k8sNativeLoopbackRegistry = isLoopbackRegistry5000(registryPrefix) && process.env.HWLAB_DEV_REGISTRY_K8S_NATIVE === "1"; const registryAccepted = registryVisible || k8sNativeLoopbackRegistry; return { id: "docker-daemon-push-access", status: registryAccepted ? "pass" : "degraded", requiredForPublish: false, requiredForDeploy: false, endpoint: registryPrefix, probeKind: "docker-ps-read-only", summary: registryVisible ? "Docker daemon can see a local/internal registry target; this legacy diagnostic did not push and is not required for BuildKit publish." : (k8sNativeLoopbackRegistry ? "Docker daemon did not show the registry container, but HWLAB_DEV_REGISTRY_K8S_NATIVE=1 declares the loopback registry is owned by Kubernetes and reachable through the node network." : "Docker daemon did not show a registry container publishing port 5000; this no longer blocks the BuildKit publish path."), evidence: { dockerVersion: summarizeCommand(dockerVersion), registryPrefix, loopbackPort5000: isLoopbackRegistry5000(registryPrefix), registryContainerVisible: registryVisible, k8sNativeLoopbackRegistry, 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 nativeKubectlArgs = (args) => ["KUBECONFIG=" + d601NativeKubeconfigPath, "kubectl", ...args]; const [context, canGetPods, podsResult] = await Promise.all([ run("env", nativeKubectlArgs(["config", "current-context"]), { timeoutMs }), run("env", nativeKubectlArgs(["auth", "can-i", "get", "pods", "-n", "hwlab-dev"]), { timeoutMs }), run("env", nativeKubectlArgs(["-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, kubeconfig: d601NativeKubeconfigPath, 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, kubeconfig: d601NativeKubeconfigPath, 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) .filter((dimension) => dimension.requiredForPublish || dimension.requiredForDeploy) .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/BuildKit-side process HTTP access to /v2/ is the publish-path registry API preflight.", dockerDaemonPushAccess: "Docker daemon registry visibility is a legacy read-only diagnostic only. It is not required for the BuildKit publish path 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." } }; }