diff --git a/.tekton/hwlab-nc01-v03-pac.yaml b/.tekton/hwlab-nc01-v03-pac.yaml index e3528604..0f884c0f 100644 --- a/.tekton/hwlab-nc01-v03-pac.yaml +++ b/.tekton/hwlab-nc01-v03-pac.yaml @@ -9,7 +9,7 @@ metadata: pipelinesascode.tekton.dev/on-cel-expression: "event == 'push' && target_branch == 'v0.3' && node == 'NC01'" pipelinesascode.tekton.dev/max-keep-runs: "8" unidesk.ai/owning-config-ref: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01" - unidesk.ai/effective-config-sha256: sha256:b132b19c115bb457e6ed7fc02d281aac0f6758208afa37977e114adaf1673e36 + unidesk.ai/effective-config-sha256: sha256:01b954f394174302b5ebce2de98a24d091be1c5922fbc188ef5af72ff1bb569c unidesk.ai/source-artifact-renderer: hwlab-runtime-lane unidesk.ai/source-artifact-mode: remote-pipeline-annotation pipelinesascode.tekton.dev/pipeline: ci/pipelines/hwlab-nc01-v03-ci-image-publish.yaml @@ -86,3 +86,11 @@ spec: value: registry - name: runtime-ready-timeout-ms value: "60000" + - name: release-base-commit + value: "{{ body.before }}" + - name: release-target + value: NC01 + - name: release-consumer + value: hwlab-nc01-v03 + - name: reviewed-plan + value: "{{ body.head_commit.message }}" diff --git a/ci/pipelines/hwlab-nc01-v03-ci-image-publish.yaml b/ci/pipelines/hwlab-nc01-v03-ci-image-publish.yaml index 7a6c1eba..09656c6c 100644 --- a/ci/pipelines/hwlab-nc01-v03-ci-image-publish.yaml +++ b/ci/pipelines/hwlab-nc01-v03-ci-image-publish.yaml @@ -19,7 +19,7 @@ metadata: unidesk.ai/ci-build-batch-count: "9" unidesk.ai/ci-build-max-parallel: "2" unidesk.ai/owning-config-ref: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01" - unidesk.ai/effective-config-sha256: sha256:b132b19c115bb457e6ed7fc02d281aac0f6758208afa37977e114adaf1673e36 + unidesk.ai/effective-config-sha256: sha256:01b954f394174302b5ebce2de98a24d091be1c5922fbc188ef5af72ff1bb569c unidesk.ai/source-artifact-renderer: hwlab-runtime-lane unidesk.ai/source-artifact-mode: remote-pipeline-annotation spec: @@ -69,6 +69,18 @@ spec: - name: runtime-ready-timeout-ms type: string default: "60000" + - name: release-base-commit + type: string + description: Reviewed release plan base commit. + - name: release-target + type: string + description: Reviewed release target. + - name: release-consumer + type: string + description: Reviewed release consumer. + - name: reviewed-plan + type: string + description: Reviewed release plan envelope supplied by the manual webhook. workspaces: - name: source - name: git-ssh @@ -348,6 +360,10 @@ spec: - name: catalog-path - name: registry-prefix - name: services + - name: release-base-commit + - name: release-target + - name: release-consumer + - name: reviewed-plan results: - name: affected-hwlab-cloud-api description: hwlab-cloud-api rollout affected according to ci-plan @@ -455,7 +471,7 @@ spec: value: /tekton/home - name: XDG_CONFIG_HOME value: /tekton/home/.config - script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\necho '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"image\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"policy\":\"no-runtime-apk\"}'\nfor tool in node git; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\ncd /workspace/source/repo\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps/yaml\" ] && [ ! -e node_modules/yaml ]; then\n mkdir -p node_modules\n ln -s \"$ci_node_deps/yaml\" node_modules/yaml\nfi\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nHWLAB_CATALOG_PATH=\"$(params.catalog-path)\" HWLAB_GIT_URL=\"$(params.git-url)\" HWLAB_GIT_READ_URL=\"$(params.git-read-url)\" HWLAB_GITOPS_BRANCH=\"$(params.gitops-branch)\" HWLAB_SERVICES=\"$(params.services)\" node scripts/ci/restore-artifact-catalog.mjs\nnode scripts/ci-plan.mjs --lane \"$(params.lane)\" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog \"$(params.catalog-path)\" --registry-prefix \"$(params.registry-prefix)\" --services \"$(params.services)\" --verify-reuse-registry > /workspace/source/ci-plan.json\nnode - <<'NODE'\nconst fs = require(\"node:fs\");\nconst plan = JSON.parse(fs.readFileSync(\"/workspace/source/ci-plan.json\", \"utf8\"));\nconst selected = String(process.env.HWLAB_SELECTED_SERVICES || \"\").split(\",\").map((item) => item.trim()).filter(Boolean);\nconst allServices = selected.length > 0 ? selected : [\"hwlab-cloud-api\",\"hwlab-cloud-web\",\"hwlab-gateway\",\"hwlab-edge-proxy\",\"hwlab-agent-skills\"];\nconst affected = new Set(plan.affectedServices || []);\nconst plannedBuildServices = Array.isArray(plan.buildServices) ? new Set(plan.buildServices) : null;\nconst selectedSet = new Set(selected);\nconst byService = new Map((plan.services || []).map((service) => [service.serviceId, service]));\nconst entries = allServices.map((serviceId) => {\n const service = byService.get(serviceId) || {};\n const serviceSelected = selectedSet.has(serviceId);\n const rolloutAffected = serviceSelected && affected.has(serviceId);\n const envReuse = service.runtimeMode === \"env-reuse-git-mirror-checkout\" || service.envReuse === true;\n const buildRequired = serviceSelected && (plannedBuildServices ? plannedBuildServices.has(serviceId) : (envReuse ? service.envChanged === true : rolloutAffected));\n return {\n serviceId,\n selected: serviceSelected,\n affected: rolloutAffected,\n buildRequired,\n rolloutAffected,\n runtimeMode: service.runtimeMode || \"service-image\",\n envChanged: service.envChanged ?? null,\n codeChanged: service.codeChanged ?? null\n };\n});\nfor (const entry of entries) {\n fs.writeFileSync(\"/tekton/results/affected-\" + entry.serviceId, entry.affected ? \"true\" : \"false\");\n fs.writeFileSync(\"/tekton/results/build-\" + entry.serviceId, entry.buildRequired ? \"true\" : \"false\");\n}\nfs.writeFileSync(\"/workspace/source/affected-services.json\", JSON.stringify({\n sourceCommitId: plan.sourceCommitId,\n affectedServices: plan.affectedServices || [],\n rolloutServices: plan.rolloutServices || plan.affectedServices || [],\n buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId),\n rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [],\n reusedServices: plan.reusedServices || [],\n serviceReusedCount: plan.serviceReusedCount || 0,\n imageBuildSkippedServices: plan.imageBuildSkippedServices || [],\n buildSkippedCount: plan.buildSkippedCount || 0,\n artifactCatalog: plan.artifactCatalog || null,\n envArtifactGroups: plan.envArtifactGroups || [],\n changedPathSummary: plan.changedPathSummary || null,\n ciCdPlan: plan.ciCdPlan || null,\n services: plan.services || [],\n entries\n}, null, 2) + String.fromCharCode(10));\nconsole.log(JSON.stringify({ event: \"g14-ci-plan\", sourceCommitId: plan.sourceCommitId, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [], reusedServices: plan.reusedServices || [], serviceReusedCount: plan.serviceReusedCount || 0, imageBuildSkippedServices: plan.imageBuildSkippedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, artifactCatalog: plan.artifactCatalog || null, noImageBuildReason: plan.ciCdPlan?.noImageBuildReason || null, envArtifactGroups: plan.envArtifactGroups || [] }));\nNODE\n" + script: "# unidesk-step-env-bootstrap\nexport HOME='/tekton/home'\nexport XDG_CONFIG_HOME='/tekton/home/.config'\nmkdir -p \"$HOME\"\nmkdir -p \"$XDG_CONFIG_HOME\"\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps\" ]; then\n if [ -d /workspace/source/repo ]; then ci_node_deps_target=/workspace/source/repo/node_modules; else ci_node_deps_target=./node_modules; fi\n mkdir -p \"$ci_node_deps_target\"\n for ci_node_dep in yaml; do if [ -d \"$ci_node_deps/$ci_node_dep\" ] && [ ! -e \"$ci_node_deps_target/$ci_node_dep\" ]; then ln -s \"$ci_node_deps/$ci_node_dep\" \"$ci_node_deps_target/$ci_node_dep\"; fi; done\nfi\n#!/bin/sh\nset -eu\necho '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"image\":\"127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1\",\"policy\":\"no-runtime-apk\"}'\nfor tool in node git; do command -v \"$tool\" >/dev/null 2>&1 || { echo '{\"event\":\"ci-base-image\",\"phase\":\"plan-artifacts\",\"ok\":false,\"reason\":\"missing-tool\",\"tool\":\"'\"$tool\"'\"}'; exit 31; }; done\ncd /workspace/source/repo\nci_node_deps=\"${HWLAB_CI_NODE_DEPS:-/opt/hwlab-ci-node-deps/node_modules}\"\nif [ -d \"$ci_node_deps/yaml\" ] && [ ! -e node_modules/yaml ]; then\n mkdir -p node_modules\n ln -s \"$ci_node_deps/yaml\" node_modules/yaml\nfi\ntest \"$(git rev-parse HEAD)\" = \"$(params.revision)\"\nHWLAB_CATALOG_PATH=\"$(params.catalog-path)\" HWLAB_GIT_URL=\"$(params.git-url)\" HWLAB_GIT_READ_URL=\"$(params.git-read-url)\" HWLAB_GITOPS_BRANCH=\"$(params.gitops-branch)\" HWLAB_SERVICES=\"$(params.services)\" node scripts/ci/restore-artifact-catalog.mjs\nnode scripts/ci-plan.mjs --lane \"$(params.lane)\" --base-ref \"$(params.release-base-commit)\" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog \"$(params.catalog-path)\" --registry-prefix \"$(params.registry-prefix)\" --services \"$(params.services)\" --release-target \"$(params.release-target)\" --release-consumer \"$(params.release-consumer)\" --verify-reuse-registry > /workspace/source/ci-plan.json\nnode scripts/ci/verify-reviewed-plan.mjs --actual-plan /workspace/source/ci-plan.json --reviewed-plan \"$(params.reviewed-plan)\"\nnode - <<'NODE'\nconst fs = require(\"node:fs\");\nconst plan = JSON.parse(fs.readFileSync(\"/workspace/source/ci-plan.json\", \"utf8\"));\nconst selected = String(process.env.HWLAB_SELECTED_SERVICES || \"\").split(\",\").map((item) => item.trim()).filter(Boolean);\nconst allServices = selected.length > 0 ? selected : [\"hwlab-cloud-api\",\"hwlab-cloud-web\",\"hwlab-gateway\",\"hwlab-edge-proxy\",\"hwlab-agent-skills\"];\nconst affected = new Set(plan.affectedServices || []);\nconst plannedBuildServices = Array.isArray(plan.buildServices) ? new Set(plan.buildServices) : null;\nconst selectedSet = new Set(selected);\nconst byService = new Map((plan.services || []).map((service) => [service.serviceId, service]));\nconst entries = allServices.map((serviceId) => {\n const service = byService.get(serviceId) || {};\n const serviceSelected = selectedSet.has(serviceId);\n const rolloutAffected = serviceSelected && affected.has(serviceId);\n const envReuse = service.runtimeMode === \"env-reuse-git-mirror-checkout\" || service.envReuse === true;\n const buildRequired = serviceSelected && (plannedBuildServices ? plannedBuildServices.has(serviceId) : (envReuse ? service.envChanged === true : rolloutAffected));\n return {\n serviceId,\n selected: serviceSelected,\n affected: rolloutAffected,\n buildRequired,\n rolloutAffected,\n runtimeMode: service.runtimeMode || \"service-image\",\n envChanged: service.envChanged ?? null,\n codeChanged: service.codeChanged ?? null\n };\n});\nfor (const entry of entries) {\n fs.writeFileSync(\"/tekton/results/affected-\" + entry.serviceId, entry.affected ? \"true\" : \"false\");\n fs.writeFileSync(\"/tekton/results/build-\" + entry.serviceId, entry.buildRequired ? \"true\" : \"false\");\n}\nfs.writeFileSync(\"/workspace/source/affected-services.json\", JSON.stringify({\n sourceCommitId: plan.sourceCommitId,\n planIdentity: plan.planIdentity || null,\n affectedServices: plan.affectedServices || [],\n rolloutServices: plan.rolloutServices || plan.affectedServices || [],\n buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId),\n rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [],\n reusedServices: plan.reusedServices || [],\n serviceReusedCount: plan.serviceReusedCount || 0,\n imageBuildSkippedServices: plan.imageBuildSkippedServices || [],\n buildSkippedCount: plan.buildSkippedCount || 0,\n artifactCatalog: plan.artifactCatalog || null,\n envArtifactGroups: plan.envArtifactGroups || [],\n changedPathSummary: plan.changedPathSummary || null,\n ciCdPlan: plan.ciCdPlan || null,\n services: plan.services || [],\n entries\n}, null, 2) + String.fromCharCode(10));\nconsole.log(JSON.stringify({ event: \"g14-ci-plan\", sourceCommitId: plan.sourceCommitId, planIdentity: plan.planIdentity || null, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [], reusedServices: plan.reusedServices || [], serviceReusedCount: plan.serviceReusedCount || 0, imageBuildSkippedServices: plan.imageBuildSkippedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, artifactCatalog: plan.artifactCatalog || null, noImageBuildReason: plan.ciCdPlan?.noImageBuildReason || null, envArtifactGroups: plan.envArtifactGroups || [] }));\nNODE\n" imagePullPolicy: Always params: - name: git-url @@ -474,6 +490,14 @@ spec: value: $(params.registry-prefix) - name: services value: $(params.services) + - name: release-base-commit + value: $(params.release-base-commit) + - name: release-target + value: $(params.release-target) + - name: release-consumer + value: $(params.release-consumer) + - name: reviewed-plan + value: $(params.reviewed-plan) - name: build-services runAfter: - plan-artifacts diff --git a/scripts/ci-plan-identity.test.mjs b/scripts/ci-plan-identity.test.mjs new file mode 100644 index 00000000..7747eb3a --- /dev/null +++ b/scripts/ci-plan-identity.test.mjs @@ -0,0 +1,91 @@ +import assert from "node:assert/strict"; +import test from "node:test"; + +import { attachCiPlanIdentity } from "./src/ci-plan-identity.mjs"; +import { compareReviewedPlan, decodeReviewedPlan } from "./ci/verify-reviewed-plan.mjs"; + +const sourceCommit = "a".repeat(40); +const baseCommit = "b".repeat(40); + +test("plan identity is stable across service and scope ordering", () => { + const left = identifiedPlan({ + services: [registryService("worker"), registryService("api")], + buildServices: ["worker", "api"] + }); + const right = identifiedPlan({ + services: [registryService("api"), registryService("worker")], + buildServices: ["api", "worker"] + }); + assert.equal(left.planIdentity.value, right.planIdentity.value); +}); + +test("registry probe result and build scope change plan identity", () => { + const reused = identifiedPlan({ services: [registryService("api", "present")] }); + const missing = identifiedPlan({ + services: [registryService("api", "missing")], + buildServices: ["api"], + rolloutServices: ["api"], + affectedServices: ["api"] + }); + assert.notEqual(reused.planIdentity.value, missing.planIdentity.value); +}); + +test("reviewed plan admission rejects scope additions before build", () => { + const expectedPlan = identifiedPlan({ rolloutServices: ["api"], affectedServices: ["api"] }); + const actualPlan = identifiedPlan({ + buildServices: ["worker"], + rolloutServices: ["api", "worker"], + affectedServices: ["api", "worker"] + }); + const result = compareReviewedPlan(envelope(expectedPlan), actualPlan); + assert.equal(result.ok, false); + assert.equal(result.scopeExpanded, true); + assert.deepEqual(result.additions.buildServices, ["worker"]); + assert.deepEqual(result.additions.rolloutServices, ["worker"]); +}); + +test("reviewed plan envelope round trips without a second authority", () => { + const plan = identifiedPlan({ rolloutServices: ["api"], affectedServices: ["api"] }); + const expected = envelope(plan); + const encoded = `unidesk-release-plan-v1.${Buffer.from(JSON.stringify(expected)).toString("base64url")}`; + assert.deepEqual(decodeReviewedPlan(encoded), expected); + assert.equal(compareReviewedPlan(expected, plan).ok, true); +}); + +function identifiedPlan(overrides = {}) { + return attachCiPlanIdentity({ + sourceCommitId: sourceCommit, + baseCommitId: baseCommit, + compatibility: { lane: "v03" }, + inputs: { verifyReuseRegistry: true }, + artifactCatalog: { + authority: "gitops-branch", + catalogSourceCommitId: baseCommit, + catalogSha256: "sha256:catalog", + gitopsCommitId: "c".repeat(40) + }, + services: [], + buildServices: [], + rolloutServices: [], + affectedServices: [], + ...overrides + }, { releaseTarget: "NC01", releaseConsumer: "hwlab-nc01-v03" }); +} + +function registryService(serviceId, status = "present") { + return { serviceId, reuseRegistry: { status, image: `registry/${serviceId}:env`, digest: `sha256:${serviceId}` } }; +} + +function envelope(plan) { + return { + version: "v1", + planIdentity: plan.planIdentity.value, + target: "NC01", + consumer: "hwlab-nc01-v03", + sourceCommit, + baseCommit, + buildServices: plan.buildServices, + rolloutServices: plan.rolloutServices, + affectedServices: plan.affectedServices + }; +} diff --git a/scripts/ci-plan.mjs b/scripts/ci-plan.mjs index de9e88eb..d735d0a0 100644 --- a/scripts/ci-plan.mjs +++ b/scripts/ci-plan.mjs @@ -2,6 +2,7 @@ import { execFileSync } from "node:child_process"; import { readArtifactCatalogSummary } from "./src/artifact-catalog-authority.mjs"; +import { attachCiPlanIdentity } from "./src/ci-plan-identity.mjs"; const args = parseArgs(process.argv.slice(2)); @@ -32,16 +33,18 @@ if (noDepsPlan) { noDepsPlan.artifactCatalog = artifactCatalog; noDepsPlan.compatibility.artifactCatalog = artifactCatalog.catalogPath; noDepsPlan.compatibility.artifactCatalogAuthority = artifactCatalog.authority; - process.stdout.write(args.pretty ? `${JSON.stringify(noDepsPlan, null, 2)}\n` : `${JSON.stringify(noDepsPlan)}\n`); + const identifiedPlan = attachCiPlanIdentity(noDepsPlan, args); + process.stdout.write(args.pretty ? `${JSON.stringify(identifiedPlan, null, 2)}\n` : `${JSON.stringify(identifiedPlan)}\n`); process.exit(0); } } const { createCiPlan } = await import("./src/ci-plan-lib.mjs"); -const plan = await createCiPlan(args); +const plan = attachCiPlanIdentity(await createCiPlan(args), args); process.stdout.write(args.pretty ? `${JSON.stringify(plan, null, 2)}\n` : `${JSON.stringify(plan)}\n`); function tryCreateNoDepsPlan(options) { + if (options.verifyReuseRegistry === true) return null; const services = Array.isArray(options.services) ? options.services.filter(Boolean) : []; if (services.length === 0) return null; const targetRef = options.targetRef ?? "HEAD"; @@ -51,10 +54,12 @@ function tryCreateNoDepsPlan(options) { const summary = classifyNoDepsGlobalChange(changedPaths); if (!summary.gitopsOnly && !summary.docsOnly && !summary.testOnly && summary.summary !== "no-source-diff") return null; const sourceCommitId = gitValue(["rev-parse", targetRef]); + const baseCommitId = gitValue(["rev-parse", baseRef]); const shortCommitId = gitValue(["rev-parse", "--short=7", targetRef]); return { planVersion: "v1", sourceCommitId, + baseCommitId, shortCommitId, baseRef, targetRef, @@ -215,6 +220,8 @@ function parseArgs(argv) { else if (arg === "--verify-reuse-registry") parsed.verifyReuseRegistry = true; else if (arg === "--no-verify-reuse-registry") parsed.verifyReuseRegistry = false; else if (arg === "--base-image") parsed.baseImage = readOption(argv, ++index, arg); + else if (arg === "--release-target") parsed.releaseTarget = readOption(argv, ++index, arg); + else if (arg === "--release-consumer") parsed.releaseConsumer = readOption(argv, ++index, arg); else if (arg === "--services") parsed.services = readOption(argv, ++index, arg).split(",").map((item) => item.trim()).filter(Boolean); else if (arg === "--pretty") parsed.pretty = true; else if (arg === "--help" || arg === "-h") parsed.help = true; diff --git a/scripts/ci/verify-reviewed-plan.mjs b/scripts/ci/verify-reviewed-plan.mjs new file mode 100644 index 00000000..5547f3b9 --- /dev/null +++ b/scripts/ci/verify-reviewed-plan.mjs @@ -0,0 +1,103 @@ +#!/usr/bin/env node +import { readFile } from "node:fs/promises"; +import { pathToFileURL } from "node:url"; + +if (import.meta.url === pathToFileURL(process.argv[1]).href) { + const args = parseArgs(process.argv.slice(2)); + const actual = JSON.parse(await readFile(args.actualPlan, "utf8")); + const expected = decodeReviewedPlan(args.reviewedPlan); + const result = compareReviewedPlan(expected, actual); + process.stdout.write(`${JSON.stringify(result)}\n`); + if (!result.ok) process.exit(42); +} + +export function decodeReviewedPlan(value) { + const prefix = "unidesk-release-plan-v1."; + if (!String(value).startsWith(prefix)) throw new Error("reviewed plan envelope is missing or unsupported"); + const decoded = Buffer.from(String(value).slice(prefix.length), "base64url").toString("utf8"); + const plan = JSON.parse(decoded); + if (plan?.version !== "v1") throw new Error("reviewed plan version is unsupported"); + return plan; +} + +export function compareReviewedPlan(expected, actual) { + const actualScope = scope(actual); + const expectedScope = scope(expected); + const expectedIdentity = text(expected?.planIdentity); + const actualIdentity = text(actual?.planIdentity?.value); + const expectedMetadata = { + target: text(expected?.target), + consumer: text(expected?.consumer), + sourceCommit: text(expected?.sourceCommit), + baseCommit: text(expected?.baseCommit) + }; + const actualMetadata = { + target: text(actual?.planIdentity?.inputs?.target), + consumer: text(actual?.planIdentity?.inputs?.consumer), + sourceCommit: text(actual?.sourceCommitId), + baseCommit: text(actual?.baseCommitId) + }; + const additions = { + buildServices: difference(actualScope.buildServices, expectedScope.buildServices), + rolloutServices: difference(actualScope.rolloutServices, expectedScope.rolloutServices), + affectedServices: difference(actualScope.affectedServices, expectedScope.affectedServices) + }; + const identityMatches = Boolean(expectedIdentity) && expectedIdentity === actualIdentity; + const metadataMatches = Object.keys(expectedMetadata).every((key) => expectedMetadata[key] && expectedMetadata[key] === actualMetadata[key]); + const scopeExpanded = Object.values(additions).some((items) => items.length > 0); + return { + event: "reviewed-plan-admission", + ok: identityMatches && metadataMatches && !scopeExpanded, + identityMatches, + metadataMatches, + scopeExpanded, + expectedIdentity, + actualIdentity, + expectedMetadata, + actualMetadata, + expectedScope, + actualScope, + additions, + valuesPrinted: false + }; +} + +function scope(value) { + return { + buildServices: sortedStrings(value?.buildServices ?? value?.scope?.buildServices), + rolloutServices: sortedStrings(value?.rolloutServices ?? value?.scope?.rolloutServices), + affectedServices: sortedStrings(value?.affectedServices ?? value?.scope?.affectedServices) + }; +} + +function difference(actual, expected) { + const expectedSet = new Set(expected); + return actual.filter((item) => !expectedSet.has(item)); +} + +function sortedStrings(value) { + return [...new Set(Array.isArray(value) ? value.map(text).filter(Boolean) : [])].sort(); +} + +function text(value) { + return typeof value === "string" ? value.trim() : ""; +} + +function parseArgs(argv) { + const result = { actualPlan: "", reviewedPlan: "" }; + for (let index = 0; index < argv.length; index += 1) { + const arg = argv[index]; + if (arg === "--actual-plan") result.actualPlan = readOption(argv, ++index, arg); + else if (arg === "--reviewed-plan") result.reviewedPlan = readOption(argv, ++index, arg); + else throw new Error(`unknown argument ${arg}`); + } + if (!result.actualPlan) throw new Error("--actual-plan is required"); + if (!result.reviewedPlan) throw new Error("--reviewed-plan is required"); + return result; +} + +function readOption(argv, index, name) { + const value = argv[index]; + if (!value || value.startsWith("--")) throw new Error(`${name} requires a value`); + return value; +} diff --git a/scripts/gitops-render.test.ts b/scripts/gitops-render.test.ts index 9536c811..a9f229e9 100644 --- a/scripts/gitops-render.test.ts +++ b/scripts/gitops-render.test.ts @@ -240,10 +240,16 @@ test("v02 render follows TypeScript runtime checks and does not self-patch boots assert.match(planArtifactsScript, /rolloutWithoutImageBuildServices/u); assert.match(planArtifactsScript, /serviceReusedCount/u); assert.match(planArtifactsScript, /artifactCatalog: plan\.artifactCatalog/u); + assert.match(planArtifactsScript, /--base-ref "\$\(params\.release-base-commit\)"/u); + assert.match(planArtifactsScript, /verify-reviewed-plan\.mjs/u); assert.ok(planArtifacts.taskSpec.params.some((param) => param.name === "git-read-url")); assert.ok(planArtifacts.taskSpec.params.some((param) => param.name === "gitops-branch")); assert.ok(planArtifacts.params.some((param) => param.name === "lane" && param.value === "$(params.lane)")); assert.ok(pipelineJson.spec.params.some((param) => param.name === "runtime-ready-timeout-ms" && param.default === "60000")); + for (const name of ["release-base-commit", "release-target", "release-consumer", "reviewed-plan"]) { + assert.ok(pipelineJson.spec.params.some((param) => param.name === name)); + assert.ok(planArtifacts.taskSpec.params.some((param) => param.name === name)); + } const gitopsPromote = taskByName(pipelineJson, "gitops-promote"); assert.ok(gitopsPromote.taskSpec.results.some((result) => result.name === "runtime-ready-required")); diff --git a/scripts/src/ci-plan-identity.mjs b/scripts/src/ci-plan-identity.mjs new file mode 100644 index 00000000..fed97896 --- /dev/null +++ b/scripts/src/ci-plan-identity.mjs @@ -0,0 +1,71 @@ +import { createHash } from "node:crypto"; + +export const CI_PLAN_IDENTITY_VERSION = "v1"; + +export function attachCiPlanIdentity(plan, context = {}) { + const canonical = canonicalCiPlanIdentity(plan, context); + const digest = createHash("sha256").update(stableStringify(canonical)).digest("hex"); + return { + ...plan, + planIdentity: { + version: CI_PLAN_IDENTITY_VERSION, + value: `sha256:${digest}`, + inputs: canonical.inputs, + scope: canonical.scope + } + }; +} + +export function canonicalCiPlanIdentity(plan, context = {}) { + const services = Array.isArray(plan?.services) ? plan.services : []; + return { + version: CI_PLAN_IDENTITY_VERSION, + inputs: { + target: text(context.releaseTarget), + consumer: text(context.releaseConsumer), + lane: text(plan?.compatibility?.lane), + sourceCommitId: text(plan?.sourceCommitId), + baseCommitId: text(plan?.baseCommitId), + catalog: { + authority: text(plan?.artifactCatalog?.authority), + sourceCommitId: text(plan?.artifactCatalog?.catalogSourceCommitId), + digest: text(plan?.artifactCatalog?.catalogSha256), + gitopsCommitId: text(plan?.artifactCatalog?.gitopsCommitId) + }, + registryProbe: { + enabled: plan?.inputs?.verifyReuseRegistry === true, + services: services.map((service) => ({ + serviceId: text(service?.serviceId), + status: text(service?.reuseRegistry?.status), + image: text(service?.reuseRegistry?.image), + digest: text(service?.reuseRegistry?.digest) + })).filter((service) => service.serviceId).sort(compareServiceId) + } + }, + scope: { + buildServices: sortedStrings(plan?.buildServices), + rolloutServices: sortedStrings(plan?.rolloutServices), + affectedServices: sortedStrings(plan?.affectedServices) + } + }; +} + +export function stableStringify(value) { + if (Array.isArray(value)) return `[${value.map(stableStringify).join(",")}]`; + if (value && typeof value === "object") { + return `{${Object.keys(value).sort().map((key) => `${JSON.stringify(key)}:${stableStringify(value[key])}`).join(",")}}`; + } + return JSON.stringify(value); +} + +function sortedStrings(value) { + return [...new Set(Array.isArray(value) ? value.map(text).filter(Boolean) : [])].sort(); +} + +function compareServiceId(left, right) { + return left.serviceId.localeCompare(right.serviceId); +} + +function text(value) { + return typeof value === "string" ? value.trim() : ""; +} diff --git a/scripts/src/ci-plan-lib.mjs b/scripts/src/ci-plan-lib.mjs index 08c784dc..a88d9809 100644 --- a/scripts/src/ci-plan-lib.mjs +++ b/scripts/src/ci-plan-lib.mjs @@ -109,6 +109,7 @@ export async function createCiPlan(options = {}) { const runtimeReuseConfig = await readStructuredFileIfPresent(repoRoot, runtimeReuseConfigPath, null); const runtimeReuseByService = runtimeReuseServices(runtimeReuseConfig); const sourceCommitId = await gitValue(repoRoot, ["rev-parse", targetRef]); + const baseCommitId = await gitValue(repoRoot, ["rev-parse", baseRef]); const shortCommitId = await gitValue(repoRoot, ["rev-parse", "--short=7", targetRef]); const changedPaths = await changedPathsBetween(repoRoot, baseRef, targetRef); const normalizedChangedPaths = changedPaths.map(normalizeRepoPath).filter(Boolean); @@ -390,6 +391,7 @@ export async function createCiPlan(options = {}) { return { planVersion: CI_PLAN_VERSION, sourceCommitId, + baseCommitId, shortCommitId, baseRef, targetRef, diff --git a/scripts/src/gitops-render/tekton-manifests.mjs b/scripts/src/gitops-render/tekton-manifests.mjs index 9bfc36d3..b0dfe83f 100644 --- a/scripts/src/gitops-render/tekton-manifests.mjs +++ b/scripts/src/gitops-render/tekton-manifests.mjs @@ -313,7 +313,11 @@ function planArtifactsTask({ serviceIds = defaultServiceIds } = {}) { { name: "revision" }, { name: "catalog-path" }, { name: "registry-prefix" }, - { name: "services" } + { name: "services" }, + { name: "release-base-commit" }, + { name: "release-target" }, + { name: "release-consumer" }, + { name: "reviewed-plan" } ], results: serviceIds.flatMap((serviceId) => [ { name: affectedResultName(serviceId), description: `${serviceId} rollout affected according to ci-plan` }, @@ -340,7 +344,11 @@ function planArtifactsTask({ serviceIds = defaultServiceIds } = {}) { { name: "revision", value: "$(params.revision)" }, { name: "catalog-path", value: "$(params.catalog-path)" }, { name: "registry-prefix", value: "$(params.registry-prefix)" }, - { name: "services", value: "$(params.services)" } + { name: "services", value: "$(params.services)" }, + { name: "release-base-commit", value: "$(params.release-base-commit)" }, + { name: "release-target", value: "$(params.release-target)" }, + { name: "release-consumer", value: "$(params.release-consumer)" }, + { name: "reviewed-plan", value: "$(params.reviewed-plan)" } ] }; } @@ -580,7 +588,11 @@ function tektonPipeline(args = { lane: "node" }, deploy = null) { { name: "services", type: "string", default: servicesParamForLane(args.lane, deploy) }, { name: "base-image", type: "string", default: defaultDevBaseImage }, { name: "build-cache-mode", type: "string", default: "registry" }, - { name: "runtime-ready-timeout-ms", type: "string", default: "60000" } + { name: "runtime-ready-timeout-ms", type: "string", default: "60000" }, + { name: "release-base-commit", type: "string", description: "Reviewed release plan base commit." }, + { name: "release-target", type: "string", description: "Reviewed release target." }, + { name: "release-consumer", type: "string", description: "Reviewed release consumer." }, + { name: "reviewed-plan", type: "string", description: "Reviewed release plan envelope supplied by the manual webhook." } ], workspaces: [ { name: "source" }, diff --git a/scripts/src/gitops-render/templates/plan-artifacts.sh b/scripts/src/gitops-render/templates/plan-artifacts.sh index 522411d8..8aa28861 100644 --- a/scripts/src/gitops-render/templates/plan-artifacts.sh +++ b/scripts/src/gitops-render/templates/plan-artifacts.sh @@ -10,7 +10,8 @@ if [ -d "$ci_node_deps/yaml" ] && [ ! -e node_modules/yaml ]; then fi test "$(git rev-parse HEAD)" = "$(params.revision)" HWLAB_CATALOG_PATH="$(params.catalog-path)" HWLAB_GIT_URL="$(params.git-url)" HWLAB_GIT_READ_URL="$(params.git-read-url)" HWLAB_GITOPS_BRANCH="$(params.gitops-branch)" HWLAB_SERVICES="$(params.services)" node scripts/ci/restore-artifact-catalog.mjs -node scripts/ci-plan.mjs --lane "$(params.lane)" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" --verify-reuse-registry > /workspace/source/ci-plan.json +node scripts/ci-plan.mjs --lane "$(params.lane)" --base-ref "$(params.release-base-commit)" --target-ref HEAD --deploy-config deploy/deploy.yaml --artifact-catalog "$(params.catalog-path)" --registry-prefix "$(params.registry-prefix)" --services "$(params.services)" --release-target "$(params.release-target)" --release-consumer "$(params.release-consumer)" --verify-reuse-registry > /workspace/source/ci-plan.json +node scripts/ci/verify-reviewed-plan.mjs --actual-plan /workspace/source/ci-plan.json --reviewed-plan "$(params.reviewed-plan)" node - <<'NODE' const fs = require("node:fs"); const plan = JSON.parse(fs.readFileSync("/workspace/source/ci-plan.json", "utf8")); @@ -43,6 +44,7 @@ for (const entry of entries) { } fs.writeFileSync("/workspace/source/affected-services.json", JSON.stringify({ sourceCommitId: plan.sourceCommitId, + planIdentity: plan.planIdentity || null, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || entries.filter((entry) => entry.buildRequired).map((entry) => entry.serviceId), @@ -58,5 +60,5 @@ fs.writeFileSync("/workspace/source/affected-services.json", JSON.stringify({ services: plan.services || [], entries }, null, 2) + String.fromCharCode(10)); -console.log(JSON.stringify({ event: "g14-ci-plan", sourceCommitId: plan.sourceCommitId, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [], reusedServices: plan.reusedServices || [], serviceReusedCount: plan.serviceReusedCount || 0, imageBuildSkippedServices: plan.imageBuildSkippedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, artifactCatalog: plan.artifactCatalog || null, noImageBuildReason: plan.ciCdPlan?.noImageBuildReason || null, envArtifactGroups: plan.envArtifactGroups || [] })); +console.log(JSON.stringify({ event: "g14-ci-plan", sourceCommitId: plan.sourceCommitId, planIdentity: plan.planIdentity || null, affectedServices: plan.affectedServices || [], rolloutServices: plan.rolloutServices || plan.affectedServices || [], buildServices: plan.buildServices || [], rolloutWithoutImageBuildServices: plan.rolloutWithoutImageBuildServices || [], reusedServices: plan.reusedServices || [], serviceReusedCount: plan.serviceReusedCount || 0, imageBuildSkippedServices: plan.imageBuildSkippedServices || [], buildSkippedCount: plan.buildSkippedCount || 0, artifactCatalog: plan.artifactCatalog || null, noImageBuildReason: plan.ciCdPlan?.noImageBuildReason || null, envArtifactGroups: plan.envArtifactGroups || [] })); NODE