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;
}
}