Files
pikasTech-HWLAB/scripts/dev-runtime-base-image.mjs
T
2026-05-24 04:36:48 +00:00

302 lines
9.1 KiB
JavaScript

#!/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: <registry>/hwlab-node-runtime-base:deps-<hash>",
" --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;
});