232 lines
9.5 KiB
JavaScript
232 lines
9.5 KiB
JavaScript
#!/usr/bin/env node
|
|
// SPEC: PJ2026-01060703 CI/CD branch follower native HWLAB control-plane refresh.
|
|
// Responsibility: render and apply the HWLAB node Tekton Pipeline from a k8s git-mirror snapshot.
|
|
import { spawnSync } from "node:child_process";
|
|
import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
import { createRequire } from "node:module";
|
|
import { tmpdir } from "node:os";
|
|
import path from "node:path";
|
|
|
|
const startedAt = Date.now();
|
|
const sourceCommit = requiredEnv("SOURCE_COMMIT");
|
|
const sourceStageRef = requiredEnv("SOURCE_STAGE_REF");
|
|
const gitReadUrl = requiredEnv("GIT_READ_URL");
|
|
const fieldManager = requiredEnv("FIELD_MANAGER");
|
|
const tektonNamespace = requiredEnv("TEKTON_NAMESPACE");
|
|
const overlay = JSON.parse(Buffer.from(requiredEnv("HWLAB_RENDER_OVERLAY_B64"), "base64").toString("utf8"));
|
|
const workDir = mkdtempSync(path.join(tmpdir(), `hwlab-control-plane-${sourceCommit.slice(0, 12)}-`));
|
|
const repoDir = path.join(workDir, "repo");
|
|
const renderDir = path.join(workDir, "render");
|
|
const depsDir = path.join(workDir, "deps");
|
|
|
|
try {
|
|
checkoutSnapshot();
|
|
prepareYamlDependency();
|
|
applyDeployOverlay();
|
|
renderControlPlane();
|
|
applyPipeline();
|
|
emit({ ok: true, status: "applied" });
|
|
} finally {
|
|
rmSync(workDir, { recursive: true, force: true });
|
|
}
|
|
|
|
function checkoutSnapshot() {
|
|
run("git", ["init", repoDir], { cwd: "/" });
|
|
run("git", ["-C", repoDir, "remote", "add", "origin", gitReadUrl]);
|
|
run("git", ["-C", repoDir, "fetch", "--depth", "1", "origin", sourceStageRef]);
|
|
run("git", ["-C", repoDir, "checkout", "--detach", "FETCH_HEAD"]);
|
|
const head = capture("git", ["-C", repoDir, "rev-parse", "HEAD"]).trim();
|
|
if (head !== sourceCommit) throw new Error(`snapshot checkout mismatch: expected ${sourceCommit}, got ${head}`);
|
|
}
|
|
|
|
function prepareYamlDependency() {
|
|
mkdirSync(depsDir, { recursive: true });
|
|
writeFileSync(path.join(depsDir, "package.json"), JSON.stringify({ private: true, dependencies: {} }), "utf8");
|
|
if (canResolveYaml()) return;
|
|
const registry = requiredOverlayString("npmRegistry");
|
|
const timeoutMs = String(requiredPositiveNumber("npmFetchTimeoutMs"));
|
|
const retries = String(requiredNonNegativeNumber("npmRetries"));
|
|
const yamlSpec = yamlDependencySpec();
|
|
const env = renderEnv({
|
|
npm_config_registry: registry,
|
|
BUN_CONFIG_REGISTRY: registry,
|
|
npm_config_fetch_timeout: timeoutMs,
|
|
npm_config_fetch_retries: retries,
|
|
});
|
|
if (commandExists("bun")) {
|
|
const result = runOptional("bun", ["add", "--no-save", "--ignore-scripts", "--registry", registry, yamlSpec], { cwd: depsDir, env });
|
|
if (result.status === 0 && canResolveYaml()) return;
|
|
}
|
|
run("npm", ["install", "--package-lock=false", "--no-save", "--ignore-scripts", "--no-audit", "--no-fund", "--omit=dev", "--registry", registry, yamlSpec], { cwd: depsDir, env });
|
|
if (!canResolveYaml()) throw new Error("yaml dependency remains unresolved after install");
|
|
}
|
|
|
|
function yamlDependencySpec() {
|
|
const pkg = JSON.parse(readFileSync(path.join(repoDir, "package.json"), "utf8"));
|
|
const version = pkg.dependencies?.yaml || pkg.devDependencies?.yaml;
|
|
if (typeof version !== "string" || version.length === 0) throw new Error("package.json must declare yaml dependency for gitops render");
|
|
return `yaml@${version}`;
|
|
}
|
|
|
|
function canResolveYaml() {
|
|
const result = runOptional("node", ["-e", "require.resolve('yaml')"], { cwd: depsDir, stdio: "ignore" });
|
|
return result.status === 0;
|
|
}
|
|
|
|
function applyDeployOverlay() {
|
|
const requireFromDeps = createRequire(path.join(depsDir, "package.json"));
|
|
const YAML = requireFromDeps("yaml");
|
|
const deployPath = path.join(repoDir, "deploy/deploy.yaml");
|
|
const doc = YAML.parse(readFileSync(deployPath, "utf8"));
|
|
doc.nodes = doc.nodes || {};
|
|
doc.nodes[overlay.nodeId] = { ...(doc.nodes[overlay.nodeId] || {}), gitopsRoot: overlay.gitopsRoot, sourceRepo: overlay.gitUrl };
|
|
doc.lanes = doc.lanes || {};
|
|
const lane = doc.lanes[overlay.lane] || {};
|
|
const downloadStack = {
|
|
...(lane.envRecipe?.downloadStack || {}),
|
|
httpProxy: overlay.dockerProxyHttp,
|
|
httpsProxy: overlay.dockerProxyHttps,
|
|
noProxy: overlay.dockerNoProxyList,
|
|
};
|
|
doc.lanes[overlay.lane] = {
|
|
...lane,
|
|
node: overlay.nodeId,
|
|
sourceBranch: overlay.sourceBranch,
|
|
gitopsBranch: overlay.gitopsBranch,
|
|
namespace: overlay.runtimeNamespace,
|
|
endpoint: overlay.publicApiUrl,
|
|
publicEndpoints: { frontend: overlay.publicWebUrl, api: overlay.publicApiUrl },
|
|
artifactCatalog: overlay.catalogPath,
|
|
runtimePath: overlay.runtimePath,
|
|
imageTagMode: "full",
|
|
sourceRepo: overlay.gitUrl,
|
|
externalPostgres: overlay.externalPostgres,
|
|
observability: overlay.observability,
|
|
envRecipe: { ...(lane.envRecipe || {}), downloadStack },
|
|
};
|
|
if (overlay.runtimeStore !== undefined) doc.lanes[overlay.lane].runtimeStore = overlay.runtimeStore;
|
|
if (overlay.codeAgentRuntime !== undefined) doc.lanes[overlay.lane].codeAgentRuntime = overlay.codeAgentRuntime;
|
|
if (overlay.deployYamlGitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.deployYamlGitMirror;
|
|
writeFileSync(deployPath, YAML.stringify(doc), "utf8");
|
|
}
|
|
|
|
function renderControlPlane() {
|
|
run("node", [
|
|
"scripts/run-bun.mjs",
|
|
"scripts/gitops-render.mjs",
|
|
"--lane", overlay.lane,
|
|
"--node", overlay.nodeId,
|
|
"--gitops-root", overlay.gitopsRoot,
|
|
"--catalog-path", overlay.catalogPath,
|
|
"--image-tag-mode", "full",
|
|
"--source-revision", sourceCommit,
|
|
"--source-repo", overlay.gitUrl,
|
|
"--source-branch", overlay.sourceBranch,
|
|
"--gitops-branch", overlay.gitopsBranch,
|
|
"--git-read-url", overlay.gitReadUrl,
|
|
"--git-write-url", overlay.gitWriteUrl,
|
|
"--registry-prefix", overlay.registryPrefix,
|
|
"--runtime-endpoint", overlay.publicApiUrl,
|
|
"--web-endpoint", overlay.publicWebUrl,
|
|
"--out", renderDir,
|
|
], { cwd: repoDir, env: renderEnv() });
|
|
}
|
|
|
|
function applyPipeline() {
|
|
const pipelinePath = path.join(renderDir, overlay.tektonDir, "pipeline.yaml");
|
|
if (!existsSync(pipelinePath)) throw new Error(`rendered Pipeline missing: ${pipelinePath}`);
|
|
run("kubectl", ["apply", "--server-side", "--force-conflicts", `--field-manager=${fieldManager}`, "-f", pipelinePath], { cwd: repoDir, env: renderEnv() });
|
|
}
|
|
|
|
function renderEnv(extra = {}) {
|
|
const noProxy = overlay.noProxy || process.env.NO_PROXY || process.env.no_proxy || "";
|
|
return {
|
|
...process.env,
|
|
HTTP_PROXY: overlay.proxyHttp || process.env.HTTP_PROXY || "",
|
|
HTTPS_PROXY: overlay.proxyHttps || process.env.HTTPS_PROXY || "",
|
|
ALL_PROXY: overlay.proxyAll || process.env.ALL_PROXY || "",
|
|
NO_PROXY: noProxy,
|
|
http_proxy: overlay.proxyHttp || process.env.http_proxy || "",
|
|
https_proxy: overlay.proxyHttps || process.env.https_proxy || "",
|
|
all_proxy: overlay.proxyAll || process.env.all_proxy || "",
|
|
no_proxy: noProxy,
|
|
HWLAB_NODE_CI_TOOLS_IMAGE: overlay.toolsImage || process.env.HWLAB_NODE_CI_TOOLS_IMAGE || "",
|
|
HWLAB_NODE_BUILDKIT_IMAGE: overlay.buildkitSidecarImage || process.env.HWLAB_NODE_BUILDKIT_IMAGE || "",
|
|
...extra,
|
|
};
|
|
}
|
|
|
|
function run(command, args, options = {}) {
|
|
const result = runOptional(command, args, options);
|
|
if (result.status !== 0) throw new Error(failedCommandMessage(command, args, result));
|
|
return result;
|
|
}
|
|
|
|
function capture(command, args) {
|
|
const result = runOptional(command, args, { cwd: repoDir, encoding: "utf8", stdio: ["ignore", "pipe", "inherit"] });
|
|
if (result.status !== 0) throw new Error(failedCommandMessage(command, args, result));
|
|
return result.stdout || "";
|
|
}
|
|
|
|
function runOptional(command, args, options = {}) {
|
|
return spawnSync(command, args, {
|
|
cwd: options.cwd || repoDir,
|
|
env: options.env || process.env,
|
|
encoding: options.encoding || "utf8",
|
|
stdio: options.stdio || "inherit",
|
|
});
|
|
}
|
|
|
|
function commandExists(command) {
|
|
return runOptional("sh", ["-lc", `command -v ${command} >/dev/null 2>&1`], { cwd: repoDir, stdio: "ignore" }).status === 0;
|
|
}
|
|
|
|
function failedCommandMessage(command, args, result) {
|
|
const signal = result.signal ? ` signal=${result.signal}` : "";
|
|
const error = result.error ? ` error=${result.error.message}` : "";
|
|
const stderr = typeof result.stderr === "string" && result.stderr.length > 0 ? ` stderr=${tail(result.stderr, 1000)}` : "";
|
|
return `${command} ${args.join(" ")} failed with exit ${result.status}${signal}${error}${stderr}`;
|
|
}
|
|
|
|
function tail(value, maxChars) {
|
|
return value.length <= maxChars ? value : value.slice(value.length - maxChars);
|
|
}
|
|
|
|
function requiredEnv(name) {
|
|
const value = process.env[name];
|
|
if (!value) throw new Error(`${name} is required`);
|
|
return value;
|
|
}
|
|
|
|
function requiredOverlayString(name) {
|
|
const value = overlay[name];
|
|
if (typeof value !== "string" || value.length === 0) throw new Error(`overlay.${name} is required`);
|
|
return value;
|
|
}
|
|
|
|
function requiredPositiveNumber(name) {
|
|
const value = Number(overlay[name]);
|
|
if (!Number.isFinite(value) || value <= 0) throw new Error(`overlay.${name} must be a positive number`);
|
|
return Math.floor(value);
|
|
}
|
|
|
|
function requiredNonNegativeNumber(name) {
|
|
const value = Number(overlay[name]);
|
|
if (!Number.isFinite(value) || value < 0) throw new Error(`overlay.${name} must be a non-negative number`);
|
|
return Math.floor(value);
|
|
}
|
|
|
|
function emit(extra) {
|
|
process.stdout.write(`${JSON.stringify({
|
|
...extra,
|
|
sourceCommit,
|
|
sourceStageRef,
|
|
pipeline: overlay.pipelineName,
|
|
namespace: tektonNamespace,
|
|
elapsedMs: Date.now() - startedAt,
|
|
sourceAuthority: "k8s-git-mirror-snapshot",
|
|
statusAuthority: "kubernetes-api-serviceaccount",
|
|
parsedDownstreamCliOutput: false,
|
|
})}\n`);
|
|
}
|