feat(cicd): generate PaC source artifacts from YAML
This commit is contained in:
@@ -0,0 +1,358 @@
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import { spawnSync } from "node:child_process";
|
||||
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { resolve } from "node:path";
|
||||
|
||||
import { rootPath } from "./config";
|
||||
import { applyNodeRuntimeDeployYamlOverlay, nodeRuntimeDeployYamlOverlayShellScript } from "./hwlab-node/deploy-overlay";
|
||||
import {
|
||||
canonicalSha256,
|
||||
canonicalizePacPipelineSpec,
|
||||
assertHwlabPipelineContracts,
|
||||
parsePacSourceArtifactOptions,
|
||||
} from "./platform-infra-pipelines-as-code-source-artifact";
|
||||
import { renderPacSourceArtifactRuntimeObserverShell } from "./platform-infra-pipelines-as-code";
|
||||
import {
|
||||
canonicalPacPipelineSha256 as nativeCanonicalSha256,
|
||||
canonicalizePacPipelineSpec as nativeCanonicalize,
|
||||
observePacSourceArtifactRuntime,
|
||||
selectPipelineRunBySourceCommit,
|
||||
} from "../native/cicd/pac-source-artifact-runtime.mjs";
|
||||
|
||||
const sourceCommit = "a".repeat(40);
|
||||
const provenance = {
|
||||
configRef: "config/example.yaml#lanes.nc01",
|
||||
effectiveConfigSha256: "sha256:config",
|
||||
renderer: "agentrun-control-plane",
|
||||
};
|
||||
const desiredSpec = { params: [], tasks: [] };
|
||||
|
||||
function manifestAnnotations(): Record<string, string> {
|
||||
return {
|
||||
"unidesk.ai/owning-config-ref": provenance.configRef,
|
||||
"unidesk.ai/effective-config-sha256": provenance.effectiveConfigSha256,
|
||||
"unidesk.ai/source-artifact-renderer": provenance.renderer,
|
||||
};
|
||||
}
|
||||
|
||||
function pipeline(): Record<string, unknown> {
|
||||
return {
|
||||
metadata: { name: "agentrun-nc01-v02-ci-image-publish", annotations: manifestAnnotations() },
|
||||
spec: desiredSpec,
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineRun(name: string, creationTimestamp: string, spec: Record<string, unknown> = desiredSpec): Record<string, unknown> {
|
||||
return {
|
||||
metadata: {
|
||||
name,
|
||||
creationTimestamp,
|
||||
labels: { "pipelinesascode.tekton.dev/sha": sourceCommit },
|
||||
annotations: manifestAnnotations(),
|
||||
},
|
||||
spec: { pipelineSpec: spec },
|
||||
};
|
||||
}
|
||||
|
||||
function runtimeInput(commit: string | null = sourceCommit): Record<string, unknown> {
|
||||
return {
|
||||
namespace: "agentrun-ci",
|
||||
pipeline: "agentrun-nc01-v02-ci-image-publish",
|
||||
pipelineRunPrefix: "agentrun-nc01-v02-ci",
|
||||
desiredSpec,
|
||||
provenance,
|
||||
sourceCommit: commit,
|
||||
};
|
||||
}
|
||||
|
||||
describe("PaC source artifact CLI contract", () => {
|
||||
test("verify-runtime requires and normalizes a full source commit", () => {
|
||||
expect(() => parsePacSourceArtifactOptions([
|
||||
"verify-runtime", "--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", "/tmp/repo",
|
||||
])).toThrow("requires explicit --source-commit");
|
||||
const parsed = parsePacSourceArtifactOptions([
|
||||
"verify-runtime", "--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", "/tmp/repo", "--source-commit", sourceCommit.toUpperCase(),
|
||||
]);
|
||||
expect(parsed.sourceCommit).toBe(sourceCommit);
|
||||
expect(() => parsePacSourceArtifactOptions([
|
||||
"plan", "--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", "/tmp/repo", "--source-commit", sourceCommit,
|
||||
])).toThrow("accepted only");
|
||||
});
|
||||
|
||||
test("real outer CLI returns bounded scoped help for both help positions", () => {
|
||||
for (const tail of [["--help"], ["check", "--help"]]) {
|
||||
const result = spawnSync("bun", ["scripts/cli.ts", "platform-infra", "pipelines-as-code", "source-artifact", ...tail], {
|
||||
cwd: rootPath(), encoding: "utf8", timeout: 30_000,
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
expect(result.stdout).toContain("PAC SOURCE ARTIFACT");
|
||||
expect(result.stdout).toContain("verify-runtime");
|
||||
expect(result.stdout).toContain("--source-commit <full-sha>");
|
||||
expect(result.stdout.split("\n").length).toBeLessThan(25);
|
||||
expect(result.stdout).not.toContain("platform-infra sub2api plan");
|
||||
}
|
||||
});
|
||||
|
||||
test("predictable validation failures omit stack and rendered specs", () => {
|
||||
const result = spawnSync("bun", [
|
||||
"scripts/cli.ts", "platform-infra", "pipelines-as-code", "source-artifact", "plan",
|
||||
"--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", rootPath(),
|
||||
], { cwd: rootPath(), encoding: "utf8", timeout: 30_000 });
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("origin does not match PaC repository pikasTech-agentrun");
|
||||
expect(result.stdout).toContain("pikasTech/unidesk.git");
|
||||
expect(result.stdout).not.toContain("stack");
|
||||
expect(result.stdout).not.toContain("taskSpec");
|
||||
expect(result.stdout.length).toBeLessThan(1500);
|
||||
});
|
||||
|
||||
test("undeclared JD01 source artifact owner fails closed", () => {
|
||||
const result = spawnSync("bun", [
|
||||
"scripts/cli.ts", "platform-infra", "pipelines-as-code", "source-artifact", "plan",
|
||||
"--target", "JD01", "--consumer", "agentrun-jd01-v02", "--source-worktree", rootPath(),
|
||||
], { cwd: rootPath(), encoding: "utf8", timeout: 30_000 });
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toContain("has no sourceArtifact owner");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Tekton admission canonicalization", () => {
|
||||
const admitted = {
|
||||
tasks: [{
|
||||
name: "build",
|
||||
taskSpec: {
|
||||
metadata: {},
|
||||
spec: null,
|
||||
params: [{ name: "input", type: "string" }],
|
||||
results: [{ name: "output", type: "string" }],
|
||||
steps: [{ name: "step", computeResources: {} }],
|
||||
sidecars: [{ name: "sidecar", computeResources: {} }],
|
||||
},
|
||||
}],
|
||||
};
|
||||
const desired = {
|
||||
tasks: [{
|
||||
name: "build",
|
||||
taskSpec: {
|
||||
params: [{ name: "input" }],
|
||||
results: [{ name: "output" }],
|
||||
steps: [{ name: "step" }],
|
||||
sidecars: [{ name: "sidecar" }],
|
||||
},
|
||||
}],
|
||||
};
|
||||
|
||||
test("removes exactly the six proven defaults and keeps local/native hashes equal", () => {
|
||||
expect(canonicalizePacPipelineSpec(admitted)).toEqual(desired);
|
||||
expect(nativeCanonicalize(admitted)).toEqual(desired);
|
||||
expect(canonicalSha256(admitted)).toBe(canonicalSha256(desired));
|
||||
expect(nativeCanonicalSha256(admitted)).toBe(canonicalSha256(admitted));
|
||||
});
|
||||
|
||||
test("does not strip same-name fields outside the three Pipeline spec roots", () => {
|
||||
const negative = {
|
||||
unrelated: admitted,
|
||||
tasks: [{ taskSpec: {
|
||||
metadata: { labels: {} },
|
||||
spec: {},
|
||||
params: [{ type: "array" }],
|
||||
results: [{ type: "object" }],
|
||||
steps: [{ computeResources: { requests: { cpu: "1" } } }],
|
||||
sidecars: [{ computeResources: { limits: { memory: "1Gi" } } }],
|
||||
} }],
|
||||
};
|
||||
expect(canonicalizePacPipelineSpec(negative)).toEqual(negative);
|
||||
expect(nativeCanonicalize(negative)).toEqual(negative);
|
||||
});
|
||||
});
|
||||
|
||||
describe("runtime source-commit selection", () => {
|
||||
test("same-commit retries select the newest only when spec and provenance agree", () => {
|
||||
const oldRun = pipelineRun("agentrun-nc01-v02-ci-old", "2026-07-10T00:00:00Z");
|
||||
const newRun = pipelineRun("agentrun-nc01-v02-ci-new", "2026-07-11T00:00:00Z");
|
||||
const selected = selectPipelineRunBySourceCommit([oldRun, newRun], "agentrun-nc01-v02-ci", sourceCommit);
|
||||
expect(selected.kind).toBe("ok");
|
||||
expect(selected.run.metadata.name).toBe("agentrun-nc01-v02-ci-new");
|
||||
const observed = observePacSourceArtifactRuntime(runtimeInput(), (args: string[], label: string) => label === "pipeline-get"
|
||||
? { kind: "ok", value: pipeline() }
|
||||
: { kind: "ok", value: { items: [oldRun, newRun] } });
|
||||
expect(observed.embedded.status).toBe("aligned");
|
||||
expect(observed.embedded.name).toBe("agentrun-nc01-v02-ci-new");
|
||||
expect(observed.embedded.candidateCount).toBe(2);
|
||||
expect(observed.embedded.sourceCommit).toBe(sourceCommit);
|
||||
});
|
||||
|
||||
test("same-commit conflicting embedded specs fail closed", () => {
|
||||
const observed = observePacSourceArtifactRuntime(runtimeInput(), (_args: string[], label: string) => label === "pipeline-get"
|
||||
? { kind: "ok", value: pipeline() }
|
||||
: { kind: "ok", value: { items: [
|
||||
pipelineRun("agentrun-nc01-v02-ci-old", "2026-07-10T00:00:00Z"),
|
||||
pipelineRun("agentrun-nc01-v02-ci-new", "2026-07-11T00:00:00Z", { params: [], tasks: [{ name: "changed" }] }),
|
||||
] } });
|
||||
expect(observed.embedded.status).toBe("unavailable");
|
||||
expect(observed.embedded.reason).toBe("source-commit-run-conflict:2");
|
||||
expect(observed.embedded.candidateCount).toBe(2);
|
||||
});
|
||||
|
||||
test("NotFound, missing commit, and transport failures remain distinct", () => {
|
||||
const noCommit = observePacSourceArtifactRuntime(runtimeInput(null), () => ({ kind: "not-found", reason: "pipeline-get-not-found" }));
|
||||
expect(noCommit.live.status).toBe("missing");
|
||||
expect(noCommit.live.reason).toBe("pipeline-get-not-found");
|
||||
expect(noCommit.embedded.status).toBe("unavailable");
|
||||
expect(noCommit.embedded.reason).toBe("source-commit-not-specified");
|
||||
|
||||
const transport = observePacSourceArtifactRuntime(runtimeInput(), (_args: string[], label: string) => ({ kind: "unavailable", reason: `${label}-command-failed:126:permission denied` }));
|
||||
expect(transport.live.status).toBe("unavailable");
|
||||
expect(transport.embedded.status).toBe("unavailable");
|
||||
expect(transport.live.reason).toContain("permission denied");
|
||||
expect(transport.embedded.reason).toContain("permission denied");
|
||||
});
|
||||
|
||||
test("temp-file transport handles a payload larger than the current 203 KiB HWLAB Pipeline", () => {
|
||||
const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-pac-runtime-test-"));
|
||||
try {
|
||||
const bin = resolve(temporary, "bin");
|
||||
mkdirSync(bin);
|
||||
const hugeSpec = { params: [], tasks: [{ name: "render", taskSpec: { steps: [{ name: "render", script: "x".repeat(220 * 1024) }] } }] };
|
||||
const input = {
|
||||
namespace: "hwlab-ci",
|
||||
pipeline: "hwlab-nc01-v03-ci-image-publish",
|
||||
pipelineRunPrefix: "hwlab-nc01-v03-ci-poll",
|
||||
desiredSpec: hugeSpec,
|
||||
provenance,
|
||||
sourceCommit,
|
||||
};
|
||||
expect(Buffer.byteLength(JSON.stringify(input), "utf8")).toBeGreaterThan(203 * 1024);
|
||||
const pipelineFile = resolve(temporary, "pipeline.json");
|
||||
const runsFile = resolve(temporary, "runs.json");
|
||||
writeFileSync(pipelineFile, JSON.stringify({ metadata: { name: input.pipeline, annotations: manifestAnnotations() }, spec: hugeSpec }));
|
||||
writeFileSync(runsFile, JSON.stringify({ items: [pipelineRun("hwlab-nc01-v03-ci-poll-large", "2026-07-11T00:00:00Z", hugeSpec)] }));
|
||||
const kubectl = resolve(bin, "kubectl");
|
||||
writeFileSync(kubectl, "#!/bin/sh\nset -eu\ncase \"$2\" in pipeline) cat \"$PAC_TEST_PIPELINE\" ;; pipelinerun) cat \"$PAC_TEST_RUNS\" ;; *) exit 2 ;; esac\n");
|
||||
chmodSync(kubectl, 0o755);
|
||||
const shell = renderPacSourceArtifactRuntimeObserverShell(input);
|
||||
expect(shell).toContain("observer_input=$(mktemp)");
|
||||
expect(shell).toContain("PAC_SOURCE_ARTIFACT_INPUT_FILE=\"$observer_input\"");
|
||||
expect(shell).not.toContain("PAC_SOURCE_ARTIFACT_INPUT=");
|
||||
const result = spawnSync("sh", [], {
|
||||
input: shell,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
maxBuffer: 8 * 1024 * 1024,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${bin}:${process.env.PATH ?? ""}`,
|
||||
PAC_TEST_PIPELINE: pipelineFile,
|
||||
PAC_TEST_RUNS: runsFile,
|
||||
},
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const observed = JSON.parse(result.stdout) as Record<string, any>;
|
||||
expect(observed.live.status).toBe("aligned");
|
||||
expect(observed.embedded.status).toBe("aligned");
|
||||
expect(observed.embedded.sourceCommit).toBe(sourceCommit);
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("HWLAB domain Pipeline contract", () => {
|
||||
const requiredMarkers = [
|
||||
"unidesk-runtime-gitops-postprocess",
|
||||
"unidesk-runtime-gitops-verify",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_ENABLED",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_GROUP_PREFIX",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_TIMEOUT_MS",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_SCAN_LIMIT",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_MATCHED_EVENT_LIMIT",
|
||||
"HWLAB_WORKBENCH_KAFKA_REFRESH_REPLAY_LIVE_BUFFER_LIMIT",
|
||||
];
|
||||
|
||||
test("accepts the complete renderer contract and rejects every missing critical marker", () => {
|
||||
const complete = { tasks: [{ taskSpec: { steps: [{ script: requiredMarkers.join("\n") }] } }] };
|
||||
expect(() => assertHwlabPipelineContracts(complete)).not.toThrow();
|
||||
for (const marker of requiredMarkers) {
|
||||
const missing = { tasks: [{ taskSpec: { steps: [{ script: requiredMarkers.filter((item) => item !== marker).join("\n") }] } }] };
|
||||
expect(() => assertHwlabPipelineContracts(missing)).toThrow(marker);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("shared HWLAB deploy overlay", () => {
|
||||
test("preserves exact undefined/null semantics without mutating its input", () => {
|
||||
const document = {
|
||||
nodes: { NC01: { keep: "node" } },
|
||||
lanes: { v03: {
|
||||
keep: "lane",
|
||||
externalPostgres: { old: true },
|
||||
runtimeStore: { old: true },
|
||||
codeAgentRuntime: { old: true },
|
||||
gitMirror: { old: true },
|
||||
envRecipe: { keep: "recipe", downloadStack: { keep: "download" } },
|
||||
} },
|
||||
};
|
||||
const overlay = {
|
||||
nodeId: "NC01",
|
||||
lane: "v03",
|
||||
gitopsRoot: "deploy/gitops/node/nc01",
|
||||
gitUrl: "git@example/HWLAB.git",
|
||||
sourceBranch: "v0.3",
|
||||
gitopsBranch: "v0.3-gitops",
|
||||
runtimeNamespace: "hwlab-v03",
|
||||
publicApiUrl: "https://api.example",
|
||||
publicWebUrl: "https://web.example",
|
||||
catalogPath: "deploy/artifacts/nc01.yaml",
|
||||
runtimePath: "deploy/gitops/node/nc01/hwlab-v03",
|
||||
observability: { enabled: true },
|
||||
dockerProxyHttp: null,
|
||||
dockerProxyHttps: null,
|
||||
dockerNoProxyList: ["localhost"],
|
||||
externalPostgres: null,
|
||||
runtimeStore: null,
|
||||
codeAgentRuntime: null,
|
||||
deployYamlGitMirror: null,
|
||||
};
|
||||
const rendered = applyNodeRuntimeDeployYamlOverlay(document, overlay);
|
||||
expect(document.lanes.v03.externalPostgres).toEqual({ old: true });
|
||||
expect(rendered.lanes.v03).not.toHaveProperty("externalPostgres");
|
||||
expect(rendered.lanes.v03.runtimeStore).toBeNull();
|
||||
expect(rendered.lanes.v03.codeAgentRuntime).toBeNull();
|
||||
expect(rendered.lanes.v03.gitMirror).toBeNull();
|
||||
expect(rendered.lanes.v03.keep).toBe("lane");
|
||||
expect(nodeRuntimeDeployYamlOverlayShellScript().join("\n")).not.toContain(": Record<string");
|
||||
});
|
||||
|
||||
test("the runtime shell helper produces the same deploy.yaml object as the pure renderer", () => {
|
||||
const temporary = mkdtempSync(resolve(tmpdir(), "unidesk-pac-overlay-test-"));
|
||||
try {
|
||||
mkdirSync(resolve(temporary, "deploy"));
|
||||
const document = { nodes: { NC01: {} }, lanes: { v03: { envRecipe: { downloadStack: {} } } } };
|
||||
const overlay = {
|
||||
nodeId: "NC01", lane: "v03", gitopsRoot: "deploy/gitops/node/nc01", gitUrl: "git@example/HWLAB.git",
|
||||
sourceBranch: "v0.3", gitopsBranch: "v0.3-gitops", runtimeNamespace: "hwlab-v03",
|
||||
publicApiUrl: "https://api.example", publicWebUrl: "https://web.example", catalogPath: "deploy/artifacts/nc01.yaml",
|
||||
runtimePath: "deploy/gitops/node/nc01/hwlab-v03", observability: { enabled: true },
|
||||
dockerProxyHttp: "http://proxy", dockerProxyHttps: "http://proxy", dockerNoProxyList: ["localhost"],
|
||||
externalPostgres: { host: "postgres" }, runtimeStore: { mode: "postgres" }, codeAgentRuntime: { enabled: true },
|
||||
deployYamlGitMirror: { readUrl: "http://mirror" },
|
||||
};
|
||||
writeFileSync(resolve(temporary, "deploy", "deploy.yaml"), Bun.YAML.stringify(document));
|
||||
const overlayBase64 = Buffer.from(JSON.stringify(overlay), "utf8").toString("base64");
|
||||
const shell = [`overlay_b64='${overlayBase64}'`, ...nodeRuntimeDeployYamlOverlayShellScript()].join("\n");
|
||||
const result = spawnSync("sh", [], {
|
||||
cwd: temporary,
|
||||
input: shell,
|
||||
encoding: "utf8",
|
||||
timeout: 30_000,
|
||||
env: { ...process.env, NODE_PATH: resolve(rootPath(), "node_modules") },
|
||||
});
|
||||
expect(result.status).toBe(0);
|
||||
const shellRendered = Bun.YAML.parse(readFileSync(resolve(temporary, "deploy", "deploy.yaml"), "utf8")) as Record<string, unknown>;
|
||||
expect(shellRendered).toEqual(applyNodeRuntimeDeployYamlOverlay(document, overlay));
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user