diff --git a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs index a068c0c7..5618110f 100644 --- a/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs +++ b/scripts/native/cicd/hwlab-node-control-plane-refresh.mjs @@ -336,10 +336,16 @@ function injectRuntimeGitopsGuard(pipeline) { 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; + 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"); } @@ -354,12 +360,20 @@ function injectRuntimeGitopsGuard(pipeline) { present: true, configMapName: runtimeGitopsConfigMapName, stepName: stringOrNull(step.name), + noBuildConditionPatched, noBuildSkipPatched, postprocessInjected, verifyInjected, }; } +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, @@ -367,6 +381,10 @@ function patchNoBuildNoRolloutSkip(script) { ); } +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)); } diff --git a/scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs b/scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs index 3771da77..2bad4c9a 100644 --- a/scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs +++ b/scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs @@ -54,10 +54,16 @@ function injectRuntimeGitopsGuard(pipeline) { 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; + 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"); } @@ -70,12 +76,20 @@ function injectRuntimeGitopsGuard(pipeline) { 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, @@ -83,6 +97,10 @@ function patchNoBuildNoRolloutSkip(script) { ); } +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)); } diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index e4cc2814..f70caed0 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -281,26 +281,60 @@ describe("HWLAB YAML-owned Pipeline provenance", () => { name: "gitops-promote", taskSpec: { steps: [{ name: "render", - script: "node scripts/run-bun.mjs scripts/gitops-render.mjs --use-deploy-images\nnode scripts/run-bun.mjs scripts/gitops-render.mjs --use-deploy-images --check\n", + script: [ + "if [ -s /workspace/source/affected-services.json ] && node - /workspace/source/affected-services.json <<'NODE'", + "const fs = require(\"node:fs\");", + "const plan = JSON.parse(fs.readFileSync(process.argv[2], \"utf8\"));", + "const buildServices = Array.isArray(plan.buildServices) ? plan.buildServices : [];", + "const affectedServices = Array.isArray(plan.affectedServices) ? plan.affectedServices : [];", + "const willRunGitopsPromote = plan.ciCdPlan && plan.ciCdPlan.willRunGitopsPromote === true;", + "process.exit(!willRunGitopsPromote && buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);", + "NODE", + "then", + " printf 'false' > /tekton/results/runtime-ready-required || true", + " echo '{\"event\":\"gitops-promote\",\"status\":\"skipped\",\"reason\":\"no-build-no-rollout-plan\"}'", + " exit 0", + "fi", + "node scripts/run-bun.mjs scripts/gitops-render.mjs --use-deploy-images", + "node scripts/run-bun.mjs scripts/gitops-render.mjs --use-deploy-images --check", + "", + ].join("\n"), }] }, }] }, })); const overlayBase64 = Buffer.from(JSON.stringify(overlay), "utf8").toString("base64"); + const postprocessInput = [ + "set -eu", + `render_dir=${JSON.stringify(temporary)}`, + `overlay_b64=${JSON.stringify(overlayBase64)}`, + ...nodeRuntimePipelinePostprocessScript(), + ].join("\n"); const result = spawnSync("sh", [], { cwd: rootPath(), - input: [ - "set -eu", - `render_dir=${JSON.stringify(temporary)}`, - `overlay_b64=${JSON.stringify(overlayBase64)}`, - ...nodeRuntimePipelinePostprocessScript(), - ].join("\n"), + input: postprocessInput, encoding: "utf8", timeout: 30_000, }); expect(result.status, result.stderr).toBe(0); - const pipeline = Bun.YAML.parse(readFileSync(pipelinePath, "utf8")) as Record; + const guardedPipelineText = readFileSync(pipelinePath, "utf8"); + const repeated = spawnSync("node", [ + resolve(temporary, ".unidesk-runtime-gitops", "runtime-gitops-pipeline-guard.mjs"), + "--pipeline", pipelinePath, + ], { + cwd: rootPath(), + env: { ...process.env, UNIDESK_RUNTIME_GITOPS_OVERLAY_B64: overlayBase64 }, + encoding: "utf8", + timeout: 30_000, + }); + expect(repeated.status, repeated.stderr).toBe(0); + expect(readFileSync(pipelinePath, "utf8")).toBe(guardedPipelineText); + const pipeline = Bun.YAML.parse(guardedPipelineText) as Record; const scripts = pipeline.spec.tasks.flatMap((task: Record) => task.taskSpec.steps.map((step: Record) => String(step.script ?? ""))); expect(Math.max(...scripts.map((script: string) => Buffer.byteLength(script)))).toBeLessThanOrEqual(budget as number); + expect(scripts.join("\n")).toContain("process.exit(buildServices.length === 0 && affectedServices.length === 0 ? 0 : 1);"); + expect(scripts.join("\n")).not.toContain("!willRunGitopsPromote"); + expect(scripts.join("\n")).toContain("no-build-no-rollout-plan-gitops-verify"); + expect(scripts.join("\n")).not.toContain('"status":"skipped","reason":"no-build-no-rollout-plan"'); expect(scripts.join("\n")).toContain("UNIDESK_RUNTIME_GITOPS_OVERLAY_FILE=/etc/unidesk-cicd-runtime-gitops/runtime-gitops-overlay.json"); expect(scripts.join("\n")).not.toContain("NODE_UNIDESK_RUNTIME_GITOPS_POSTPROCESS"); expect(scripts.join("\n")).not.toContain("NODE_UNIDESK_RUNTIME_GITOPS_VERIFY");