feat: add dev base image preflight

This commit is contained in:
HWLAB Code Queue
2026-05-21 16:45:17 +00:00
parent c1a227d184
commit 6eb4c199b0
8 changed files with 803 additions and 138 deletions
+72 -33
View File
@@ -12,11 +12,12 @@ import path from "node:path";
import { fileURLToPath } from "node:url";
import { ENVIRONMENT_DEV, SERVICE_IDS } from "../internal/protocol/index.mjs";
import { runDevBaseImagePreflight } from "./src/dev-base-image-preflight.mjs";
const repoRoot = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
const defaultRegistryPrefix =
process.env.HWLAB_DEV_REGISTRY_PREFIX || process.env.HWLAB_DEV_REGISTRY || "127.0.0.1:5000/hwlab";
const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || "node:22-bookworm-slim";
const defaultBaseImage = process.env.HWLAB_DEV_BASE_IMAGE || null;
const defaultReportPath = "reports/dev-gate/dev-artifacts.json";
const catalogPath = "deploy/artifact-catalog.dev.json";
const deployPath = "deploy/deploy.json";
@@ -93,7 +94,7 @@ function printHelp() {
"",
"options:",
" --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab",
" --base-image IMAGE default: node:22-bookworm-slim",
" --base-image IMAGE override HWLAB_DEV_BASE_IMAGE for this run; must already be local",
" --services LIST comma-separated service IDs; default: all frozen DEV services",
" --report PATH default: reports/dev-gate/dev-artifacts.json",
" --no-report print JSON without updating the report file"
@@ -127,6 +128,44 @@ function blocker({ type, scope, summary, next }) {
};
}
function preflightEnvForBaseImage(baseImage) {
if (!baseImage) {
return process.env;
}
return {
...process.env,
HWLAB_DEV_BASE_IMAGE: baseImage
};
}
function summarizeBaseImagePreflight(result) {
return {
status: result.status,
imageSource: result.imageSource,
requestedImage: result.requestedImage,
localTag: result.localTag,
imageId: result.imageId,
repoDigests: result.repoDigests,
publishUsable: result.publishUsable,
blockers: result.blockers,
nextSteps: result.nextSteps,
containerEngine: result.containerEngine
};
}
function baseImagePreflightBlocker(result) {
return blocker({
type: "environment_blocker",
scope: "base-image",
summary: result.blockers.length
? result.blockers.join("; ")
: "DEV builder base image preflight did not return ready.",
next: result.nextSteps.length
? result.nextSteps.join(" ")
: "Provide an approved local Node 20 or HWLAB DEV builder base image before artifact publish."
});
}
function commandLine(command, args) {
return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" ");
}
@@ -460,10 +499,16 @@ function tailText(value, maxLength = 2500) {
return text.slice(text.length - maxLength);
}
async function preflight({ args, services, catalog, deployManifest }) {
async function preflight({ args, services, catalog, deployManifest, baseImagePreflight }) {
const blockers = [];
let registryPrefix = args.registryPrefix;
if (baseImagePreflight.publishUsable) {
args.baseImage = baseImagePreflight.localTag;
} else {
blockers.push(baseImagePreflightBlocker(baseImagePreflight));
}
try {
registryPrefix = validateRegistryPrefix(args.registryPrefix);
} catch (error) {
@@ -490,7 +535,8 @@ async function preflight({ args, services, catalog, deployManifest }) {
);
}
for (const tag of [args.baseImage.split(":").at(-1), args.services.length === 1 ? args.services[0] : ""]) {
const baseImageTag = args.baseImage ? args.baseImage.split(":").at(-1) : "";
for (const tag of [baseImageTag, args.services.length === 1 ? args.services[0] : ""]) {
if (mutableTags.has(tag)) {
blockers.push(
blocker({
@@ -503,29 +549,7 @@ async function preflight({ args, services, catalog, deployManifest }) {
}
}
const dockerVersion = await run("docker", ["version", "--format", "{{json .}}"]);
if (dockerVersion.code !== 0) {
blockers.push(
blocker({
type: "environment_blocker",
scope: "docker",
summary: "Docker CLI or daemon is unavailable.",
next: "Make the D601 runner Docker daemon reachable, then rerun the publish command."
})
);
} else {
const baseInspect = await run("docker", ["image", "inspect", args.baseImage, "--format", "{{.Id}}"]);
if (baseInspect.code !== 0) {
blockers.push(
blocker({
type: "environment_blocker",
scope: "base-image",
summary: `base image ${args.baseImage} is not present locally`,
next: "Preload an internal/local Node base image or set HWLAB_DEV_BASE_IMAGE to an already-local image; the publish path uses --pull=false."
})
);
}
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)) {
@@ -755,7 +779,7 @@ function devGateBlockers(blockers) {
return result;
}
function createReport({ args, repo, commitId, shortCommit, mode, artifacts, blockers }) {
function createReport({ args, repo, commitId, shortCommit, mode, artifacts, blockers, baseImagePreflight }) {
const status = reportStatus(mode, artifacts, blockers);
const fatalBlocked = hasFatalBlocker(blockers);
const publishedCount = artifacts.filter((artifact) => artifact.status === "published").length;
@@ -777,12 +801,16 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc
"docs/dev-acceptance-matrix.md",
"docs/m0-contract-audit.md",
"docs/artifact-catalog.md",
"docs/dev-artifact-publish.md"
"docs/dev-artifact-publish.md",
"docs/dev-base-image-preflight.md"
],
summary: "DEV artifact publish evidence for pikasTech/HWLAB#35, stored in the DEV gate report envelope."
},
validationCommands: [
"node --check scripts/dev-artifact-publish.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",
"node scripts/dev-artifact-publish.mjs --preflight --no-report",
"node --check scripts/validate-dev-gate-report.mjs",
"node scripts/validate-dev-gate-report.mjs"
@@ -811,6 +839,7 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc
status: fatalBlocked ? "blocked" : "pass",
requirements: [
"Registry prefix is localhost or private/internal only.",
"DEV builder base image preflight returns ready before build or publish.",
"Environment remains dev and namespace remains hwlab-dev.",
"PROD profile remains disabled.",
"No secret or token material is required by this workflow."
@@ -827,7 +856,8 @@ function createReport({ args, repo, commitId, shortCommit, mode, artifacts, bloc
repo,
sourceCommitId: commitId,
registryPrefix: args.registryPrefix,
baseImage: args.baseImage,
baseImage: args.baseImage ?? null,
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
environment: ENVIRONMENT_DEV,
serviceCount: artifacts.length,
builtCount,
@@ -865,6 +895,12 @@ async function main() {
}
args.registryPrefix = validateRegistryPrefix(args.registryPrefix);
const baseImagePreflight = await runDevBaseImagePreflight({
env: preflightEnvForBaseImage(args.baseImage)
});
if (baseImagePreflight.publishUsable) {
args.baseImage = baseImagePreflight.localTag;
}
const [catalog, deployManifest, commitId, remoteUrl] = await Promise.all([
readJson(catalogPath),
readJson(deployPath),
@@ -875,7 +911,7 @@ async function main() {
const repo = repoLabelFromRemote(remoteUrl);
const services = await resolveServices(args.services);
let blockers = await preflight({ args, services, catalog, deployManifest });
let blockers = await preflight({ args, services, catalog, deployManifest, baseImagePreflight });
let artifacts = services.map((service) => ({
...service,
status: "preflight_only",
@@ -912,7 +948,7 @@ async function main() {
}
}
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, artifacts, blockers });
const report = createReport({ args, repo, commitId, shortCommit, mode: args.mode, artifacts, blockers, baseImagePreflight });
if (args.emitReport) {
await writeReport(args.reportPath, report);
}
@@ -923,6 +959,7 @@ async function main() {
reportPath: args.emitReport ? args.reportPath : null,
sourceCommitId: commitId,
registryPrefix: args.registryPrefix,
baseImagePreflight: summarizeBaseImagePreflight(baseImagePreflight),
services: report.artifactPublish.services.map((service) => ({
serviceId: service.serviceId,
status: service.status,
@@ -932,7 +969,9 @@ async function main() {
blockers
}, null, 2));
if ((args.mode === "build" || args.mode === "publish") && artifacts.some((artifact) => artifact.status.endsWith("_failed"))) {
if (hasFatalBlocker(blockers)) {
process.exitCode = 2;
} else if ((args.mode === "build" || args.mode === "publish") && artifacts.some((artifact) => artifact.status.endsWith("_failed"))) {
process.exitCode = 1;
}
}
+17
View File
@@ -0,0 +1,17 @@
#!/usr/bin/env node
import {
exitCodeForPreflight,
formatPreflightResult,
runDevBaseImagePreflight,
unexpectedPreflightErrorResult
} from "./src/dev-base-image-preflight.mjs";
try {
const result = await runDevBaseImagePreflight({ env: process.env });
console.log(formatPreflightResult(result));
process.exitCode = exitCodeForPreflight(result);
} catch (error) {
const result = unexpectedPreflightErrorResult(error);
console.log(formatPreflightResult(result));
process.exitCode = exitCodeForPreflight(result);
}
+471
View File
@@ -0,0 +1,471 @@
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 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:*"
]);
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("<none>")) ?? 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)(?=[:@]|$)/u.test(lowerRef);
}
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: [],
blockers: [],
nextSteps: [],
constraints: {
localOnly: true,
doesNotPull: true,
doesNotBuild: true,
doesNotPush: true,
doesNotDeploy: true,
doesNotReadSecrets: true,
allowedExplicitBaseImages,
forbiddenImageFragments,
forbiddenHwlabServiceImages: SERVICE_IDS
},
...overrides
};
}
function blocked(overrides) {
return baseResult({
status: "blocked",
publishUsable: false,
artifactPublish: {
issue: "#35",
usable: false,
reason: "DEV artifact publish must stop until the blocker is resolved."
},
...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,
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}=<local-image> 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 selectNode20Candidate(images) {
const candidates = images
.filter((image) => image.Repository === "node" && node20TagPattern.test(image.Tag))
.map((image) => ({
source: "local-docker-node:20-*",
approval: approvalForBaseRef(candidateTag(image)),
localTag: candidateTag(image),
imageId: image.ID ?? null,
size: image.Size ?? null,
createdSince: image.CreatedSince ?? null
}))
.sort((left, right) => left.localTag.localeCompare(right.localTag));
return {
candidates,
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,
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 hwlab-dev-base:<tag>.",
"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])
],
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 hwlab-dev-base:<tag>.",
"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 or build the approved DEV builder base image locally 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}=<approved-local-image> 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, selected } = selectNode20Candidate(splitJsonLines(imageList.stdout));
if (!selected) {
return blocked({
blockers: [`${envVarName} is not set and no local node:20-* image was found in the Docker image cache.`],
candidates,
nextSteps: [
`Set ${envVarName} to an approved local DEV builder base image, or preload a local node:20-* image such as node:20-bookworm-slim.`,
"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,
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,
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
}),
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;
}