302 lines
9.1 KiB
JavaScript
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;
|
|
});
|