diff --git a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs index 6abee5df..a590b6fd 100644 --- a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs +++ b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs @@ -15,6 +15,7 @@ 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"); @@ -143,7 +144,8 @@ async function applyPipeline() { if (typeof renderedPipelineName !== "string" || renderedPipelineName.length === 0) { throw new Error(`rendered Pipeline metadata.name missing: ${pipelinePath}`); } - const render = summarizeRenderedPipeline(pipeline, renderedPipelineName); + 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; @@ -286,13 +288,89 @@ function requiredNonNegativeNumber(name) { return Math.floor(value); } -function summarizeRenderedPipeline(pipeline, pipelineName) { +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 `${match}\n${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, }; } diff --git a/scripts/native/hwlab/runtime-gitops-postprocess.mjs b/scripts/native/hwlab/runtime-gitops-postprocess.mjs new file mode 100644 index 00000000..d15d46c0 --- /dev/null +++ b/scripts/native/hwlab/runtime-gitops-postprocess.mjs @@ -0,0 +1,130 @@ +#!/usr/bin/env node +// SPEC: PJ2026-01060703 CI/CD branch follower runtime GitOps postprocess. +// Responsibility: mutate rendered runtime GitOps files after HWLAB source render, before publish. +import { existsSync, readdirSync, readFileSync, statSync, unlinkSync, writeFileSync } from "node:fs"; +import path from "node:path"; + +const repoDir = process.cwd(); +const overlay = readOverlay(); +const runtimePath = requiredOverlayString("runtimePath"); +const runtimeDir = path.resolve(repoDir, runtimePath); +const prometheusOperatorKinds = new Set(["ServiceMonitor", "PrometheusRule", "PodMonitor", "Probe"]); + +if (!existsSync(runtimeDir)) { + emit({ ok: false, reason: "runtime-path-missing", runtimePath }); + process.exit(45); +} + +const result = postprocessRuntimeGitops(); +emit({ ok: true, runtimePath, ...result }); + +function postprocessRuntimeGitops() { + const prometheusOperatorDisabled = overlay?.observability?.prometheusOperator === false; + if (!prometheusOperatorDisabled) { + return { observabilityPrometheusOperator: overlay?.observability?.prometheusOperator ?? null, observabilityWorkloadsChanged: false, kustomizationChanged: false }; + } + const changedFiles = []; + const deletedFiles = []; + for (const file of listYamlFiles(runtimeDir)) { + const changed = stripPrometheusOperatorResourcesFromFile(file); + if (changed === "deleted") deletedFiles.push(path.relative(repoDir, file)); + if (changed === "changed") changedFiles.push(path.relative(repoDir, file)); + } + const kustomizationChanged = pruneMissingKustomizationResources(); + return { + observabilityPrometheusOperator: false, + observabilityWorkloadsChanged: changedFiles.length > 0 || deletedFiles.length > 0, + kustomizationChanged, + changedFiles, + deletedFiles, + }; +} + +function stripPrometheusOperatorResourcesFromFile(file) { + const original = readFileSync(file, "utf8"); + const docs = splitYamlDocuments(original); + const nextDocs = docs.filter((doc) => !isPrometheusOperatorDocument(doc)); + if (nextDocs.length === docs.length) return "unchanged"; + if (nextDocs.length === 0) { + unlinkSync(file); + return "deleted"; + } + writeFileSync(file, `${nextDocs.map((doc) => doc.trimEnd()).join("\n---\n")}\n`, "utf8"); + return "changed"; +} + +function pruneMissingKustomizationResources() { + const file = path.join(runtimeDir, "kustomization.yaml"); + if (!existsSync(file)) return false; + const original = readFileSync(file, "utf8"); + const lines = original.split(/\n/u); + const next = []; + let changed = false; + let inResources = false; + let resourcesIndent = 0; + for (const line of lines) { + const resourcesMatch = line.match(/^(\s*)resources:\s*(?:#.*)?$/u); + if (resourcesMatch) { + inResources = true; + resourcesIndent = resourcesMatch[1].length; + next.push(line); + continue; + } + if (inResources && line.trim() !== "" && !line.match(/^\s*-/u) && leadingSpaces(line) <= resourcesIndent) inResources = false; + if (inResources) { + const item = line.match(/^(\s*)-\s+["']?([^"'#\s]+)["']?\s*(?:#.*)?$/u); + if (item && !existsSync(path.resolve(runtimeDir, item[2]))) { + changed = true; + continue; + } + } + next.push(line); + } + if (changed) writeFileSync(file, next.join("\n"), "utf8"); + return changed; +} + +function splitYamlDocuments(text) { + return text.split(/^---[ \t]*(?:#.*)?$/mu).map((doc) => doc.trim()).filter(Boolean); +} + +function isPrometheusOperatorDocument(text) { + const api = text.match(/^\s*apiVersion:\s*["']?(monitoring\.coreos\.com\/[^"'\s#]+)["']?\s*(?:#.*)?$/mu); + const kind = text.match(/^\s*kind:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/mu); + return Boolean(api && kind && prometheusOperatorKinds.has(kind[1])); +} + +function leadingSpaces(value) { + const match = value.match(/^ */u); + return match ? match[0].length : 0; +} + +function listYamlFiles(root) { + const out = []; + for (const name of readdirSync(root)) { + const file = path.join(root, name); + const stat = statSync(file); + if (stat.isDirectory()) { + out.push(...listYamlFiles(file)); + } else if (/\.(ya?ml)$/u.test(name)) { + out.push(file); + } + } + return out.sort(); +} + +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 requiredOverlayString(name) { + const value = overlay[name]; + if (typeof value !== "string" || value.length === 0) throw new Error(`overlay.${name} is required`); + return value; +} + +function emit(fields) { + console.error(JSON.stringify({ event: "unidesk-runtime-gitops-postprocess", ...fields })); +} diff --git a/scripts/native/hwlab/runtime-gitops-verify.mjs b/scripts/native/hwlab/runtime-gitops-verify.mjs new file mode 100644 index 00000000..b8bbb2ee --- /dev/null +++ b/scripts/native/hwlab/runtime-gitops-verify.mjs @@ -0,0 +1,79 @@ +#!/usr/bin/env node +// SPEC: PJ2026-01060703 CI/CD branch follower runtime GitOps verify. +// Responsibility: fail publish before Argo when rendered runtime GitOps violates UniDesk overlay gates. +import { existsSync, readdirSync, readFileSync, statSync } from "node:fs"; +import path from "node:path"; + +const repoDir = process.cwd(); +const overlay = readOverlay(); +const runtimePath = requiredOverlayString("runtimePath"); +const runtimeDir = path.resolve(repoDir, runtimePath); +const prometheusOperatorKinds = new Set(["ServiceMonitor", "PrometheusRule", "PodMonitor", "Probe"]); + +if (!existsSync(runtimeDir)) { + fail("runtime-path-missing", { runtimePath }); +} + +const checks = []; +if (overlay?.observability?.prometheusOperator === false) { + checks.push("prometheus-operator-disabled"); + const refs = findPrometheusOperatorResources(); + if (refs.length > 0) fail("prometheus-operator-resource-present", { runtimePath, refs: refs.slice(0, 12), refCount: refs.length }); +} + +console.error(JSON.stringify({ event: "unidesk-runtime-gitops-verify", ok: true, runtimePath, checks })); + +function findPrometheusOperatorResources() { + const refs = []; + for (const file of listYamlFiles(runtimeDir)) { + const rel = path.relative(repoDir, file); + for (const doc of splitYamlDocuments(readFileSync(file, "utf8"))) { + const ref = prometheusOperatorResourceRef(doc, rel); + if (ref !== null) refs.push(ref); + } + } + return refs; +} + +function splitYamlDocuments(text) { + return text.split(/^---[ \t]*(?:#.*)?$/mu).map((doc) => doc.trim()).filter(Boolean); +} + +function prometheusOperatorResourceRef(text, file) { + const api = text.match(/^\s*apiVersion:\s*["']?(monitoring\.coreos\.com\/[^"'\s#]+)["']?\s*(?:#.*)?$/mu); + const kind = text.match(/^\s*kind:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/mu); + if (!api || !kind || !prometheusOperatorKinds.has(kind[1])) return null; + const name = text.match(/^\s*name:\s*["']?([^"'\s#]+)["']?\s*(?:#.*)?$/mu); + return { file, kind: kind[1], name: name ? name[1] : null, container: null }; +} + +function listYamlFiles(root) { + const out = []; + for (const name of readdirSync(root)) { + const file = path.join(root, name); + const stat = statSync(file); + if (stat.isDirectory()) { + out.push(...listYamlFiles(file)); + } else if (/\.(ya?ml)$/u.test(name)) { + out.push(file); + } + } + return out.sort(); +} + +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 requiredOverlayString(name) { + const value = overlay[name]; + if (typeof value !== "string" || value.length === 0) throw new Error(`overlay.${name} is required`); + return value; +} + +function fail(reason, extra = {}) { + console.error(JSON.stringify({ event: "unidesk-runtime-gitops-verify", ok: false, reason, ...extra })); + process.exit(48); +} diff --git a/scripts/src/cicd-controller-render.ts b/scripts/src/cicd-controller-render.ts index 4c52a991..79e1c47a 100644 --- a/scripts/src/cicd-controller-render.ts +++ b/scripts/src/cicd-controller-render.ts @@ -164,6 +164,9 @@ export function renderControllerManifests(registry: BranchFollowerRegistry): Rec "hwlab-node-control-plane-refresh.mjs": nativeCicdScript("hwlab-node-control-plane-refresh.mjs"), "github-proxy-connect.mjs": nativeCicdScript("github-proxy-connect.mjs"), "git-ssh-proxy.sh": nativeCicdScript("git-ssh-proxy.sh"), + "runtime-gitops-observability.mjs": nativeHwlabScript("runtime-gitops-observability.mjs"), + "runtime-gitops-postprocess.mjs": nativeHwlabScript("runtime-gitops-postprocess.mjs"), + "runtime-gitops-verify.mjs": nativeHwlabScript("runtime-gitops-verify.mjs"), }; const controllerConfigSha = sha256(JSON.stringify(controllerConfigData)); return [ @@ -302,6 +305,10 @@ function nativeCicdScript(name: string): string { return readFileSync(rootPath("scripts/native/cicd", name), "utf8"); } +function nativeHwlabScript(name: string): string { + return readFileSync(rootPath("scripts/native/hwlab", name), "utf8"); +} + function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } diff --git a/scripts/src/cicd-hwlab-refresh.ts b/scripts/src/cicd-hwlab-refresh.ts index 62fd02eb..97114a9e 100644 --- a/scripts/src/cicd-hwlab-refresh.ts +++ b/scripts/src/cicd-hwlab-refresh.ts @@ -83,6 +83,7 @@ function nativeHwlabControlPlaneRefreshJobManifest( { name: "FIELD_MANAGER", value: spec.controlPlaneFieldManager }, { name: "TEKTON_NAMESPACE", value: tektonNamespace }, { name: "KUBE_REQUEST_TIMEOUT_SECONDS", value: String(timeoutSeconds) }, + { name: "RUNTIME_GITOPS_CONFIGMAP_NAME", value: registry.controller.configMapName }, { name: "HWLAB_RENDER_OVERLAY_B64", value: overlay }, ], }],