Files
pikasTech-HWLAB/scripts/src/registry-capabilities.mjs
T

403 lines
13 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 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."
}
};
}