import { spawnSync } from "node:child_process"; import { ENVIRONMENT_DEV, SERVICE_IDS } from "../../internal/protocol/index.mjs"; const envVarName = "HWLAB_DEV_BASE_IMAGE"; const dockerCommand = "docker"; const dockerTimeoutMs = 15_000; const node20TagPattern = /^20-/u; const recommendedNode20Image = "node:20-bookworm-slim"; const recommendedHwlabBaseImage = "127.0.0.1:5000/hwlab/hwlab-dev-base:node20-bookworm-slim"; const recommendedPreloadTar = "hwlab-node20-bookworm-slim.tar"; const forbiddenImageFragments = Object.freeze([ "unidesk", "backend-core", "provider-gateway", "microservice-proxy", "code-queue" ]); const allowedExplicitBaseImages = Object.freeze([ "node:20-*", "hwlab-dev-base:*", "*/hwlab-dev-base:*", "hwlab-node20-base:*", "*/hwlab-node20-base:*", "hwlab-node-runtime-base:*", "*/hwlab-node-runtime-base:*" ]); function baseImageRecommendation(envValue = recommendedNode20Image) { return { source: "Preloaded D601 local Docker cache or D601-local registry tag.", envVar: envVarName, preferredImageTag: recommendedNode20Image, preferredHwlabImageTag: recommendedHwlabBaseImage, recommendedEnvValue: envValue }; } function provisionPlan({ exactBlocker = null, envValue = recommendedNode20Image, required = true } = {}) { return { required, exactBlocker, expectedImageTag: recommendedNode20Image, recommendedEnv: { name: envVarName, value: envValue, assignment: `${envVarName}=${envValue}` }, recommendedHwlabImageTag: recommendedHwlabBaseImage, nextHumanCommand: required ? `docker pull ${recommendedNode20Image}` : null, commands: { d601OnlinePreload: [ `docker pull ${recommendedNode20Image}`, `${envVarName}=${recommendedNode20Image} node scripts/preflight-dev-base-image.mjs` ], d601HwlabTagProvision: [ `docker pull ${recommendedNode20Image}`, `docker tag ${recommendedNode20Image} ${recommendedHwlabBaseImage}`, `${envVarName}=${recommendedHwlabBaseImage} node scripts/preflight-dev-base-image.mjs` ], offlineSourceExport: [ `docker pull ${recommendedNode20Image}`, `docker save ${recommendedNode20Image} -o ${recommendedPreloadTar}` ], offlineD601Import: [ `docker load -i ${recommendedPreloadTar}`, `${envVarName}=${recommendedNode20Image} node scripts/preflight-dev-base-image.mjs` ], artifactPublishDryRun: [ `${envVarName}=${envValue} node scripts/g14-artifact-publish.mjs --preflight --no-report` ] } }; } function blockedReportFor(exactBlocker) { const plan = provisionPlan({ exactBlocker }); return { exactBlocker, expectedImageTag: plan.expectedImageTag, recommendedEnv: plan.recommendedEnv, recommendedHwlabImageTag: plan.recommendedHwlabImageTag, nextHumanCommand: plan.nextHumanCommand, preloadCommands: plan.commands.d601OnlinePreload, offlinePreloadCommands: { sourceExport: plan.commands.offlineSourceExport, d601Import: plan.commands.offlineD601Import } }; } function runDocker(args) { const result = spawnSync(dockerCommand, args, { encoding: "utf8", timeout: dockerTimeoutMs }); return { ok: result.status === 0, status: result.status, signal: result.signal, errorCode: result.error?.code ?? null, stdout: result.stdout?.trim() ?? "", stderr: result.stderr?.trim() ?? "", timedOut: result.error?.code === "ETIMEDOUT" }; } function splitJsonLines(stdout) { return stdout .split("\n") .map((line) => line.trim()) .filter(Boolean) .map((line) => JSON.parse(line)); } function candidateTag(image) { return `${image.Repository}:${image.Tag}`; } function imageRefFromInspect(requestedImage, inspectDoc) { const repoTags = Array.isArray(inspectDoc.RepoTags) ? inspectDoc.RepoTags : []; if (repoTags.includes(requestedImage)) { return requestedImage; } return repoTags.find((tag) => !tag.includes("")) ?? requestedImage; } function isNode20BaseRef(imageRef) { const lowerRef = imageRef.toLowerCase(); return lowerRef.startsWith("node:20-"); } function isHwlabDevBaseRef(imageRef) { const lowerRef = imageRef.toLowerCase(); return /(^|\/)(hwlab-dev-base|hwlab-node20-base|hwlab-node-runtime-base)(?=[:@]|$)/u.test(lowerRef); } function isTaggedImage(image) { return image.Repository && image.Tag && image.Repository !== "" && image.Tag !== ""; } function approvalForBaseRef(imageRef) { if (isNode20BaseRef(imageRef)) { return { approved: true, reason: "local Node 20 builder base image" }; } if (isHwlabDevBaseRef(imageRef)) { return { approved: true, reason: "explicit HWLAB DEV builder base image" }; } return { approved: false, reason: `image reference is not one of the approved builder base patterns: ${allowedExplicitBaseImages.join(", ")}` }; } function forbiddenReasons(imageRef) { const lowerRef = imageRef.toLowerCase(); const reasons = []; for (const fragment of forbiddenImageFragments) { if (lowerRef.includes(fragment)) { reasons.push(`image reference contains forbidden runtime/build substitute fragment: ${fragment}`); } } for (const serviceId of SERVICE_IDS) { if ( lowerRef.includes(`/pikastech/${serviceId}:`) || lowerRef.includes(`/pikastech/${serviceId}@`) || lowerRef.startsWith(`${serviceId}:`) || lowerRef.startsWith(`${serviceId}@`) ) { reasons.push(`image reference points at HWLAB service artifact ${serviceId}, not a builder base image`); } } return reasons; } function inspectEvidenceStrings(inspectDoc) { const labels = inspectDoc.Config?.Labels ?? {}; const env = Array.isArray(inspectDoc.Config?.Env) ? inspectDoc.Config.Env : []; return [ ...(Array.isArray(inspectDoc.RepoTags) ? inspectDoc.RepoTags : []), ...(Array.isArray(inspectDoc.RepoDigests) ? inspectDoc.RepoDigests : []), ...Object.entries(labels).map(([key, value]) => `${key}=${value}`), ...env ].filter((value) => typeof value === "string" && value.length > 0); } function inspectEvidenceBlockers(inspectDoc) { const blockers = []; for (const evidence of inspectEvidenceStrings(inspectDoc)) { for (const reason of forbiddenReasons(evidence)) { blockers.push(`${reason} in image inspect evidence ${JSON.stringify(evidence)}`); } } return [...new Set(blockers)]; } function baseResult(overrides) { return { preflight: "hwlab-dev-base-image", environment: ENVIRONMENT_DEV, status: "blocked", publishUsable: false, artifactPublish: { issue: "#35", usable: false, reason: "No approved local DEV builder base image was selected." }, imageSource: "none", requestedImage: null, localTag: null, imageId: null, repoDigests: [], candidates: [], rejectedCandidates: [], blockers: [], nextSteps: [], recommendation: baseImageRecommendation(), provision: provisionPlan(), blockedReport: null, constraints: { localOnly: true, doesNotPull: true, doesNotBuild: true, doesNotPush: true, doesNotDeploy: true, doesNotReadSecrets: true, allowedExplicitBaseImages, forbiddenImageFragments, forbiddenHwlabServiceImages: SERVICE_IDS }, ...overrides }; } function blocked(overrides) { const exactBlocker = overrides.blockers?.length ? overrides.blockers.join("; ") : "DEV builder base image preflight did not return ready."; const envValue = overrides.recommendedEnvValue ?? overrides.requestedImage ?? recommendedNode20Image; return baseResult({ status: "blocked", publishUsable: false, artifactPublish: { issue: "#35", usable: false, reason: "DEV artifact publish must stop until the blocker is resolved." }, recommendation: baseImageRecommendation(envValue), provision: provisionPlan({ exactBlocker, envValue }), blockedReport: blockedReportFor(exactBlocker), ...overrides }); } function ready({ imageSource, approval, requestedImage, localTag, inspectDoc, candidates }) { return baseResult({ status: "ready", publishUsable: true, artifactPublish: { issue: "#35", usable: true, reason: "A local approved DEV builder base image is available." }, imageSource, approval, requestedImage, localTag, imageId: inspectDoc.Id ?? null, repoDigests: Array.isArray(inspectDoc.RepoDigests) ? inspectDoc.RepoDigests : [], candidates, recommendation: baseImageRecommendation(localTag), provision: provisionPlan({ envValue: localTag, required: false }), blockedReport: null, blockers: [], nextSteps: [ "Use the reported localTag as the DEV builder base image for artifact publish.", "Keep publish DEV-only and record the produced HWLAB service image tags and digests separately." ] }); } function inspectLocalImage(imageRef) { const inspect = runDocker(["image", "inspect", imageRef]); if (!inspect.ok) { return { ok: false, inspect, docs: [] }; } return { ok: true, inspect, docs: JSON.parse(inspect.stdout) }; } function dockerUnavailableResult(versionResult) { const reason = versionResult.timedOut ? "Docker CLI timed out during version detection." : "Docker CLI is not available in this runner."; return blocked({ blockers: [reason], nextSteps: [ "Run this preflight in the D601 builder environment with Docker available.", `Preload an approved local Node 20 builder image and set ${envVarName}= when using a non-node:20-* tag.`, "Do not use a UniDesk, Code Queue, backend-core, provider-gateway, or microservice-proxy runtime image as a substitute." ], containerEngine: { command: dockerCommand, available: false, version: null, errorCode: versionResult.errorCode, stderr: versionResult.stderr } }); } function candidateSource(image) { if (image.Repository === "node" && node20TagPattern.test(image.Tag)) { return "local-docker-node:20-*"; } if (isHwlabDevBaseRef(candidateTag(image))) { return "local-docker-hwlab-dev-base"; } return null; } function candidatePriority(candidate) { if (candidate.source === "local-docker-node:20-*") return 0; if (candidate.source === "local-docker-hwlab-dev-base") return 1; return 2; } function rejectedBaseCandidate(image) { if (!isTaggedImage(image)) return null; const localTag = candidateTag(image); if (image.Repository === "node" && !node20TagPattern.test(image.Tag)) { return { source: "local-docker-node", localTag, imageId: image.ID ?? null, reason: "local Node image is not Node 20 and is not approved as the HWLAB DEV builder base image" }; } return null; } function selectLocalBaseCandidate(images) { const candidates = images .filter(isTaggedImage) .map((image) => { const source = candidateSource(image); if (!source) return null; const localTag = candidateTag(image); return { source, approval: approvalForBaseRef(localTag), localTag, imageId: image.ID ?? null, size: image.Size ?? null, createdSince: image.CreatedSince ?? null }; }) .filter(Boolean) .map((image) => ({ ...image, priority: candidatePriority(image) })) .sort((left, right) => ( left.priority - right.priority || left.localTag.localeCompare(right.localTag) )) .map(({ priority, ...candidate }) => candidate); const rejectedCandidates = images .map(rejectedBaseCandidate) .filter(Boolean) .sort((left, right) => left.localTag.localeCompare(right.localTag)); return { candidates, rejectedCandidates, selected: candidates[0] ?? null }; } function explicitImageBlockers(requestedImage, localTag) { const approval = approvalForBaseRef(localTag); const requestedApproval = approvalForBaseRef(requestedImage); if (approval.approved || requestedApproval.approved) { return { approval: approval.approved ? approval : requestedApproval, blockers: [] }; } return { approval, blockers: [approval.reason] }; } function readyFromExplicitImage({ requestedImage, inspectDoc, containerEngine }) { const localTag = imageRefFromInspect(requestedImage, inspectDoc); const localTagBlockers = forbiddenReasons(localTag); const evidenceBlockers = inspectEvidenceBlockers(inspectDoc); const localTagApproval = explicitImageBlockers(requestedImage, localTag); const blockers = [...localTagBlockers, ...evidenceBlockers, ...localTagApproval.blockers]; if (blockers.length > 0) { return blocked({ imageSource: `env:${envVarName}`, requestedImage, localTag, imageId: inspectDoc.Id ?? null, blockers, recommendedEnvValue: recommendedNode20Image, nextSteps: [ `Set ${envVarName} to an approved local builder base image, not a runtime service image.`, `Allowed references are local node:20-* tags or HWLAB dev base tags such as ${recommendedHwlabBaseImage}.`, "Do not continue #35 artifact publish with this image." ], containerEngine }); } return { ...ready({ imageSource: `env:${envVarName}`, approval: localTagApproval.approval, requestedImage, localTag, inspectDoc, candidates: [ { source: `env:${envVarName}`, approval: localTagApproval.approval, localTag, imageId: inspectDoc.Id ?? null } ] }), containerEngine }; } export async function runDevBaseImagePreflight({ env = process.env } = {}) { const requestedImage = env[envVarName]?.trim() || null; const version = runDocker(["--version"]); if (!version.ok) { return dockerUnavailableResult(version); } const containerEngine = { command: dockerCommand, available: true, version: version.stdout, errorCode: null, stderr: "" }; if (requestedImage) { const refBlockers = forbiddenReasons(requestedImage); const requestedApproval = approvalForBaseRef(requestedImage); if (refBlockers.length > 0 || !requestedApproval.approved) { return blocked({ imageSource: `env:${envVarName}`, requestedImage, blockers: [ ...refBlockers, ...(requestedApproval.approved ? [] : [requestedApproval.reason]) ], recommendedEnvValue: recommendedNode20Image, nextSteps: [ `Set ${envVarName} to an approved local builder base image, not a runtime service image.`, `Allowed references are local node:20-* tags or HWLAB dev base tags such as ${recommendedHwlabBaseImage}.`, "Do not continue #35 artifact publish with this image." ], containerEngine }); } const inspected = inspectLocalImage(requestedImage); if (!inspected.ok || inspected.docs.length === 0) { return blocked({ imageSource: `env:${envVarName}`, requestedImage, blockers: [`${envVarName} is set to ${requestedImage}, but that image is not present in the local Docker image cache.`], nextSteps: [ `Preload ${recommendedNode20Image} or tag it as ${recommendedHwlabBaseImage} before artifact publish.`, `Keep ${envVarName} pointed at the exact local tag once the image is available.`, "Do not pull from third-party hosting or substitute a UniDesk runtime image during this preflight." ], containerEngine }); } return readyFromExplicitImage({ requestedImage, inspectDoc: inspected.docs[0], containerEngine }); } const imageList = runDocker(["image", "ls", "--format", "{{json .}}"]); if (!imageList.ok) { return blocked({ blockers: ["Docker image list failed; local base-image cache could not be inspected."], nextSteps: [ "Start or repair the local Docker daemon for the D601 builder environment.", `Then set ${envVarName}= or provide a local node:20-* image.`, "Do not claim #35 artifact publish readiness until this preflight returns ready." ], containerEngine: { ...containerEngine, stderr: imageList.stderr, errorCode: imageList.errorCode } }); } const { candidates, rejectedCandidates, selected } = selectLocalBaseCandidate(splitJsonLines(imageList.stdout)); if (!selected) { return blocked({ blockers: [`${envVarName} is not set and no approved local DEV builder base image was found in the Docker image cache; expected ${recommendedNode20Image} or ${recommendedHwlabBaseImage}.`], candidates, rejectedCandidates, nextSteps: [ `Preload ${recommendedNode20Image} into the D601 Docker cache, or tag it as ${recommendedHwlabBaseImage}.`, `Set ${envVarName}=${recommendedNode20Image} or ${envVarName}=${recommendedHwlabBaseImage} when invoking artifact publish.`, "Rerun this preflight and require status=ready before #35 artifact publish.", "Do not use UniDesk runtime, Code Queue runner, backend-core, provider-gateway, or microservice-proxy images as substitutes." ], containerEngine }); } const inspected = inspectLocalImage(selected.localTag); if (!inspected.ok || inspected.docs.length === 0) { return blocked({ imageSource: selected.source, requestedImage: null, localTag: selected.localTag, candidates, rejectedCandidates, blockers: [`Selected local candidate ${selected.localTag} could not be inspected.`], nextSteps: [ `Set ${envVarName} to a known-good approved local base image and rerun this preflight.`, "Do not continue #35 artifact publish until image inspect succeeds." ], containerEngine }); } const evidenceBlockers = inspectEvidenceBlockers(inspected.docs[0]); if (evidenceBlockers.length > 0) { return blocked({ imageSource: selected.source, requestedImage: null, localTag: selected.localTag, candidates, rejectedCandidates, imageId: inspected.docs[0].Id ?? null, blockers: evidenceBlockers, nextSteps: [ `Replace ${selected.localTag} with a clean approved Node 20 builder base image.`, "Do not continue #35 artifact publish with an image whose inspect metadata points at a forbidden runtime substitute." ], containerEngine }); } return { ...ready({ imageSource: selected.source, approval: selected.approval, requestedImage: null, localTag: selected.localTag, inspectDoc: inspected.docs[0], candidates }), rejectedCandidates, containerEngine }; } export function unexpectedPreflightErrorResult(error) { const message = error instanceof Error ? error.message : String(error); return blocked({ blockers: [`Unexpected preflight error: ${message}`], nextSteps: [ "Treat this as a blocker and inspect the preflight command output.", "Do not continue #35 artifact publish until the preflight returns status=ready." ] }); } export function formatPreflightResult(result) { return JSON.stringify(result, null, 2); } export function exitCodeForPreflight(result) { return result.publishUsable ? 0 : 2; } export function summarizeForRuntimeBase(result) { return { status: result.status, publishUsable: result.publishUsable, imageSource: result.imageSource, requestedImage: result.requestedImage, localTag: result.localTag, imageId: result.imageId, blockers: result.blockers, nextSteps: result.nextSteps, containerEngine: result.containerEngine }; }