feat: parallelize dev ci cd artifact flow
This commit is contained in:
@@ -0,0 +1,301 @@
|
||||
#!/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;
|
||||
});
|
||||
Reference in New Issue
Block a user