#!/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"; import { runtimeGitopsScriptsConfigMap } from "./runtime-gitops-scripts-configmap.mjs"; 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 = patchNoBuildNoRolloutCondition(originalScript); const noBuildConditionPatched = script !== originalScript; const beforeSkipPatch = script; script = patchNoBuildNoRolloutSkip(script); const noBuildSkipPatched = script !== beforeSkipPatch; const beforeInjection = script; script = injectRuntimeGitopsCommands(script); if (hasWillRunGitopsPromoteCoupling(script)) { throw new Error("runtime GitOps guard injection failed: no-build-no-rollout marker remains coupled to willRunGitopsPromote"); } 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, noBuildConditionPatched, noBuildSkipPatched, postprocessInjected: !hasPostprocess(beforeInjection) && postprocessPresent, verifyInjected: !hasVerify(beforeInjection) && verifyPresent, }; } function patchNoBuildNoRolloutCondition(script) { return String(script).replace( /const willRunGitopsPromote = plan\.ciCdPlan && plan\.ciCdPlan\.willRunGitopsPromote === true;\nprocess\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u, "process.exit(buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);", ); } 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 hasWillRunGitopsPromoteCoupling(script) { return /process\.exit\(!willRunGitopsPromote && buildServices\.length === 0 && affectedServices\.length === 0 \? 0 : 1\);/u.test(String(script)); } 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) { const overlayEnv = "UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json"; const migratedScript = String(script).replace(/UNIDESK_RUNTIME_GITOPS_OVERLAY_B64='[^'\n]*'/gu, overlayEnv); if (hasPostprocess(migratedScript) && hasVerify(migratedScript)) return migratedScript; 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 migratedScript.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 configMap = runtimeGitopsScriptsConfigMap({ overlay, scriptsDir, namespace, configMapName }); writeFileSync(targetPath, `${YAML.stringify(configMap).trimEnd()}\n`, "utf8"); return { name: configMapName, namespace, keyCount: Object.keys(configMap.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 requireYaml() { try { return requireFromScript("yaml"); } catch { return requireFromCwd("yaml"); } }