#!/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 https from "node:https"; 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 kubeRequestTimeoutSeconds = requiredEnvPositiveNumber("KUBE_REQUEST_TIMEOUT_SECONDS"); const runtimeGitopsConfigMapName = requiredEnv("RUNTIME_GITOPS_CONFIGMAP_NAME"); 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(); const evidence = await applyPipeline(); emit({ ok: true, status: "applied", ...evidence }); } 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 YAML = yamlModule(); 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() }); } async function applyPipeline() { const pipelinePath = path.join(renderDir, overlay.tektonDir, "pipeline.yaml"); if (!existsSync(pipelinePath)) throw new Error(`rendered Pipeline missing: ${pipelinePath}`); const YAML = yamlModule(); const pipeline = YAML.parse(readFileSync(pipelinePath, "utf8")); if (!pipeline || typeof pipeline !== "object") throw new Error(`rendered Pipeline is not an object: ${pipelinePath}`); const renderedPipelineName = pipeline?.metadata?.name; if (typeof renderedPipelineName !== "string" || renderedPipelineName.length === 0) { throw new Error(`rendered Pipeline metadata.name missing: ${pipelinePath}`); } const runtimeGitopsScripts = await applyRuntimeGitopsScriptsConfigMap(); const runtimeGitopsGuard = injectRuntimeGitopsGuard(pipeline); const render = summarizeRenderedPipeline(pipeline, renderedPipelineName, runtimeGitopsGuard); const pipelineName = requiredOverlayString("pipelineName"); pipeline.metadata = pipeline.metadata && typeof pipeline.metadata === "object" ? pipeline.metadata : {}; pipeline.metadata.name = pipelineName; const pipelineText = YAML.stringify(pipeline); const applyText = await kubeRequest( "PATCH", `/apis/tekton.dev/v1/namespaces/${encodeURIComponent(tektonNamespace)}/pipelines/${encodeURIComponent(pipelineName)}?fieldManager=${encodeURIComponent(fieldManager)}&force=true`, pipelineText, "application/apply-patch+yaml", ); return { render, runtimeGitopsScripts, apply: summarizeAppliedPipeline(parseJsonObject(applyText), pipelineName, tektonNamespace), }; } async function applyRuntimeGitopsScriptsConfigMap() { const data = {}; for (const name of ["runtime-gitops-observability.mjs", "runtime-gitops-postprocess.mjs", "runtime-gitops-verify.mjs"]) { data[name] = readFileSync(`/etc/unidesk-cicd-branch-follower/${name}`, "utf8"); } const YAML = yamlModule(); const applied = parseJsonObject(await kubeRequest( "PATCH", `/api/v1/namespaces/${encodeURIComponent(tektonNamespace)}/configmaps/${encodeURIComponent(runtimeGitopsConfigMapName)}?fieldManager=${encodeURIComponent(fieldManager)}&force=true`, YAML.stringify({ apiVersion: "v1", kind: "ConfigMap", metadata: { name: runtimeGitopsConfigMapName, namespace: tektonNamespace, labels: { "app.kubernetes.io/name": "hwlab-runtime-gitops-scripts", "app.kubernetes.io/part-of": "unidesk-cicd-branch-follower", "hwlab.pikastech.local/node": overlay.nodeId, "hwlab.pikastech.local/lane": overlay.lane, }, }, data, }), "application/apply-patch+yaml", )); const metadata = recordOrNull(applied?.metadata); return { name: stringOrNull(metadata?.name) || runtimeGitopsConfigMapName, namespace: stringOrNull(metadata?.namespace) || tektonNamespace, resourceVersion: stringOrNull(metadata?.resourceVersion), keyCount: Object.keys(data).length, }; } function yamlModule() { const requireFromDeps = createRequire(path.join(depsDir, "package.json")); return requireFromDeps("yaml"); } function kubeRequest(method, requestPath, body, contentType = "application/json") { const host = requiredEnv("KUBERNETES_SERVICE_HOST"); const port = requiredEnvPositiveNumber("KUBERNETES_SERVICE_PORT"); const token = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/token", "utf8").trim(); const ca = readFileSync("/var/run/secrets/kubernetes.io/serviceaccount/ca.crt"); return new Promise((resolve, reject) => { const payload = typeof body === "string" ? body : JSON.stringify(body); const req = https.request({ host, port, path: requestPath, method, ca, headers: { authorization: `Bearer ${token}`, "content-type": contentType, "content-length": Buffer.byteLength(payload), }, }, (res) => { let text = ""; res.setEncoding("utf8"); res.on("data", (chunk) => { text += chunk; }); res.on("end", () => { if ((res.statusCode || 0) < 200 || (res.statusCode || 0) >= 300) { reject(new Error(`kube api ${method} ${requestPath} status ${res.statusCode}: ${tail(text, 1200)}`)); return; } resolve(text); }); }); req.setTimeout(kubeRequestTimeoutSeconds * 1000, () => req.destroy(new Error(`kube api ${method} ${requestPath} timed out after ${kubeRequestTimeoutSeconds}s`))); req.on("error", reject); req.write(payload); req.end(); }); } 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 requiredEnvPositiveNumber(name) { const value = Number(requiredEnv(name)); if (!Number.isFinite(value) || value <= 0) throw new Error(`${name} must be a positive number`); return Math.floor(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 injectRuntimeGitopsGuard(pipeline) { const tasks = Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks : []; const task = tasks.find((item) => recordOrNull(item)?.name === "gitops-promote"); if (!task) throw new Error("runtime GitOps guard injection failed: gitops-promote task missing"); const taskSpec = recordOrNull(task.taskSpec); if (taskSpec === null) throw new Error("runtime GitOps guard injection failed: gitops-promote taskSpec missing"); const steps = Array.isArray(taskSpec.steps) ? taskSpec.steps : []; const step = steps.find((item) => typeof recordOrNull(item)?.script === "string" && item.script.includes("scripts/gitops-render.mjs")); if (!step) throw new Error("runtime GitOps guard injection failed: gitops-promote render step missing"); const originalScript = String(step.script); let script = patchNoBuildNoRolloutSkip(originalScript); const noBuildSkipPatched = script !== originalScript; const beforeInjection = script; script = injectRuntimeGitopsCommands(script); if (hasNoBuildNoRolloutEarlyExit(script)) { throw new Error("runtime GitOps guard injection failed: no-build-no-rollout-plan early exit remains after patch"); } const postprocessInjected = !beforeInjection.includes("runtime-gitops-postprocess.mjs") && script.includes("runtime-gitops-postprocess.mjs"); const verifyInjected = !beforeInjection.includes("runtime-gitops-verify.mjs") && script.includes("runtime-gitops-verify.mjs"); if (!script.includes("runtime-gitops-postprocess.mjs") || !script.includes("runtime-gitops-verify.mjs")) { throw new Error("runtime GitOps guard injection failed: postprocess/verify commands missing after patch"); } step.script = script; ensureRuntimeGitopsScriptsMount(taskSpec, step); return { present: true, configMapName: runtimeGitopsConfigMapName, stepName: stringOrNull(step.name), noBuildSkipPatched, postprocessInjected, verifyInjected, }; } function patchNoBuildNoRolloutSkip(script) { return String(script).replace( /(^|\n)([ \t]*)echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u, (_match, prefix, indent) => `${prefix}${indent}echo '{"event":"gitops-promote","status":"continuing","reason":"no-build-no-rollout-plan-gitops-verify"}' >&2`, ); } function hasNoBuildNoRolloutEarlyExit(script) { return /echo '\{"event":"gitops-promote","status":"skipped","reason":"no-build-no-rollout-plan"\}'(?:\s*>\s*&2)?\n[ \t]*exit 0(?=\n|$)/u.test(String(script)); } function injectRuntimeGitopsCommands(script) { if (script.includes("runtime-gitops-postprocess.mjs") && script.includes("runtime-gitops-verify.mjs")) return script; const overlayEnv = `UNIDESK_RUNTIME_GITOPS_OVERLAY_B64=${shellSingle(Buffer.from(JSON.stringify(overlay), "utf8").toString("base64"))}`; const postprocess = `${overlayEnv} node /etc/unidesk-cicd-runtime-gitops/runtime-gitops-postprocess.mjs`; const verify = `${overlayEnv} node /etc/unidesk-cicd-runtime-gitops/runtime-gitops-verify.mjs`; return String(script).replace( /(node scripts\/run-bun\.mjs scripts\/gitops-render\.mjs[^\n]*--use-deploy-images[^\n]*)/g, (match) => { if (match.includes("--check")) return verify; return `${match}\n${postprocess}`; }, ); } function ensureRuntimeGitopsScriptsMount(taskSpec, step) { const volumeName = "unidesk-runtime-gitops-scripts"; taskSpec.volumes = Array.isArray(taskSpec.volumes) ? taskSpec.volumes : []; if (!taskSpec.volumes.some((item) => recordOrNull(item)?.name === volumeName)) { taskSpec.volumes.push({ name: volumeName, configMap: { name: runtimeGitopsConfigMapName, defaultMode: 0o755 } }); } step.volumeMounts = Array.isArray(step.volumeMounts) ? step.volumeMounts : []; if (!step.volumeMounts.some((item) => recordOrNull(item)?.name === volumeName)) { step.volumeMounts.push({ name: volumeName, mountPath: "/etc/unidesk-cicd-runtime-gitops", readOnly: true }); } } function shellSingle(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } function summarizeRenderedPipeline(pipeline, pipelineName, runtimeGitopsGuard) { const tasks = Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks : []; const runtimeReady = tasks.find((task) => recordOrNull(task)?.name === "runtime-ready"); return { pipelineName, taskCount: tasks.length, runtimeReadyTask: summarizeRuntimeReadyTask(runtimeReady), runtimeGitopsGuard, }; } function summarizeRuntimeReadyTask(value) { const task = recordOrNull(value); if (task === null) return { present: false, name: null, runAfter: [], when: [] }; return { present: true, name: stringOrNull(task.name), runAfter: compactStringArray(task.runAfter, 4), when: compactWhenList(task.when, 4), }; } function summarizeAppliedPipeline(value, pipelineName, namespace) { const metadata = recordOrNull(value?.metadata); return { pipelineName: stringOrNull(metadata?.name) || pipelineName, namespace: stringOrNull(metadata?.namespace) || namespace, resourceVersion: stringOrNull(metadata?.resourceVersion), annotations: compactMetadataMap(metadata?.annotations, [ "sourceConfig", "ciContract", "policy", "hwlab.pikastech.local/source-commit", "tekton.dev/pipelines.minVersion", ], 6), labels: compactMetadataMap(metadata?.labels, [ "hwlab.pikastech.local/source-commit", "app.kubernetes.io/name", "app.kubernetes.io/part-of", "app.kubernetes.io/component", ], 6), degradedReason: metadata === null ? "apply-response-metadata-missing" : null, }; } function compactMetadataMap(value, preferredKeys, limit) { const record = recordOrNull(value); if (record === null) return null; const output = {}; for (const key of preferredKeys) { const item = stringOrNull(record[key]); if (item === null || output[key] !== undefined) continue; output[key] = item; if (Object.keys(output).length >= limit) return output; } for (const key of Object.keys(record).sort()) { const item = stringOrNull(record[key]); if (item === null || output[key] !== undefined) continue; output[key] = item; if (Object.keys(output).length >= limit) break; } return Object.keys(output).length === 0 ? null : output; } function compactStringArray(value, limit) { return Array.isArray(value) ? value.map((item) => stringOrNull(item)).filter(Boolean).slice(0, limit) : []; } function compactWhenList(value, limit) { return Array.isArray(value) ? value.map((item) => recordOrNull(item)).filter(Boolean).slice(0, limit).map((item) => ({ input: stringOrNull(item.input), operator: stringOrNull(item.operator), values: compactStringArray(item.values, 4), })) : []; } function parseJsonObject(text) { try { const parsed = JSON.parse(text); return recordOrNull(parsed); } catch { return null; } } function recordOrNull(value) { return typeof value === "object" && value !== null && !Array.isArray(value) ? value : null; } function stringOrNull(value) { return typeof value === "string" && value.length > 0 ? value : null; } 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`); }