diff --git a/config/unidesk-host-k8s.yaml b/config/unidesk-host-k8s.yaml index 0e20c7b0..2d9a28ea 100644 --- a/config/unidesk-host-k8s.yaml +++ b/config/unidesk-host-k8s.yaml @@ -23,12 +23,20 @@ delivery: changeDetection: runtimePaths: - .tekton/unidesk-host-pac.yaml + - config/hwlab-node-lanes.yaml - config/platform-infra/pipelines-as-code.yaml - config/unidesk-host-k8s.yaml - scripts/native/cicd/build-unidesk-host-image.sh - scripts/native/cicd/prepare-unidesk-host-release.mjs - scripts/native/cicd/publish-unidesk-host-gitops.mjs - scripts/native/deploy/render-unidesk-host-service.mjs + - scripts/native/hwlab/runtime-gitops-observability.mjs + - scripts/native/hwlab/runtime-gitops-postprocess.mjs + - scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs + - scripts/native/hwlab/runtime-gitops-verify.mjs + - scripts/src/config.ts + - scripts/src/hwlab-node-lanes.ts + - scripts/src/yaml-composition.ts - src/components/microservices/todo-note/Dockerfile - src/components/microservices/todo-note/bun.lock - src/components/microservices/todo-note/package.json diff --git a/scripts/native/cicd/publish-unidesk-host-gitops.mjs b/scripts/native/cicd/publish-unidesk-host-gitops.mjs index 7a9d6fce..acb100b7 100755 --- a/scripts/native/cicd/publish-unidesk-host-gitops.mjs +++ b/scripts/native/cicd/publish-unidesk-host-gitops.mjs @@ -143,7 +143,7 @@ const writeUrl = required(gitops.writeUrl, "delivery.gitops.writeUrl"); const branch = required(gitops.branch, "delivery.gitops.branch"); const manifestPath = safeManifestPath(gitops.manifestPath); const releaseStatePath = safeReleaseStatePath(gitops.releaseStatePath); -const gitopsResources = renderGitOpsResources(gitops, sourceRoot); +const gitopsResources = action === "skip" ? [] : renderGitOpsResources(gitops, sourceRoot); let digest = null; let digestRef = null; let manifest = null; @@ -190,6 +190,26 @@ if (action === "skip") { digest = existingState.digest; digestRef = existingState.digestRef; runtimeSourceCommit = existingState.sourceCommit; + const gitopsCommit = run("git", ["rev-parse", "HEAD"], worktree).stdout.trim(); + process.stdout.write(`${JSON.stringify({ + ok: true, + phase: "gitops-publish", + action, + reason, + status: "skipped", + imageStatus: "skipped", + sourceCommit, + baselineSourceCommit, + runtimeSourceCommit, + digest, + digestRef, + gitopsCommit, + resources: [], + changed: false, + pushAttempts: 0, + valuesPrinted: false, + })}\n`); + return; } else if (action === "build" && manifest !== null) { mkdirSync(dirname(targetPath), { recursive: true }); writeFileSync(targetPath, manifest, "utf8"); diff --git a/scripts/native/cicd/publish-unidesk-host-gitops.test.ts b/scripts/native/cicd/publish-unidesk-host-gitops.test.ts new file mode 100644 index 00000000..98c66a9d --- /dev/null +++ b/scripts/native/cicd/publish-unidesk-host-gitops.test.ts @@ -0,0 +1,115 @@ +import { afterEach, describe, expect, test } from "bun:test"; +import { spawnSync } from "node:child_process"; +import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs"; +import { tmpdir } from "node:os"; +import { dirname, join } from "node:path"; + +import { rootPath } from "../../src/config"; + +const temporaryRoots: string[] = []; + +afterEach(() => { + for (const root of temporaryRoots.splice(0)) rmSync(root, { recursive: true, force: true }); +}); + +describe("UniDesk Host GitOps publisher", () => { + test("skip preserves the GitOps branch and every managed resource", () => { + const root = mkdtempSync(join(tmpdir(), "unidesk-host-gitops-skip-")); + temporaryRoots.push(root); + const seed = join(root, "seed"); + const bare = join(root, "gitops.git"); + const releaseDir = join(root, "release"); + const publisherWorktree = join(root, "publisher"); + const configPath = join(root, "config.yaml"); + const statePath = "deploy/gitops-state/unidesk-host/todo-note.json"; + const resourcePath = "deploy/gitops/unidesk-host/hwlab-runtime-gitops-scripts.yaml"; + const runtimeSourceCommit = "a".repeat(40); + const sourceCommit = "b".repeat(40); + const digest = `sha256:${"c".repeat(64)}`; + + mkdirSync(seed); + git(["init", "-b", "unidesk-host-gitops"], seed); + identity(seed); + write(seed, "deploy/gitops/unidesk-host/todo-note.yaml", "existing service manifest\n"); + write(seed, resourcePath, "stale configmap must remain unchanged\n"); + write(seed, statePath, `${JSON.stringify({ + version: 1, + kind: "UniDeskHostReleaseState", + serviceRef: "services.todoNote", + sourceCommit: runtimeSourceCommit, + digest, + digestRef: `registry.example/todo-note@${digest}`, + }, null, 2)}\n`); + git(["add", "."], seed); + git(["commit", "-m", "seed gitops"], seed); + const originalHead = git(["rev-parse", "HEAD"], seed).stdout.trim(); + git(["init", "--bare", bare], root); + git(["remote", "add", "origin", `file://${bare}`], seed); + git(["push", "origin", "unidesk-host-gitops"], seed); + + mkdirSync(releaseDir); + writeFileSync(join(releaseDir, "action"), "skip\n"); + writeFileSync(join(releaseDir, "reason"), "runtime-inputs-unchanged\n"); + writeFileSync(join(releaseDir, "baseline-source-commit"), `${runtimeSourceCommit}\n`); + writeFileSync(configPath, Bun.YAML.stringify({ + delivery: { + enabled: true, + serviceRef: "services.todoNote", + image: { repository: "registry.example/todo-note" }, + gitops: { + readUrl: `file://${bare}`, + writeUrl: `file://${bare}`, + branch: "unidesk-host-gitops", + manifestPath: "deploy/gitops/unidesk-host/todo-note.yaml", + releaseStatePath: statePath, + resources: [{ + id: "hwlab-runtime-gitops-scripts", + renderer: "hwlab-runtime-gitops-scripts", + configRef: "config/hwlab-node-lanes.yaml#lanes.v03.targets.NC01", + manifestPath: resourcePath, + namespace: "hwlab-ci", + }], + author: { name: "UniDesk Test", email: "unidesk-test@example.invalid" }, + }, + }, + })); + + const result = spawnSync("bun", [ + "scripts/native/cicd/publish-unidesk-host-gitops.mjs", + "--config", configPath, + "--source-root", rootPath(), + "--metadata", join(root, "unused-metadata.json"), + "--release-dir", releaseDir, + "--source-commit", sourceCommit, + "--worktree", publisherWorktree, + ], { cwd: rootPath(), encoding: "utf8", timeout: 30_000 }); + expect(result.status, result.stderr).toBe(0); + const output = JSON.parse(result.stdout) as Record; + expect(output).toMatchObject({ action: "skip", status: "skipped", changed: false, pushAttempts: 0, resources: [] }); + expect(output.gitopsCommit).toBe(originalHead); + expect(git(["rev-parse", "refs/heads/unidesk-host-gitops"], bare).stdout.trim()).toBe(originalHead); + expect(show(bare, resourcePath)).toBe("stale configmap must remain unchanged\n"); + expect(JSON.parse(show(bare, statePath)).sourceCommit).toBe(runtimeSourceCommit); + }); +}); + +function write(root: string, relativePath: string, content: string) { + const path = join(root, relativePath); + mkdirSync(dirname(path), { recursive: true }); + writeFileSync(path, content); +} + +function identity(root: string) { + git(["config", "user.name", "UniDesk Test"], root); + git(["config", "user.email", "unidesk-test@example.invalid"], root); +} + +function show(bare: string, relativePath: string): string { + return git(["show", `refs/heads/unidesk-host-gitops:${relativePath}`], bare).stdout; +} + +function git(args: string[], cwd: string) { + const result = spawnSync("git", args, { cwd, encoding: "utf8" }); + if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed: ${result.stderr || result.stdout}`); + return result; +} 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 5992a1f6..e4cc2814 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 @@ -316,6 +316,17 @@ describe("HWLAB YAML-owned Pipeline provenance", () => { "runtime-gitops-verify.mjs", ]); const hostConfig = Bun.YAML.parse(readFileSync(rootPath("config", "unidesk-host-k8s.yaml"), "utf8")) as Record; + const runtimePaths = new Set(hostConfig.delivery.changeDetection.runtimePaths as string[]); + for (const inputPath of [ + "config/hwlab-node-lanes.yaml", + "scripts/native/hwlab/runtime-gitops-observability.mjs", + "scripts/native/hwlab/runtime-gitops-postprocess.mjs", + "scripts/native/hwlab/runtime-gitops-scripts-configmap.mjs", + "scripts/native/hwlab/runtime-gitops-verify.mjs", + "scripts/src/config.ts", + "scripts/src/hwlab-node-lanes.ts", + "scripts/src/yaml-composition.ts", + ]) expect(runtimePaths.has(inputPath), inputPath).toBe(true); expect(hostConfig.delivery.gitops.resources).toContainEqual({ id: "hwlab-nc01-v03-runtime-gitops-scripts", renderer: "hwlab-runtime-gitops-scripts",