#!/usr/bin/env node // SPEC: PJ2026-01060703 HWLAB runtime GitOps Pipeline guard. // Responsibility: patch rendered Tekton Pipeline GitOps steps to keep runtime GitOps gates active on no-build paths. import { existsSync, readFileSync, writeFileSync } from "node:fs"; import path from "node:path"; import { createRequire } from "node:module"; const requireFromScript = createRequire(import.meta.url); const requireFromCwd = createRequire(path.join(process.cwd(), "package.json")); const YAML = requireYaml(); const args = parseArgs(process.argv.slice(2)); const overlay = readOverlay(); const pipelinePath = requiredArg("pipeline"); const scriptsConfigMapPath = args.scriptsConfigMap ?? args.scriptsConfigmap ?? null; const scriptsDir = args.scriptsDir ?? null; const namespaceArg = args.namespace ?? null; const configMapName = args.configMapName ?? args.configmapName ?? `${requiredOverlayString("pipelineName")}-runtime-gitops-scripts`; if (!existsSync(pipelinePath)) throw new Error(`rendered Pipeline missing: ${pipelinePath}`); const docs = YAML.parseAllDocuments(readFileSync(pipelinePath, "utf8")).map((document) => document.toJS()).filter((doc) => doc !== null); let patched = false; let summary = null; for (const doc of docs) { if (!doc || typeof doc !== "object" || doc.kind !== "Pipeline") continue; summary = injectRuntimeGitopsGuard(doc); patched = true; } if (!patched || summary === null) throw new Error(`rendered Pipeline not found: ${pipelinePath}`); writeFileSync(pipelinePath, `${docs.map((doc) => YAML.stringify(doc).trimEnd()).join("\n---\n")}\n`, "utf8"); let scriptsConfigMap = null; if (scriptsConfigMapPath !== null) { const namespace = namespaceArg || pipelineNamespace(docs) || "hwlab-ci"; scriptsConfigMap = writeScriptsConfigMap(scriptsConfigMapPath, namespace); } console.error(JSON.stringify({ event: "unidesk-runtime-gitops-pipeline-guard", ok: true, pipelinePath, runtimeGitopsGuard: summary, scriptsConfigMap, })); function injectRuntimeGitopsGuard(pipeline) { const tasks = Array.isArray(pipeline?.spec?.tasks) ? pipeline.spec.tasks : []; const task = tasks.find((item) => item && item.name === "gitops-promote"); if (!task) throw new Error("runtime GitOps guard injection failed: gitops-promote task missing"); task.taskSpec = objectOr(task.taskSpec); const steps = Array.isArray(task.taskSpec.steps) ? task.taskSpec.steps : []; const step = steps.find((item) => typeof 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 postprocessPresent = hasPostprocess(script); const verifyPresent = hasVerify(script); if (!postprocessPresent || !verifyPresent) throw new Error("runtime GitOps guard injection failed: postprocess/verify commands missing after patch"); step.script = script; ensureRuntimeGitopsScriptsMount(task.taskSpec, step); return { present: true, configMapName, stepName: typeof step.name === "string" ? step.name : null, noBuildSkipPatched, postprocessInjected: !hasPostprocess(beforeInjection) && postprocessPresent, verifyInjected: !hasVerify(beforeInjection) && verifyPresent, }; } 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 (hasPostprocess(script) && hasVerify(script)) return script; const overlayEnv = `UNIDESK_RUNTIME_GITOPS_OVERLAY_B64=${shellSingle(Buffer.from(JSON.stringify({ runtimePath: overlay.runtimePath, observability: overlay.observability, }), "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 hasVerify(script) ? match : verify; return hasPostprocess(script) ? match : `${match}\n${postprocess}`; }, ); } function hasPostprocess(script) { const value = String(script); return value.includes("runtime-gitops-postprocess.mjs") || value.includes("unidesk-runtime-gitops-postprocess"); } function hasVerify(script) { const value = String(script); return value.includes("runtime-gitops-verify.mjs") || value.includes("unidesk-runtime-gitops-verify"); } function ensureRuntimeGitopsScriptsMount(taskSpec, step) { const volumeName = "unidesk-runtime-gitops-scripts"; taskSpec.volumes = Array.isArray(taskSpec.volumes) ? taskSpec.volumes : []; if (!taskSpec.volumes.some((item) => item && item.name === volumeName)) { taskSpec.volumes.push({ name: volumeName, configMap: { name: configMapName, defaultMode: 0o755 } }); } step.volumeMounts = Array.isArray(step.volumeMounts) ? step.volumeMounts : []; if (!step.volumeMounts.some((item) => item && item.name === volumeName)) { step.volumeMounts.push({ name: volumeName, mountPath: "/etc/unidesk-cicd-runtime-gitops", readOnly: true }); } } function writeScriptsConfigMap(targetPath, namespace) { if (scriptsDir === null) throw new Error("--scripts-dir is required with --scripts-configmap"); const data = {}; for (const name of ["runtime-gitops-observability.mjs", "runtime-gitops-postprocess.mjs", "runtime-gitops-verify.mjs"]) { data[name] = readFileSync(path.join(scriptsDir, name), "utf8"); } writeFileSync(targetPath, `${YAML.stringify({ apiVersion: "v1", kind: "ConfigMap", metadata: { name: configMapName, namespace, labels: { "app.kubernetes.io/name": "hwlab-runtime-gitops-scripts", "app.kubernetes.io/part-of": "unidesk-hwlab-control-plane", "hwlab.pikastech.local/node": overlay.nodeId ?? null, "hwlab.pikastech.local/lane": overlay.lane ?? null, }, }, data, }).trimEnd()}\n`, "utf8"); return { name: configMapName, namespace, keyCount: Object.keys(data).length, path: targetPath }; } function pipelineNamespace(docs) { for (const doc of docs) { const namespace = doc?.metadata?.namespace; if (typeof namespace === "string" && namespace.length > 0) return namespace; } return null; } function readOverlay() { const encoded = process.env.UNIDESK_RUNTIME_GITOPS_OVERLAY_B64; if (!encoded) throw new Error("UNIDESK_RUNTIME_GITOPS_OVERLAY_B64 is required"); return JSON.parse(Buffer.from(encoded, "base64").toString("utf8")); } function parseArgs(values) { const out = {}; for (let index = 0; index < values.length; index += 1) { const value = values[index]; if (!value.startsWith("--")) throw new Error(`unexpected argument: ${value}`); const key = value.slice(2).replace(/-([a-z])/gu, (_match, char) => char.toUpperCase()); const next = values[index + 1]; if (next === undefined || next.startsWith("--")) throw new Error(`${value} requires a value`); out[key] = next; index += 1; } return out; } function requiredArg(name) { const value = args[name]; if (typeof value !== "string" || value.length === 0) 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 objectOr(value) { return value && typeof value === "object" && !Array.isArray(value) ? value : {}; } function shellSingle(value) { return `'${String(value).replaceAll("'", "'\\''")}'`; } function requireYaml() { try { return requireFromScript("yaml"); } catch { return requireFromCwd("yaml"); } }