#!/usr/bin/env node import assert from "node:assert/strict"; import { spawn } from "node:child_process"; import { createHash } from "node:crypto"; import { readFile } from "node:fs/promises"; import path from "node:path"; import { fileURLToPath } from "node:url"; import { runDevBaseImagePreflight, summarizeForRuntimeBase } 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_RUNTIME_BASE_PARENT || process.env.HWLAB_DEV_BASE_IMAGE || null; function parseArgs(argv) { const args = { registryPrefix: defaultRegistryPrefix, baseImage: defaultBaseImage, tag: null, build: true, push: false, help: false }; for (let index = 0; index < argv.length; index += 1) { const arg = argv[index]; if (arg === "--registry-prefix") args.registryPrefix = readOption(argv, ++index, arg); else if (arg === "--base-image") args.baseImage = readOption(argv, ++index, arg); else if (arg === "--tag") args.tag = readOption(argv, ++index, arg); else if (arg === "--dry-run" || arg === "--plan") args.build = false; else if (arg === "--push") args.push = true; else if (arg === "--help" || arg === "-h") args.help = true; else throw new Error(`unknown argument ${arg}`); } return args; } function readOption(argv, index, name) { const value = argv[index]; if (!value || value.startsWith("--")) { throw new Error(`${name} requires a value`); } return value; } function usage() { return [ "usage: node scripts/dev-runtime-base-image.mjs [--dry-run] [--push] [--base-image IMAGE] [--tag IMAGE]", "", "Build an optional D601-local HWLAB Node runtime base image with package dependencies preinstalled.", "", "options:", " --registry-prefix PREFIX default: 127.0.0.1:5000/hwlab", " --base-image IMAGE approved local parent image; default resolved by DEV base preflight", " --tag IMAGE output image tag; default: /hwlab-node-runtime-base:deps-", " --dry-run print the build plan without building", " --push push the resulting base image to the local/internal registry" ].join("\n"); } function normalizeRegistryPrefix(prefix) { const normalized = prefix.replace(/\/+$/u, ""); const [hostPort, ...pathParts] = normalized.split("/"); const host = hostPort.split(":")[0].toLowerCase(); assert.ok(hostPort && pathParts.length > 0, "registry prefix must include host and namespace"); assert.ok(!/prod|production/iu.test(normalized), "registry prefix must not include prod"); assert.ok( host === "localhost" || host === "127.0.0.1" || /^10\./u.test(host) || /^192\.168\./u.test(host) || /^172\.(1[6-9]|2\d|3[0-1])\./u.test(host) || host.endsWith(".local") || host.endsWith(".svc") || host.endsWith(".cluster.local"), "registry prefix must target localhost or a private/internal host" ); return normalized; } async function fileOrEmpty(relativePath) { try { return await readFile(path.join(repoRoot, relativePath), "utf8"); } catch (error) { if (error?.code === "ENOENT") return ""; throw error; } } async function dependencyHash() { const packageJson = await fileOrEmpty("package.json"); const packageLockJson = await fileOrEmpty("package-lock.json"); assert.ok(packageJson.trim(), "package.json is required"); const hash = createHash("sha256"); hash.update("package.json\0"); hash.update(packageJson); hash.update("\0package-lock.json\0"); hash.update(packageLockJson); return hash.digest("hex"); } function dockerfile(parentImage, depsHash) { return [ `FROM ${parentImage}`, "WORKDIR /opt/hwlab-node-runtime-base", `LABEL hwlab.pikastech.local/runtime-base-deps-hash=${depsHash}`, "COPY package.json ./package.json", "COPY package-lock.json* ./", "RUN if [ -f package-lock.json ]; then npm ci --omit=dev --ignore-scripts; else npm install --omit=dev --ignore-scripts; fi", "" ].join("\n"); } function commandLine(command, args) { return [command, ...args].map((part) => (/\s/u.test(part) ? JSON.stringify(part) : part)).join(" "); } function runWithInput(command, args, input) { const startedAt = Date.now(); return new Promise((resolve) => { const child = spawn(command, args, { cwd: repoRoot, env: process.env, stdio: ["pipe", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdin.end(input); child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("error", (error) => { resolve({ command: commandLine(command, args), code: 127, stdout, stderr: error.message, durationMs: Date.now() - startedAt }); }); child.on("close", (code) => { resolve({ command: commandLine(command, args), code: code ?? 1, stdout, stderr, durationMs: Date.now() - startedAt }); }); }); } function run(command, args) { const startedAt = Date.now(); return new Promise((resolve) => { const child = spawn(command, args, { cwd: repoRoot, env: process.env, stdio: ["ignore", "pipe", "pipe"] }); let stdout = ""; let stderr = ""; child.stdout.on("data", (chunk) => { stdout += chunk; }); child.stderr.on("data", (chunk) => { stderr += chunk; }); child.on("error", (error) => { resolve({ command: commandLine(command, args), code: 127, stdout, stderr: error.message, durationMs: Date.now() - startedAt }); }); child.on("close", (code) => { resolve({ command: commandLine(command, args), code: code ?? 1, stdout, stderr, durationMs: Date.now() - startedAt }); }); }); } function tailText(value, maxLength = 2500) { const text = value.trim(); if (text.length <= maxLength) return text; return text.slice(text.length - maxLength); } function inspectDigest(pushOutput) { const matches = [...pushOutput.matchAll(/digest:\s+(sha256:[a-f0-9]{64})/giu)]; return matches.length ? matches[matches.length - 1][1] : null; } async function main() { const args = parseArgs(process.argv.slice(2)); if (args.help) { console.log(usage()); return; } args.registryPrefix = normalizeRegistryPrefix(args.registryPrefix); const depsHash = await dependencyHash(); const image = args.tag ?? `${args.registryPrefix}/hwlab-node-runtime-base:deps-${depsHash.slice(0, 12)}`; const baseImagePreflight = await runDevBaseImagePreflight({ env: args.baseImage ? { ...process.env, HWLAB_DEV_BASE_IMAGE: args.baseImage } : process.env }); const parentImage = baseImagePreflight.localTag; const plan = { status: args.build ? "planned" : "dry-run", image, parentImage, depsHash, push: args.push, baseImagePreflight: summarizeForRuntimeBase(baseImagePreflight) }; if (!baseImagePreflight.publishUsable) { console.log(JSON.stringify({ ...plan, status: "blocked", blockers: baseImagePreflight.blockers, nextSteps: baseImagePreflight.nextSteps }, null, 2)); process.exitCode = 2; return; } if (!args.build) { console.log(JSON.stringify(plan, null, 2)); return; } const build = await runWithInput( "docker", ["build", "--pull=false", "-t", image, "-f", "-", "."], dockerfile(parentImage, depsHash) ); if (build.code !== 0) { console.log(JSON.stringify({ ...plan, status: "build_failed", command: build.command, durationMs: build.durationMs, logTail: tailText(`${build.stdout}\n${build.stderr}`) }, null, 2)); process.exitCode = 1; return; } const inspect = await run("docker", ["image", "inspect", "--format", "{{.Id}}", image]); let push = null; if (args.push) { const pushResult = await run("docker", ["push", image]); const digest = pushResult.code === 0 ? inspectDigest(`${pushResult.stdout}\n${pushResult.stderr}`) : null; push = { status: pushResult.code === 0 && digest ? "published" : "blocked", command: pushResult.command, code: pushResult.code, durationMs: pushResult.durationMs, digest, logTail: tailText(`${pushResult.stdout}\n${pushResult.stderr}`, 1200) }; } const status = push ? push.status : "built"; console.log(JSON.stringify({ ...plan, status, imageId: inspect.code === 0 ? inspect.stdout.trim() : null, build: { command: build.command, durationMs: build.durationMs, logTail: tailText(`${build.stdout}\n${build.stderr}`, 1200) }, push }, null, 2)); process.exitCode = status === "blocked" ? 2 : 0; } main().catch((error) => { console.error(JSON.stringify({ status: "failed", error: error instanceof Error ? error.message : String(error) }, null, 2)); process.exitCode = 1; });