feat(cicd): generate PaC source artifacts from YAML
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
// Renders AgentRun YAML lane policy into runtime manager environment.
|
||||
import { createHash } from "node:crypto";
|
||||
import type { AgentRunLaneSpec } from "./agentrun-lanes";
|
||||
import { stableJsonSha256 } from "./stable-json";
|
||||
|
||||
export interface AgentRunArtifactService {
|
||||
readonly serviceId: string;
|
||||
@@ -75,7 +76,7 @@ export function renderAgentRunControlPlaneManifests(spec: AgentRunLaneSpec): rea
|
||||
subjects: [{ kind: "ServiceAccount", name: spec.ci.serviceAccountName }],
|
||||
roleRef: { apiGroup: "rbac.authorization.k8s.io", kind: "Role", name: spec.ci.serviceAccountName },
|
||||
},
|
||||
agentRunPipelineManifest(spec),
|
||||
renderAgentRunPipelineManifest(spec),
|
||||
agentRunArgoProjectManifest(spec),
|
||||
agentRunArgoApplicationManifest(spec),
|
||||
];
|
||||
@@ -172,7 +173,7 @@ export function renderedObjectsDigest(objects: readonly Record<string, unknown>[
|
||||
return `sha256:${createHash("sha256").update(yamlAll(objects)).digest("hex")}`;
|
||||
}
|
||||
|
||||
function agentRunPipelineManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
|
||||
export function renderAgentRunPipelineManifest(spec: AgentRunLaneSpec): Record<string, unknown> {
|
||||
const build = spec.deployment.manager.imageBuild;
|
||||
if (spec.ci.buildkitImage === null) throw new Error(`config/agentrun.yaml controlPlane.lanes.${spec.lane}.ci.buildkitImage is required for AgentRun Tekton image build`);
|
||||
return {
|
||||
@@ -182,6 +183,12 @@ function agentRunPipelineManifest(spec: AgentRunLaneSpec): Record<string, unknow
|
||||
name: spec.ci.pipeline,
|
||||
namespace: spec.ci.namespace,
|
||||
labels: agentRunLabels(spec),
|
||||
annotations: {
|
||||
"unidesk.ai/owning-config-ref": `config/agentrun.yaml#controlPlane.lanes.${spec.lane}`,
|
||||
"unidesk.ai/effective-config-sha256": stableJsonSha256(spec),
|
||||
"unidesk.ai/source-artifact-renderer": "agentrun-control-plane",
|
||||
"unidesk.ai/source-artifact-mode": "embedded-pipeline-spec",
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
params: [
|
||||
|
||||
@@ -833,6 +833,7 @@ export async function staticNamespaceHelp(args: string[]): Promise<unknown | nul
|
||||
if (top === "gh") return ghScopedHelp(args.slice(1)) ?? ghHelp();
|
||||
if (top === "cicd") return loadHelp(async () => (await import("./cicd")).cicdHelp(), cicdHelpSummary());
|
||||
if (top === "agentrun") return loadHelp(async () => (await import("./agentrun")).agentRunHelp(), agentRunHelpSummary());
|
||||
if (top === "platform-infra" && (sub === "pipelines-as-code" || sub === "pac") && args[2] === "source-artifact") return null;
|
||||
if (top === "platform-infra") return loadHelp(async () => (await import("./platform-infra")).platformInfraHelp(), platformInfraHelpSummary());
|
||||
if (top === "platform-db") return platformDbHelp();
|
||||
if (top === "secrets") return secretsHelp();
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
export function applyNodeRuntimeDeployYamlOverlay(document: Record<string, unknown>, overlay: Record<string, unknown>): Record<string, unknown> {
|
||||
const doc = structuredClone(document);
|
||||
const nodeId = requiredString(overlay.nodeId, "overlay.nodeId");
|
||||
const laneId = requiredString(overlay.lane, "overlay.lane");
|
||||
const nodes = record(doc.nodes);
|
||||
const node = record(nodes[nodeId]);
|
||||
nodes[nodeId] = { ...node, gitopsRoot: overlay.gitopsRoot, sourceRepo: overlay.gitUrl };
|
||||
doc.nodes = nodes;
|
||||
const lanes = record(doc.lanes);
|
||||
const lane = record(lanes[laneId]);
|
||||
const envRecipe = record(lane.envRecipe);
|
||||
const downloadStack = {
|
||||
...record(envRecipe.downloadStack),
|
||||
httpProxy: overlay.dockerProxyHttp,
|
||||
httpsProxy: overlay.dockerProxyHttps,
|
||||
noProxy: overlay.dockerNoProxyList,
|
||||
};
|
||||
const nextLane: Record<string, unknown> = {
|
||||
...lane,
|
||||
node: nodeId,
|
||||
sourceBranch: overlay.sourceBranch,
|
||||
gitopsBranch: overlay.gitopsBranch,
|
||||
namespace: overlay.runtimeNamespace,
|
||||
endpoint: overlay.publicApiUrl,
|
||||
publicEndpoints: { frontend: overlay.publicWebUrl, api: overlay.publicApiUrl },
|
||||
artifactCatalog: overlay.catalogPath,
|
||||
runtimePath: overlay.runtimePath,
|
||||
imageTagMode: "full",
|
||||
sourceRepo: overlay.gitUrl,
|
||||
observability: overlay.observability,
|
||||
envRecipe: { ...envRecipe, downloadStack },
|
||||
};
|
||||
if (overlay.externalPostgres === undefined || overlay.externalPostgres === null) delete nextLane.externalPostgres;
|
||||
else nextLane.externalPostgres = overlay.externalPostgres;
|
||||
if (overlay.runtimeStore !== undefined) nextLane.runtimeStore = overlay.runtimeStore;
|
||||
if (overlay.codeAgentRuntime !== undefined) nextLane.codeAgentRuntime = overlay.codeAgentRuntime;
|
||||
if (overlay.deployYamlGitMirror !== undefined) nextLane.gitMirror = overlay.deployYamlGitMirror;
|
||||
lanes[laneId] = nextLane;
|
||||
doc.lanes = lanes;
|
||||
return doc;
|
||||
}
|
||||
|
||||
export function nodeRuntimeDeployYamlOverlayShellScript(): string[] {
|
||||
return [
|
||||
"node - \"$overlay_b64\" <<'NODE_UNIDESK_DEPLOY_OVERLAY'",
|
||||
"const fs = require('fs');",
|
||||
"const YAML = require('yaml');",
|
||||
`const applyNodeRuntimeDeployYamlOverlay = ${applyNodeRuntimeDeployYamlOverlay.toString()};`,
|
||||
`const record = ${record.toString()};`,
|
||||
`const requiredString = ${requiredString.toString()};`,
|
||||
"const overlay = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));",
|
||||
"const path = 'deploy/deploy.yaml';",
|
||||
"const doc = YAML.parse(fs.readFileSync(path, 'utf8'));",
|
||||
"fs.writeFileSync(path, YAML.stringify(applyNodeRuntimeDeployYamlOverlay(doc, overlay)));",
|
||||
"NODE_UNIDESK_DEPLOY_OVERLAY",
|
||||
];
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
@@ -40,6 +40,7 @@ import { externalPostgresBridgeStatus, externalPostgresSecretStatus, getNodeRunt
|
||||
import { webObserveShort, webObserveText } from "./web-probe-observe";
|
||||
import { readNodeRuntimeStatusPolicy, runNodeRuntimeStatusProbe, skippedNodeRuntimeStatusCommand, type NodeRuntimeStatusPolicy, type NodeRuntimeStatusWorkerEvent } from "./control-plane-status-observed";
|
||||
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
|
||||
import { nodeRuntimeDeployYamlOverlayShellScript } from "./deploy-overlay";
|
||||
|
||||
const runtimeGitopsObservabilityNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-observability.mjs"), "utf8").trimEnd();
|
||||
const runtimeGitopsPipelineGuardNativeScript = readFileSync(rootPath("scripts/native/hwlab/runtime-gitops-pipeline-guard.mjs"), "utf8").trimEnd();
|
||||
@@ -1468,44 +1469,7 @@ export function renderNodeRuntimeControlPlaneOnNode(spec: HwlabRuntimeLaneSpec,
|
||||
"git -C \"$worktree_dir\" checkout --detach \"$source_commit\"",
|
||||
"cd \"$worktree_dir\"",
|
||||
...yamlDependencyInstallScript(spec.downloadProfile.npm.registry, spec.downloadProfile.npm.fetchTimeoutSeconds, spec.downloadProfile.npm.retries, "control-plane-render"),
|
||||
"node - \"$overlay_b64\" <<'NODE'",
|
||||
"const fs = require('fs');",
|
||||
"const YAML = require('yaml');",
|
||||
"const overlay = JSON.parse(Buffer.from(process.argv[2], 'base64').toString('utf8'));",
|
||||
"const path = 'deploy/deploy.yaml';",
|
||||
"const doc = YAML.parse(fs.readFileSync(path, 'utf8'));",
|
||||
"doc.nodes = doc.nodes || {};",
|
||||
"doc.nodes[overlay.nodeId] = { ...(doc.nodes[overlay.nodeId] || {}), gitopsRoot: overlay.gitopsRoot, sourceRepo: overlay.gitUrl };",
|
||||
"doc.lanes = doc.lanes || {};",
|
||||
"const lane = doc.lanes[overlay.lane] || {};",
|
||||
"const downloadStack = {",
|
||||
" ...(lane.envRecipe?.downloadStack || {}),",
|
||||
" httpProxy: overlay.dockerProxyHttp,",
|
||||
" httpsProxy: overlay.dockerProxyHttps,",
|
||||
" noProxy: overlay.dockerNoProxyList,",
|
||||
"};",
|
||||
"doc.lanes[overlay.lane] = {",
|
||||
" ...lane,",
|
||||
" node: overlay.nodeId,",
|
||||
" sourceBranch: overlay.sourceBranch,",
|
||||
" gitopsBranch: overlay.gitopsBranch,",
|
||||
" namespace: overlay.runtimeNamespace,",
|
||||
" endpoint: overlay.publicApiUrl,",
|
||||
" publicEndpoints: { frontend: overlay.publicWebUrl, api: overlay.publicApiUrl },",
|
||||
" artifactCatalog: overlay.catalogPath,",
|
||||
" runtimePath: overlay.runtimePath,",
|
||||
" imageTagMode: 'full',",
|
||||
" sourceRepo: overlay.gitUrl,",
|
||||
" observability: overlay.observability,",
|
||||
" envRecipe: { ...(lane.envRecipe || {}), downloadStack },",
|
||||
"};",
|
||||
"if (overlay.externalPostgres === undefined || overlay.externalPostgres === null) delete doc.lanes[overlay.lane].externalPostgres;",
|
||||
"else doc.lanes[overlay.lane].externalPostgres = overlay.externalPostgres;",
|
||||
"if (overlay.runtimeStore !== undefined) doc.lanes[overlay.lane].runtimeStore = overlay.runtimeStore;",
|
||||
"if (overlay.codeAgentRuntime !== undefined) doc.lanes[overlay.lane].codeAgentRuntime = overlay.codeAgentRuntime;",
|
||||
"if (overlay.deployYamlGitMirror !== undefined) doc.lanes[overlay.lane].gitMirror = overlay.deployYamlGitMirror;",
|
||||
"fs.writeFileSync(path, YAML.stringify(doc));",
|
||||
"NODE",
|
||||
...nodeRuntimeDeployYamlOverlayShellScript(),
|
||||
"if [ -f scripts/gitops-render.mjs ]; then render_script=scripts/gitops-render.mjs; else echo 'render script missing: scripts/gitops-render.mjs' >&2; exit 43; fi",
|
||||
[
|
||||
"node scripts/run-bun.mjs \"$render_script\"",
|
||||
@@ -2557,6 +2521,11 @@ export function nodeRuntimePipelinePostprocessScript(): string[] {
|
||||
" doc.metadata = doc.metadata || {};",
|
||||
" if (typeof overlay.pipelineName === 'string' && overlay.pipelineName.length > 0) doc.metadata.name = overlay.pipelineName;",
|
||||
" doc.metadata.annotations = doc.metadata.annotations || {};",
|
||||
" const provenance = overlay.sourceArtifactProvenance || {};",
|
||||
" if (provenance.configRef) doc.metadata.annotations['unidesk.ai/owning-config-ref'] = provenance.configRef;",
|
||||
" if (provenance.effectiveConfigSha256) doc.metadata.annotations['unidesk.ai/effective-config-sha256'] = provenance.effectiveConfigSha256;",
|
||||
" if (provenance.renderer) doc.metadata.annotations['unidesk.ai/source-artifact-renderer'] = provenance.renderer;",
|
||||
" if (provenance.mode) doc.metadata.annotations['unidesk.ai/source-artifact-mode'] = provenance.mode;",
|
||||
" doc.metadata.annotations['hwlab.pikastech.local/download-profile'] = overlay.downloadProfileId;",
|
||||
" doc.metadata.annotations['hwlab.pikastech.local/network-profile'] = overlay.networkProfileId;",
|
||||
" for (const task of doc.spec?.tasks || []) {",
|
||||
|
||||
@@ -40,6 +40,7 @@ import { assertKnownOptions, nodeWebProbeAutoCommandTimeoutSeconds, normalizeNod
|
||||
import { resolveNodeWebProbeCliOrigin } from "./web-probe-origin";
|
||||
import { hwlabRuntimeActiveExternalPostgres } from "../hwlab-node-lanes";
|
||||
import { resolveEgressProxySourceRef } from "../egress-proxy-sources";
|
||||
import { stableJsonSha256 } from "../stable-json";
|
||||
|
||||
export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<string, unknown> {
|
||||
const gitSshProxy = httpProxyEndpoint(spec.networkProfile.proxy.http);
|
||||
@@ -74,6 +75,12 @@ export function nodeRuntimeRenderOverlay(spec: HwlabRuntimeLaneSpec): Record<str
|
||||
},
|
||||
};
|
||||
return {
|
||||
sourceArtifactProvenance: {
|
||||
configRef: `${hwlabRuntimeLaneConfigPath}#lanes.${spec.lane}.targets.${spec.nodeId}`,
|
||||
effectiveConfigSha256: stableJsonSha256(spec),
|
||||
renderer: "hwlab-runtime-lane",
|
||||
mode: "remote-pipeline-annotation",
|
||||
},
|
||||
nodeId: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
sourceBranch: spec.sourceBranch,
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,909 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { cpSync, existsSync, lstatSync, mkdirSync, mkdtempSync, readFileSync, realpathSync, renameSync, rmSync, statSync, symlinkSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { dirname, isAbsolute, relative, resolve, sep } from "node:path";
|
||||
import { spawnSync } from "node:child_process";
|
||||
|
||||
import { rootPath } from "./config";
|
||||
import { AGENTRUN_CONFIG_PATH, resolveAgentRunLaneTarget } from "./agentrun-lanes";
|
||||
import { renderAgentRunPipelineManifest } from "./agentrun-manifests";
|
||||
import { hwlabRuntimeLaneSpecForNode, isHwlabRuntimeLane, type HwlabRuntimeLaneSpec } from "./hwlab-node-lanes";
|
||||
import { nodeRuntimeGitopsRoot } from "./hwlab-node/cleanup";
|
||||
import { nodeRuntimePipelinePostprocessScript } from "./hwlab-node/render";
|
||||
import { nodeRuntimeRenderOverlay } from "./hwlab-node/web-probe";
|
||||
import { applyNodeRuntimeDeployYamlOverlay } from "./hwlab-node/deploy-overlay";
|
||||
import type { RenderedCliResult } from "./output";
|
||||
import { renderedCliResult } from "./agentrun/render";
|
||||
import { stableJsonSha256 } from "./stable-json";
|
||||
|
||||
export type PacSourceArtifactMode = "embedded-pipeline-spec" | "remote-pipeline-annotation";
|
||||
export type PacSourceArtifactRenderer = "agentrun-control-plane" | "hwlab-runtime-lane";
|
||||
export type PacSourceArtifactAction = "plan" | "check" | "write" | "status" | "verify-runtime";
|
||||
|
||||
export interface PacSourceArtifactSpec {
|
||||
readonly mode: PacSourceArtifactMode;
|
||||
readonly renderer: PacSourceArtifactRenderer;
|
||||
readonly configRef: string;
|
||||
readonly pipelineRunPath: string;
|
||||
readonly pipelinePath: string | null;
|
||||
readonly maxKeepRuns: number;
|
||||
}
|
||||
|
||||
export interface PacSourceArtifactBinding {
|
||||
readonly target: { readonly id: string };
|
||||
readonly consumer: {
|
||||
readonly id: string;
|
||||
readonly node: string;
|
||||
readonly lane: string;
|
||||
readonly namespace: string;
|
||||
readonly pipeline: string;
|
||||
readonly pipelineRunPrefix: string;
|
||||
readonly sourceArtifact: PacSourceArtifactSpec;
|
||||
};
|
||||
readonly repository: {
|
||||
readonly id: string;
|
||||
readonly cloneUrl: string;
|
||||
readonly owner: string;
|
||||
readonly repo: string;
|
||||
readonly params: Readonly<Record<string, string>>;
|
||||
};
|
||||
}
|
||||
|
||||
export interface PacSourceArtifactOptions {
|
||||
readonly action: PacSourceArtifactAction;
|
||||
readonly targetId: string;
|
||||
readonly consumerId: string;
|
||||
readonly sourceWorktree: string;
|
||||
readonly sourceCommit: string | null;
|
||||
readonly confirm: boolean;
|
||||
readonly json: boolean;
|
||||
readonly full: boolean;
|
||||
}
|
||||
|
||||
export interface PacSourceArtifactRuntimeObservation {
|
||||
readonly live: PacSourceArtifactRuntimeItem;
|
||||
readonly embedded: PacSourceArtifactRuntimeItem;
|
||||
}
|
||||
|
||||
export interface PacSourceArtifactRuntimeItem {
|
||||
readonly status: "aligned" | "drifted" | "missing" | "unavailable";
|
||||
readonly name: string | null;
|
||||
readonly canonicalSha256: string | null;
|
||||
readonly firstDrift: PacSourceArtifactDrift | null;
|
||||
readonly provenanceAligned: boolean;
|
||||
readonly configRef: string | null;
|
||||
readonly effectiveConfigSha256: string | null;
|
||||
readonly sourceCommit: string | null;
|
||||
readonly reason: string | null;
|
||||
readonly candidateCount: number;
|
||||
}
|
||||
|
||||
export interface PacSourceArtifactDrift {
|
||||
readonly path: string;
|
||||
readonly expected: string;
|
||||
readonly actual: string;
|
||||
}
|
||||
|
||||
export type PacSourceArtifactRuntimeObserver = (input: {
|
||||
readonly binding: PacSourceArtifactBinding;
|
||||
readonly desiredSpec: Record<string, unknown>;
|
||||
readonly provenance: PacSourceArtifactProvenance;
|
||||
readonly sourceCommit: string | null;
|
||||
}) => Promise<PacSourceArtifactRuntimeObservation>;
|
||||
|
||||
interface PacSourceArtifactProvenance {
|
||||
readonly configRef: string;
|
||||
readonly effectiveConfigSha256: string;
|
||||
readonly renderer: PacSourceArtifactRenderer;
|
||||
}
|
||||
|
||||
interface DesiredArtifact {
|
||||
readonly pipeline: Record<string, unknown>;
|
||||
readonly pipelineRun: Record<string, unknown>;
|
||||
readonly provenance: PacSourceArtifactProvenance;
|
||||
readonly desiredSpec: Record<string, unknown>;
|
||||
readonly files: readonly { readonly path: string; readonly content: string }[];
|
||||
}
|
||||
|
||||
interface SourceInspection {
|
||||
readonly status: "aligned" | "drifted" | "missing";
|
||||
readonly aligned: boolean;
|
||||
readonly pipelineCanonicalSha256: string | null;
|
||||
readonly pipelineRunCanonicalSha256: string | null;
|
||||
readonly firstDrift: PacSourceArtifactDrift | null;
|
||||
readonly provenanceAligned: boolean;
|
||||
}
|
||||
|
||||
const provenanceKeys = {
|
||||
configRef: "unidesk.ai/owning-config-ref",
|
||||
effectiveConfigSha256: "unidesk.ai/effective-config-sha256",
|
||||
renderer: "unidesk.ai/source-artifact-renderer",
|
||||
mode: "unidesk.ai/source-artifact-mode",
|
||||
} as const;
|
||||
|
||||
export function parsePacSourceArtifactOptions(args: readonly string[]): PacSourceArtifactOptions {
|
||||
const action = args[0];
|
||||
if (!isSourceArtifactAction(action)) {
|
||||
throw new Error("source-artifact requires one of plan, check, write, status, verify-runtime");
|
||||
}
|
||||
let targetId: string | null = null;
|
||||
let consumerId: string | null = null;
|
||||
let sourceWorktree: string | null = null;
|
||||
let sourceCommit: string | null = null;
|
||||
let confirm = false;
|
||||
let json = false;
|
||||
let full = false;
|
||||
for (let index = 1; index < args.length; index += 1) {
|
||||
const arg = args[index];
|
||||
if (arg === "--confirm") {
|
||||
confirm = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--json") {
|
||||
json = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--full") {
|
||||
full = true;
|
||||
continue;
|
||||
}
|
||||
if (arg === "--target" || arg === "--consumer" || arg === "--source-worktree" || arg === "--source-commit") {
|
||||
const value = args[index + 1];
|
||||
if (value === undefined || value.startsWith("--")) throw new Error(`${arg} requires a value`);
|
||||
if (arg === "--target") targetId = value;
|
||||
else if (arg === "--consumer") consumerId = value;
|
||||
else if (arg === "--source-worktree") sourceWorktree = value;
|
||||
else sourceCommit = value.toLowerCase();
|
||||
index += 1;
|
||||
continue;
|
||||
}
|
||||
throw new Error(`unsupported source-artifact option: ${arg}`);
|
||||
}
|
||||
if (targetId === null) throw new Error("source-artifact requires explicit --target");
|
||||
if (consumerId === null) throw new Error("source-artifact requires explicit --consumer");
|
||||
if (sourceWorktree === null) throw new Error("source-artifact requires explicit --source-worktree");
|
||||
if (!isAbsolute(sourceWorktree)) throw new Error("--source-worktree must be an absolute path");
|
||||
if (sourceCommit !== null && !/^[0-9a-f]{40}$/u.test(sourceCommit)) throw new Error("--source-commit must be a full 40-character git SHA");
|
||||
if (sourceCommit !== null && action !== "status" && action !== "verify-runtime") throw new Error("--source-commit is accepted only by source-artifact status or verify-runtime");
|
||||
if (action === "verify-runtime" && sourceCommit === null) throw new Error("source-artifact verify-runtime requires explicit --source-commit");
|
||||
if (action === "write" && !confirm) throw new Error("source-artifact write requires --confirm");
|
||||
if (action !== "write" && confirm) throw new Error("--confirm is accepted only by source-artifact write");
|
||||
return { action, targetId, consumerId, sourceWorktree, sourceCommit, confirm, json, full };
|
||||
}
|
||||
|
||||
export async function runPacSourceArtifact(
|
||||
binding: PacSourceArtifactBinding,
|
||||
options: PacSourceArtifactOptions,
|
||||
observeRuntime?: PacSourceArtifactRuntimeObserver,
|
||||
): Promise<Record<string, unknown>> {
|
||||
if (binding.target.id.toLowerCase() !== options.targetId.toLowerCase()) {
|
||||
throw new Error(`source-artifact target ${options.targetId} does not match resolved target ${binding.target.id}`);
|
||||
}
|
||||
if (binding.consumer.id.toLowerCase() !== options.consumerId.toLowerCase()) {
|
||||
throw new Error(`source-artifact consumer ${options.consumerId} does not match resolved consumer ${binding.consumer.id}`);
|
||||
}
|
||||
const sourceWorktree = validateSourceWorktree(options.sourceWorktree, binding);
|
||||
const desired = renderDesiredArtifact(binding, sourceWorktree);
|
||||
let source = inspectSourceArtifact(sourceWorktree, binding, desired);
|
||||
const written: string[] = [];
|
||||
if (options.action === "write") {
|
||||
written.push(...writeArtifactFilesAtomically(sourceWorktree, desired.files));
|
||||
source = inspectSourceArtifact(sourceWorktree, binding, desired);
|
||||
}
|
||||
const needsRuntime = options.action === "status" || options.action === "verify-runtime";
|
||||
const runtime = needsRuntime
|
||||
? observeRuntime === undefined
|
||||
? unavailableRuntimeObservation()
|
||||
: await observeRuntime({ binding, desiredSpec: desired.desiredSpec, provenance: desired.provenance, sourceCommit: options.sourceCommit })
|
||||
: null;
|
||||
const sourceOk = source.aligned && source.provenanceAligned;
|
||||
const runtimeOk = runtime !== null
|
||||
&& runtime.live.status === "aligned"
|
||||
&& runtime.live.provenanceAligned
|
||||
&& runtime.embedded.status === "aligned"
|
||||
&& runtime.embedded.provenanceAligned;
|
||||
const ok = options.action === "check"
|
||||
? sourceOk
|
||||
: options.action === "verify-runtime"
|
||||
? sourceOk && runtimeOk
|
||||
: options.action === "write"
|
||||
? sourceOk
|
||||
: true;
|
||||
const desiredSpecSha256 = canonicalSha256(desired.desiredSpec);
|
||||
return {
|
||||
ok,
|
||||
action: `platform-infra-pipelines-as-code-source-artifact-${options.action}`,
|
||||
mutation: options.action === "write" && written.length > 0,
|
||||
target: binding.target.id,
|
||||
consumer: binding.consumer.id,
|
||||
node: binding.consumer.node,
|
||||
lane: binding.consumer.lane,
|
||||
mode: binding.consumer.sourceArtifact.mode,
|
||||
renderer: binding.consumer.sourceArtifact.renderer,
|
||||
sourceWorktree,
|
||||
files: {
|
||||
pipelineRun: binding.consumer.sourceArtifact.pipelineRunPath,
|
||||
pipeline: binding.consumer.sourceArtifact.pipelinePath,
|
||||
written,
|
||||
},
|
||||
provenance: desired.provenance,
|
||||
desired: {
|
||||
canonicalSha256: desiredSpecSha256,
|
||||
pipeline: binding.consumer.pipeline,
|
||||
namespace: binding.consumer.namespace,
|
||||
},
|
||||
source,
|
||||
runtime,
|
||||
runtimeTarget: { sourceCommit: options.sourceCommit },
|
||||
runtimeAlignment: runtime === null
|
||||
? "not-requested"
|
||||
: runtimeOk
|
||||
? "aligned"
|
||||
: runtime.live.status === "missing" || runtime.embedded.status === "missing"
|
||||
? "missing"
|
||||
: runtime.live.status === "unavailable" || runtime.embedded.status === "unavailable"
|
||||
? "unavailable"
|
||||
: "pending",
|
||||
boundary: {
|
||||
desiredAuthority: "owning YAML plus domain renderer",
|
||||
liveExportAllowed: false,
|
||||
sourceWorktreeExplicit: true,
|
||||
canonicalAdmissionDefaultsRemoved: [
|
||||
"tasks[].taskSpec.metadata={}",
|
||||
"tasks[].taskSpec.spec=null",
|
||||
"tasks[].taskSpec.params[].type=string",
|
||||
"tasks[].taskSpec.results[].type=string",
|
||||
"tasks[].taskSpec.steps[].computeResources={}",
|
||||
"tasks[].taskSpec.sidecars[].computeResources={}",
|
||||
],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
next: sourceOk
|
||||
? options.action === "verify-runtime"
|
||||
? null
|
||||
: `After the consumer PR rolls out, run source-artifact verify-runtime for ${binding.consumer.id} with --source-commit <full-sha>.`
|
||||
: options.action === "write"
|
||||
? "Generated files are still inconsistent; inspect firstDrift and do not publish."
|
||||
: `Run source-artifact write --confirm against the explicit ${sourceWorktree} worktree.`,
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPacSourceArtifactResult(result: Record<string, unknown>): RenderedCliResult {
|
||||
const files = record(result.files);
|
||||
const desired = record(result.desired);
|
||||
const source = record(result.source);
|
||||
const runtime = result.runtime === null ? null : record(result.runtime);
|
||||
const live = runtime === null ? null : record(runtime.live);
|
||||
const embedded = runtime === null ? null : record(runtime.embedded);
|
||||
const firstDrift = source.firstDrift === null ? null : record(source.firstDrift);
|
||||
const lines = [
|
||||
"PAC SOURCE ARTIFACT",
|
||||
`target=${text(result.target)} consumer=${text(result.consumer)} node=${text(result.node)} lane=${text(result.lane)}`,
|
||||
`mode=${text(result.mode)} renderer=${text(result.renderer)} mutation=${text(result.mutation)}`,
|
||||
`pipelineRun=${text(files.pipelineRun)} pipeline=${text(files.pipeline ?? "-")}`,
|
||||
`desired=${shortSha(desired.canonicalSha256)} source=${shortSha(source.pipelineCanonicalSha256)} aligned=${text(source.aligned)} provenance=${text(source.provenanceAligned)}`,
|
||||
`firstDrift=${firstDrift === null ? "-" : `${text(firstDrift.path)} expected=${text(firstDrift.expected)} actual=${text(firstDrift.actual)}`}`,
|
||||
`runtime=${text(result.runtimeAlignment)} live=${text(live?.name)}@${shortSha(live?.canonicalSha256)} embedded=${text(embedded?.name)}@${shortSha(embedded?.canonicalSha256)} commit=${text(embedded?.sourceCommit ?? record(result.runtimeTarget).sourceCommit)} candidates=${text(embedded?.candidateCount)}`,
|
||||
`runtimeReason=live:${text(live?.reason)} embedded:${text(embedded?.reason)}`,
|
||||
`written=${Array.isArray(files.written) && files.written.length > 0 ? files.written.join(",") : "-"}`,
|
||||
];
|
||||
if (typeof result.next === "string") lines.push(`next=${result.next}`);
|
||||
return renderedCliResult(result.ok !== false, String(result.action), `${lines.join("\n")}\n`);
|
||||
}
|
||||
|
||||
export function canonicalizePacPipelineSpec(value: unknown, path: readonly (string | number)[] = []): unknown {
|
||||
if (Array.isArray(value)) return value.map((item, index) => canonicalizePacPipelineSpec(item, [...path, index]));
|
||||
if (!isRecord(value)) return value;
|
||||
const output: Record<string, unknown> = {};
|
||||
for (const key of Object.keys(value).sort()) {
|
||||
const child = value[key];
|
||||
const childPath = [...path, key];
|
||||
if (isProvenTektonAdmissionDefault(childPath, child)) continue;
|
||||
output[key] = canonicalizePacPipelineSpec(child, childPath);
|
||||
}
|
||||
return output;
|
||||
}
|
||||
|
||||
export function canonicalSha256(value: unknown): string {
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify(canonicalizePacPipelineSpec(value))).digest("hex")}`;
|
||||
}
|
||||
|
||||
export function firstPacSourceArtifactDrift(expected: unknown, actual: unknown, path = "$"): PacSourceArtifactDrift | null {
|
||||
const left = canonicalizePacPipelineSpec(expected);
|
||||
const right = canonicalizePacPipelineSpec(actual);
|
||||
return firstCanonicalDrift(left, right, path);
|
||||
}
|
||||
|
||||
function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree: string): DesiredArtifact {
|
||||
const sourceArtifact = binding.consumer.sourceArtifact;
|
||||
const rendered = sourceArtifact.renderer === "agentrun-control-plane"
|
||||
? renderAgentRunDesiredPipeline(binding)
|
||||
: renderHwlabDesiredPipeline(binding, sourceWorktree);
|
||||
const pipeline = withPipelineProvenance(rendered.pipeline, rendered.provenance, sourceArtifact.mode);
|
||||
const desiredSpec = requiredRecord(pipeline.spec, "rendered Pipeline.spec");
|
||||
assertPipelineIdentity(pipeline, binding);
|
||||
if (sourceArtifact.renderer === "hwlab-runtime-lane") assertHwlabPipelineContracts(desiredSpec);
|
||||
const pipelineRun = sourceArtifact.mode === "embedded-pipeline-spec"
|
||||
? embeddedPipelineRun(binding, desiredSpec, rendered.provenance)
|
||||
: remotePipelineRun(binding, pipeline, rendered.provenance);
|
||||
const files = sourceArtifact.mode === "embedded-pipeline-spec"
|
||||
? [{ path: sourceArtifact.pipelineRunPath, content: yaml(pipelineRun) }]
|
||||
: [
|
||||
{ path: requiredString(sourceArtifact.pipelinePath, "sourceArtifact.pipelinePath"), content: `${JSON.stringify(pipeline)}\n` },
|
||||
{ path: sourceArtifact.pipelineRunPath, content: yaml(pipelineRun) },
|
||||
];
|
||||
return { pipeline, pipelineRun, provenance: rendered.provenance, desiredSpec, files };
|
||||
}
|
||||
|
||||
function renderAgentRunDesiredPipeline(binding: PacSourceArtifactBinding): { pipeline: Record<string, unknown>; provenance: PacSourceArtifactProvenance } {
|
||||
const { spec } = resolveAgentRunLaneTarget({ node: binding.consumer.node, lane: binding.consumer.lane });
|
||||
const expectedConfigRef = `${AGENTRUN_CONFIG_PATH}#controlPlane.lanes.${spec.lane}`;
|
||||
assertConfigRef(binding.consumer.sourceArtifact.configRef, expectedConfigRef);
|
||||
assertBindingIdentity(binding, {
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
namespace: spec.ci.namespace,
|
||||
pipeline: spec.ci.pipeline,
|
||||
pipelineRunPrefix: spec.ci.pipelineRunPrefix,
|
||||
serviceAccount: spec.ci.serviceAccountName,
|
||||
sourceBranch: spec.source.branch,
|
||||
gitopsBranch: spec.gitops.branch,
|
||||
});
|
||||
return {
|
||||
pipeline: renderAgentRunPipelineManifest(spec),
|
||||
provenance: {
|
||||
configRef: expectedConfigRef,
|
||||
effectiveConfigSha256: stableJsonSha256(spec),
|
||||
renderer: "agentrun-control-plane",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record<string, unknown>; provenance: PacSourceArtifactProvenance } {
|
||||
if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new Error(`HWLAB source artifact lane ${binding.consumer.lane} is not declared`);
|
||||
const spec = hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node);
|
||||
const expectedConfigRef = `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.targets.${spec.nodeId}`;
|
||||
assertConfigRef(binding.consumer.sourceArtifact.configRef, expectedConfigRef);
|
||||
assertBindingIdentity(binding, {
|
||||
node: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
namespace: "hwlab-ci",
|
||||
pipeline: spec.pipeline,
|
||||
pipelineRunPrefix: spec.pipelineRunPrefix,
|
||||
serviceAccount: spec.serviceAccountName,
|
||||
sourceBranch: spec.sourceBranch,
|
||||
gitopsBranch: spec.gitopsBranch,
|
||||
});
|
||||
const pipeline = renderHwlabPipelineFromOwningYaml(spec, sourceWorktree);
|
||||
return {
|
||||
pipeline,
|
||||
provenance: {
|
||||
configRef: expectedConfigRef,
|
||||
effectiveConfigSha256: stableJsonSha256(spec),
|
||||
renderer: "hwlab-runtime-lane",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): Record<string, unknown> {
|
||||
const temporaryRoot = mkdtempSync(resolve(tmpdir(), "unidesk-pac-source-artifact-"));
|
||||
const temporarySource = resolve(temporaryRoot, "source");
|
||||
const output = resolve(temporaryRoot, "rendered");
|
||||
const sourceCommit = git(sourceWorktree, ["rev-parse", "HEAD"]);
|
||||
try {
|
||||
copySourceWorktreeForRender(sourceWorktree, temporarySource);
|
||||
const overlayRecord = nodeRuntimeRenderOverlay(spec);
|
||||
const deployPath = resolve(temporarySource, "deploy", "deploy.yaml");
|
||||
const deploy = parseYamlRecord(readFileSync(deployPath, "utf8"), deployPath);
|
||||
writeFileSync(deployPath, `${Bun.YAML.stringify(applyNodeRuntimeDeployYamlOverlay(deploy, overlayRecord)).trim()}\n`);
|
||||
const renderArgs = [
|
||||
"scripts/run-bun.mjs",
|
||||
"scripts/gitops-render.mjs",
|
||||
"--lane", spec.lane,
|
||||
"--node", spec.nodeId,
|
||||
"--gitops-root", nodeRuntimeGitopsRoot(spec),
|
||||
"--catalog-path", spec.catalogPath,
|
||||
"--image-tag-mode", "full",
|
||||
"--source-revision", sourceCommit,
|
||||
"--source-repo", spec.gitUrl,
|
||||
"--source-branch", spec.sourceBranch,
|
||||
"--gitops-branch", spec.gitopsBranch,
|
||||
"--git-read-url", spec.gitReadUrl,
|
||||
"--git-write-url", spec.gitWriteUrl,
|
||||
"--registry-prefix", spec.registryPrefix,
|
||||
"--runtime-endpoint", spec.publicApiUrl,
|
||||
"--web-endpoint", spec.publicWebUrl,
|
||||
"--out", output,
|
||||
];
|
||||
runChecked("node", renderArgs, temporarySource, 120_000, "HWLAB owning YAML renderer");
|
||||
const overlay = Buffer.from(JSON.stringify(overlayRecord), "utf8").toString("base64");
|
||||
const postprocess = [
|
||||
"set -eu",
|
||||
`render_dir=${shellQuote(output)}`,
|
||||
`overlay_b64=${shellQuote(overlay)}`,
|
||||
...nodeRuntimePipelinePostprocessScript(),
|
||||
].join("\n");
|
||||
runChecked("sh", ["-c", postprocess], temporarySource, 120_000, "HWLAB UniDesk domain postprocess/verify renderer");
|
||||
const path = resolve(output, spec.tektonDir, "pipeline.yaml");
|
||||
if (!existsSync(path)) throw new Error(`HWLAB domain renderer did not produce ${path}`);
|
||||
return parseYamlRecord(readFileSync(path, "utf8"), path);
|
||||
} finally {
|
||||
rmSync(temporaryRoot, { recursive: true, force: true });
|
||||
}
|
||||
}
|
||||
|
||||
function copySourceWorktreeForRender(sourceWorktree: string, destination: string): void {
|
||||
const excluded = new Set([".git", ".worktree", ".state", "node_modules", "coverage", "tmp", ".tmp"]);
|
||||
cpSync(sourceWorktree, destination, {
|
||||
recursive: true,
|
||||
filter: (source) => {
|
||||
const rel = relative(sourceWorktree, source);
|
||||
if (rel === "") return true;
|
||||
return !excluded.has(rel.split(sep)[0] ?? "");
|
||||
},
|
||||
});
|
||||
const nodeModules = resolve(sourceWorktree, "node_modules");
|
||||
if (existsSync(nodeModules)) symlinkSync(nodeModules, resolve(destination, "node_modules"), "dir");
|
||||
}
|
||||
|
||||
function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>, provenance: PacSourceArtifactProvenance): Record<string, unknown> {
|
||||
return {
|
||||
apiVersion: "tekton.dev/v1",
|
||||
kind: "PipelineRun",
|
||||
metadata: {
|
||||
name: `${binding.consumer.pipelineRunPrefix}-{{ revision }}`,
|
||||
namespace: binding.consumer.namespace,
|
||||
annotations: pipelineRunAnnotations(binding, provenance),
|
||||
labels: pipelineRunLabels(binding, "agentrun"),
|
||||
},
|
||||
spec: {
|
||||
pipelineSpec: desiredSpec,
|
||||
taskRunTemplate: taskRunTemplate(binding),
|
||||
params: pipelineRunParams(binding, desiredSpec),
|
||||
workspaces: pipelineRunWorkspaces(binding, desiredSpec),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function remotePipelineRun(binding: PacSourceArtifactBinding, pipeline: Record<string, unknown>, provenance: PacSourceArtifactProvenance): Record<string, unknown> {
|
||||
const desiredSpec = requiredRecord(pipeline.spec, "rendered Pipeline.spec");
|
||||
const pipelinePath = requiredString(binding.consumer.sourceArtifact.pipelinePath, "sourceArtifact.pipelinePath");
|
||||
const annotations = {
|
||||
...pipelineRunAnnotations(binding, provenance),
|
||||
"pipelinesascode.tekton.dev/pipeline": pipelinePath,
|
||||
};
|
||||
if (provenance.renderer === "hwlab-runtime-lane") {
|
||||
if (!isHwlabRuntimeLane(binding.consumer.lane)) throw new Error(`HWLAB source artifact lane ${binding.consumer.lane} is not declared`);
|
||||
const spec = hwlabRuntimeLaneSpecForNode(binding.consumer.lane, binding.consumer.node);
|
||||
Object.assign(annotations, {
|
||||
"hwlab.pikastech.local/ci-contract": "tekton-native-primitive-tasks",
|
||||
"hwlab.pikastech.local/download-profile": spec.downloadProfileId,
|
||||
"hwlab.pikastech.local/gitops-branch": spec.gitopsBranch,
|
||||
"hwlab.pikastech.local/network-profile": spec.networkProfileId,
|
||||
"hwlab.pikastech.local/node": spec.nodeId,
|
||||
"hwlab.pikastech.local/policy": "native-per-service-taskrun-image-publish",
|
||||
"hwlab.pikastech.local/runtime-path": spec.runtimePath,
|
||||
"hwlab.pikastech.local/source-branch": spec.sourceBranch,
|
||||
"hwlab.pikastech.local/source-config": binding.consumer.sourceArtifact.pipelineRunPath,
|
||||
});
|
||||
}
|
||||
return {
|
||||
apiVersion: "tekton.dev/v1",
|
||||
kind: "PipelineRun",
|
||||
metadata: {
|
||||
generateName: `${binding.consumer.pipelineRunPrefix}-`,
|
||||
namespace: binding.consumer.namespace,
|
||||
annotations,
|
||||
labels: pipelineRunLabels(binding, "hwlab"),
|
||||
},
|
||||
spec: {
|
||||
timeouts: { pipeline: binding.repository.params.pipeline_timeout },
|
||||
taskRunTemplate: taskRunTemplate(binding),
|
||||
pipelineRef: { name: binding.consumer.pipeline },
|
||||
workspaces: pipelineRunWorkspaces(binding, desiredSpec),
|
||||
params: pipelineRunParams(binding, desiredSpec),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: PacSourceArtifactProvenance): Record<string, string> {
|
||||
const branch = requiredParam(binding, "source_branch");
|
||||
return {
|
||||
"pipelinesascode.tekton.dev/on-event": "[push]",
|
||||
"pipelinesascode.tekton.dev/on-target-branch": `[${branch}]`,
|
||||
"pipelinesascode.tekton.dev/on-cel-expression": `event == 'push' && target_branch == '${branch}' && node == '${binding.consumer.node}'`,
|
||||
"pipelinesascode.tekton.dev/max-keep-runs": String(binding.consumer.sourceArtifact.maxKeepRuns),
|
||||
[provenanceKeys.configRef]: provenance.configRef,
|
||||
[provenanceKeys.effectiveConfigSha256]: provenance.effectiveConfigSha256,
|
||||
[provenanceKeys.renderer]: provenance.renderer,
|
||||
[provenanceKeys.mode]: binding.consumer.sourceArtifact.mode,
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineRunLabels(binding: PacSourceArtifactBinding, partOf: "agentrun" | "hwlab"): Record<string, string> {
|
||||
return partOf === "agentrun"
|
||||
? {
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
"agentrun.pikastech.local/lane": "v0.2",
|
||||
"agentrun.pikastech.local/node": binding.consumer.node,
|
||||
"agentrun.pikastech.local/source-commit": "{{ revision }}",
|
||||
"agentrun.pikastech.local/trigger": "pipelines-as-code",
|
||||
}
|
||||
: {
|
||||
"app.kubernetes.io/name": `${binding.consumer.id}-pac`,
|
||||
"app.kubernetes.io/part-of": "hwlab",
|
||||
"unidesk.ai/source-commit": "{{ revision }}",
|
||||
"hwlab.pikastech.local/gitops-target": binding.consumer.lane,
|
||||
"hwlab.pikastech.local/source-commit": "{{ revision }}",
|
||||
"hwlab.pikastech.local/trigger": "pipelines-as-code",
|
||||
};
|
||||
}
|
||||
|
||||
function taskRunTemplate(binding: PacSourceArtifactBinding): Record<string, unknown> {
|
||||
return {
|
||||
serviceAccountName: `{{ service_account }}`,
|
||||
podTemplate: {
|
||||
hostNetwork: true,
|
||||
dnsPolicy: "ClusterFirstWithHostNet",
|
||||
securityContext: { fsGroup: 1000 },
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>): Record<string, unknown>[] {
|
||||
const params = arrayRecords(desiredSpec.params, "Pipeline.spec.params");
|
||||
return params.map((param) => {
|
||||
const name = requiredString(param.name, "Pipeline.spec.params[].name");
|
||||
if (name === "revision" || name === "source-commit") return { name, value: "{{ revision }}" };
|
||||
if (name === "source-stage-ref") return { name, value: "{{ source_snapshot_prefix }}/{{ revision }}" };
|
||||
if (name === "git-url") return { name, value: "{{ repo_url }}" };
|
||||
const repositoryParam = name.replaceAll("-", "_");
|
||||
if (Object.prototype.hasOwnProperty.call(binding.repository.params, repositoryParam)) {
|
||||
return { name, value: `{{ ${repositoryParam} }}` };
|
||||
}
|
||||
if (Object.prototype.hasOwnProperty.call(param, "default")) return { name, value: param.default };
|
||||
throw new Error(`Pipeline param ${name} has no default or PaC repository parameter`);
|
||||
});
|
||||
}
|
||||
|
||||
function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>): Record<string, unknown>[] {
|
||||
return arrayRecords(desiredSpec.workspaces, "Pipeline.spec.workspaces").map((workspace) => {
|
||||
const name = requiredString(workspace.name, "Pipeline.spec.workspaces[].name");
|
||||
if (name === "source") {
|
||||
return {
|
||||
name,
|
||||
volumeClaimTemplate: {
|
||||
spec: {
|
||||
accessModes: ["ReadWriteOnce"],
|
||||
resources: { requests: { storage: requiredParam(binding, "workspace_pvc_size") } },
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
if (name === "git-ssh") return { name, secret: { secretName: requiredParam(binding, "git_ssh_secret") } };
|
||||
throw new Error(`unsupported Pipeline workspace ${name}; declare a renderer mapping before generating the source artifact`);
|
||||
});
|
||||
}
|
||||
|
||||
function withPipelineProvenance(pipeline: Record<string, unknown>, provenance: PacSourceArtifactProvenance, mode: PacSourceArtifactMode): Record<string, unknown> {
|
||||
const next = structuredClone(pipeline);
|
||||
const metadata = requiredRecord(next.metadata, "rendered Pipeline.metadata");
|
||||
const annotations = isRecord(metadata.annotations) ? metadata.annotations : {};
|
||||
metadata.annotations = {
|
||||
...annotations,
|
||||
[provenanceKeys.configRef]: provenance.configRef,
|
||||
[provenanceKeys.effectiveConfigSha256]: provenance.effectiveConfigSha256,
|
||||
[provenanceKeys.renderer]: provenance.renderer,
|
||||
[provenanceKeys.mode]: mode,
|
||||
};
|
||||
return next;
|
||||
}
|
||||
|
||||
function inspectSourceArtifact(sourceWorktree: string, binding: PacSourceArtifactBinding, desired: DesiredArtifact): SourceInspection {
|
||||
const sourceArtifact = binding.consumer.sourceArtifact;
|
||||
const pipelineRunPath = safeArtifactPath(sourceWorktree, sourceArtifact.pipelineRunPath);
|
||||
const pipelinePath = sourceArtifact.pipelinePath === null ? null : safeArtifactPath(sourceWorktree, sourceArtifact.pipelinePath);
|
||||
if (!existsSync(pipelineRunPath) || (pipelinePath !== null && !existsSync(pipelinePath))) {
|
||||
return { status: "missing", aligned: false, pipelineCanonicalSha256: null, pipelineRunCanonicalSha256: null, firstDrift: { path: "$", expected: "generated artifact", actual: "missing" }, provenanceAligned: false };
|
||||
}
|
||||
const actualPipelineRun = parseYamlRecord(readFileSync(pipelineRunPath, "utf8"), sourceArtifact.pipelineRunPath);
|
||||
const actualPipeline = sourceArtifact.mode === "remote-pipeline-annotation"
|
||||
? parseYamlRecord(readFileSync(requiredString(pipelinePath, "pipelinePath"), "utf8"), requiredString(sourceArtifact.pipelinePath, "pipelinePath"))
|
||||
: {
|
||||
apiVersion: "tekton.dev/v1",
|
||||
kind: "Pipeline",
|
||||
metadata: desired.pipeline.metadata,
|
||||
spec: requiredRecord(requiredRecord(actualPipelineRun.spec, "PipelineRun.spec").pipelineSpec, "PipelineRun.spec.pipelineSpec"),
|
||||
};
|
||||
const pipelineDrift = firstPacSourceArtifactDrift(desired.pipeline, actualPipeline);
|
||||
const pipelineRunDrift = firstPacSourceArtifactDrift(desired.pipelineRun, actualPipelineRun);
|
||||
const actualProvenance = sourceArtifact.mode === "remote-pipeline-annotation"
|
||||
? provenanceFromManifest(actualPipeline)
|
||||
: provenanceFromManifest(actualPipelineRun);
|
||||
const wrapperProvenance = provenanceFromManifest(actualPipelineRun);
|
||||
const provenanceAligned = provenanceEquals(actualProvenance, desired.provenance) && provenanceEquals(wrapperProvenance, desired.provenance);
|
||||
const aligned = pipelineDrift === null && pipelineRunDrift === null && provenanceAligned;
|
||||
return {
|
||||
status: aligned ? "aligned" : "drifted",
|
||||
aligned,
|
||||
pipelineCanonicalSha256: canonicalSha256(requiredRecord(actualPipeline.spec, "source Pipeline.spec")),
|
||||
pipelineRunCanonicalSha256: canonicalSha256(actualPipelineRun),
|
||||
firstDrift: pipelineDrift ?? pipelineRunDrift ?? (provenanceAligned ? null : { path: "$.metadata.annotations.provenance", expected: JSON.stringify(desired.provenance), actual: JSON.stringify(actualProvenance) }),
|
||||
provenanceAligned,
|
||||
};
|
||||
}
|
||||
|
||||
function validateSourceWorktree(input: string, binding: PacSourceArtifactBinding): string {
|
||||
if (!existsSync(input) || !statSync(input).isDirectory()) throw new Error(`source worktree does not exist or is not a directory: ${input}`);
|
||||
const worktree = realpathSync(input);
|
||||
const top = realpathSync(git(worktree, ["rev-parse", "--show-toplevel"]));
|
||||
if (top !== worktree) throw new Error(`--source-worktree must be the git worktree root; resolved ${top}`);
|
||||
const remote = git(worktree, ["remote", "get-url", "origin"]);
|
||||
const repoIdentity = binding.repository.repo.toLowerCase().replace(/\.git$/u, "");
|
||||
const canonicalIdentity = repoIdentity.replace(/^pikastech-/u, "pikastech/");
|
||||
const allowedIdentities = new Set([
|
||||
canonicalIdentity,
|
||||
`${binding.repository.owner}/${repoIdentity}`.toLowerCase(),
|
||||
remoteRepositoryIdentity(binding.repository.cloneUrl),
|
||||
]);
|
||||
const observedIdentity = remoteRepositoryIdentity(remote);
|
||||
if (!allowedIdentities.has(observedIdentity)) {
|
||||
throw new Error(`source worktree origin does not match PaC repository ${binding.repository.repo}; observed ${remote}`);
|
||||
}
|
||||
safeArtifactPath(worktree, binding.consumer.sourceArtifact.pipelineRunPath);
|
||||
if (binding.consumer.sourceArtifact.pipelinePath !== null) safeArtifactPath(worktree, binding.consumer.sourceArtifact.pipelinePath);
|
||||
return worktree;
|
||||
}
|
||||
|
||||
function remoteRepositoryIdentity(remote: string): string {
|
||||
const trimmed = remote.trim();
|
||||
const scp = /^[^/@\s]+@[^:\s]+:(.+)$/u.exec(trimmed);
|
||||
let path = scp?.[1] ?? "";
|
||||
if (path.length === 0) {
|
||||
try {
|
||||
path = new URL(trimmed).pathname;
|
||||
} catch {
|
||||
throw new Error(`git remote must be an SSH or URL repository identity; observed ${remote}`);
|
||||
}
|
||||
}
|
||||
return path.replace(/^\/+|\/+$/gu, "").replace(/\.git$/iu, "").toLowerCase();
|
||||
}
|
||||
|
||||
function writeArtifactFilesAtomically(sourceWorktree: string, files: readonly { readonly path: string; readonly content: string }[]): string[] {
|
||||
const pending = files.map((file) => ({ ...file, output: safeArtifactPath(sourceWorktree, file.path) }))
|
||||
.filter((file) => !existsSync(file.output) || readFileSync(file.output, "utf8") !== file.content);
|
||||
if (pending.length === 0) return [];
|
||||
for (const file of pending) {
|
||||
mkdirSync(dirname(file.output), { recursive: true });
|
||||
safeArtifactPath(sourceWorktree, file.path);
|
||||
}
|
||||
const token = `${process.pid}-${Date.now()}`;
|
||||
const staged = pending.map((file, index) => ({ ...file, temporary: `${file.output}.unidesk-${token}-${index}.tmp` }));
|
||||
try {
|
||||
for (const file of staged) writeFileSync(file.temporary, file.content, { flag: "wx" });
|
||||
for (const file of staged) renameSync(file.temporary, file.output);
|
||||
} finally {
|
||||
for (const file of staged) rmSync(file.temporary, { force: true });
|
||||
}
|
||||
return pending.map((file) => file.path);
|
||||
}
|
||||
|
||||
function safeArtifactPath(worktree: string, relativePath: string): string {
|
||||
if (relativePath.length === 0 || isAbsolute(relativePath) || relativePath.split(/[\\/]/u).includes("..")) {
|
||||
throw new Error(`source artifact path must be a non-empty worktree-relative path without ..: ${relativePath}`);
|
||||
}
|
||||
const root = realpathSync(worktree);
|
||||
const candidate = resolve(root, relativePath);
|
||||
if (candidate !== root && !candidate.startsWith(`${root}${sep}`)) throw new Error(`source artifact path escapes worktree: ${relativePath}`);
|
||||
let current = root;
|
||||
for (const segment of relativePath.split(/[\\/]/u).filter(Boolean)) {
|
||||
current = resolve(current, segment);
|
||||
if (!existsSync(current)) continue;
|
||||
if (lstatSync(current).isSymbolicLink()) throw new Error(`source artifact path crosses symlink: ${relativePath}`);
|
||||
const resolved = realpathSync(current);
|
||||
if (resolved !== root && !resolved.startsWith(`${root}${sep}`)) throw new Error(`source artifact path resolves outside worktree: ${relativePath}`);
|
||||
}
|
||||
return candidate;
|
||||
}
|
||||
|
||||
function assertConfigRef(actual: string, expected: string): void {
|
||||
if (actual !== expected) throw new Error(`sourceArtifact.configRef must equal resolved owning selector ${expected}; observed ${actual}`);
|
||||
const [file, selector] = actual.split("#", 2);
|
||||
if (file === undefined || selector === undefined || selector.length === 0) throw new Error(`invalid sourceArtifact.configRef: ${actual}`);
|
||||
const parsed = Bun.YAML.parse(readFileSync(rootPath(...file.split("/")), "utf8")) as unknown;
|
||||
let value = parsed;
|
||||
for (const segment of selector.split(".")) {
|
||||
if (!isRecord(value) || !Object.prototype.hasOwnProperty.call(value, segment)) throw new Error(`sourceArtifact.configRef selector does not exist: ${actual}`);
|
||||
value = value[segment];
|
||||
}
|
||||
if (!isRecord(value)) throw new Error(`sourceArtifact.configRef must resolve to an owning YAML object: ${actual}`);
|
||||
}
|
||||
|
||||
function assertBindingIdentity(binding: PacSourceArtifactBinding, expected: {
|
||||
node: string;
|
||||
lane: string;
|
||||
namespace: string;
|
||||
pipeline: string;
|
||||
pipelineRunPrefix: string;
|
||||
serviceAccount: string;
|
||||
sourceBranch: string;
|
||||
gitopsBranch: string;
|
||||
}): void {
|
||||
const observed = {
|
||||
node: binding.consumer.node,
|
||||
lane: binding.consumer.lane,
|
||||
namespace: binding.consumer.namespace,
|
||||
pipeline: binding.consumer.pipeline,
|
||||
pipelineRunPrefix: binding.consumer.pipelineRunPrefix,
|
||||
serviceAccount: requiredParam(binding, "service_account"),
|
||||
sourceBranch: requiredParam(binding, "source_branch"),
|
||||
gitopsBranch: requiredParam(binding, "gitops_branch"),
|
||||
};
|
||||
for (const key of Object.keys(expected) as Array<keyof typeof expected>) {
|
||||
if (observed[key] !== expected[key]) throw new Error(`PaC ${binding.consumer.id} ${key}=${observed[key]} does not match owning renderer ${expected[key]}`);
|
||||
}
|
||||
if (requiredParam(binding, "pipeline_name") !== expected.pipeline) throw new Error(`PaC ${binding.consumer.id} repository pipeline_name does not match ${expected.pipeline}`);
|
||||
if (requiredParam(binding, "pipeline_run_prefix") !== expected.pipelineRunPrefix) throw new Error(`PaC ${binding.consumer.id} repository pipeline_run_prefix does not match ${expected.pipelineRunPrefix}`);
|
||||
if (requiredParam(binding, "node") !== expected.node) throw new Error(`PaC ${binding.consumer.id} repository node does not match ${expected.node}`);
|
||||
}
|
||||
|
||||
function assertPipelineIdentity(pipeline: Record<string, unknown>, binding: PacSourceArtifactBinding): void {
|
||||
if (pipeline.apiVersion !== "tekton.dev/v1" || pipeline.kind !== "Pipeline") throw new Error("domain renderer must return tekton.dev/v1 Pipeline");
|
||||
const metadata = requiredRecord(pipeline.metadata, "rendered Pipeline.metadata");
|
||||
if (metadata.name !== binding.consumer.pipeline) throw new Error(`rendered Pipeline name ${String(metadata.name)} does not match consumer ${binding.consumer.pipeline}`);
|
||||
if (metadata.namespace !== binding.consumer.namespace) throw new Error(`rendered Pipeline namespace ${String(metadata.namespace)} does not match consumer ${binding.consumer.namespace}`);
|
||||
}
|
||||
|
||||
export function assertHwlabPipelineContracts(spec: Record<string, unknown>): void {
|
||||
const text = JSON.stringify(spec);
|
||||
const required = [
|
||||
"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",
|
||||
];
|
||||
const missing = required.filter((value) => !text.includes(value));
|
||||
if (missing.length > 0) throw new Error(`HWLAB domain renderer missing required postprocess/verify refresh contract: ${missing.join(", ")}`);
|
||||
}
|
||||
|
||||
function provenanceFromManifest(manifest: Record<string, unknown>): PacSourceArtifactProvenance | null {
|
||||
const metadata = isRecord(manifest.metadata) ? manifest.metadata : {};
|
||||
const annotations = isRecord(metadata.annotations) ? metadata.annotations : {};
|
||||
const configRef = annotations[provenanceKeys.configRef];
|
||||
const effectiveConfigSha256 = annotations[provenanceKeys.effectiveConfigSha256];
|
||||
const renderer = annotations[provenanceKeys.renderer];
|
||||
if (typeof configRef !== "string" || typeof effectiveConfigSha256 !== "string" || (renderer !== "agentrun-control-plane" && renderer !== "hwlab-runtime-lane")) return null;
|
||||
return { configRef, effectiveConfigSha256, renderer };
|
||||
}
|
||||
|
||||
function provenanceEquals(actual: PacSourceArtifactProvenance | null, expected: PacSourceArtifactProvenance): boolean {
|
||||
return actual !== null
|
||||
&& actual.configRef === expected.configRef
|
||||
&& actual.effectiveConfigSha256 === expected.effectiveConfigSha256
|
||||
&& actual.renderer === expected.renderer;
|
||||
}
|
||||
|
||||
function firstCanonicalDrift(expected: unknown, actual: unknown, path: string): PacSourceArtifactDrift | null {
|
||||
if (Object.is(expected, actual)) return null;
|
||||
if (Array.isArray(expected) || Array.isArray(actual)) {
|
||||
if (!Array.isArray(expected) || !Array.isArray(actual)) return drift(path, expected, actual);
|
||||
if (expected.length !== actual.length) return drift(`${path}.length`, expected.length, actual.length);
|
||||
for (let index = 0; index < expected.length; index += 1) {
|
||||
const child = firstCanonicalDrift(expected[index], actual[index], `${path}[${index}]`);
|
||||
if (child !== null) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
if (isRecord(expected) || isRecord(actual)) {
|
||||
if (!isRecord(expected) || !isRecord(actual)) return drift(path, expected, actual);
|
||||
const keys = [...new Set([...Object.keys(expected), ...Object.keys(actual)])].sort();
|
||||
for (const key of keys) {
|
||||
if (!Object.prototype.hasOwnProperty.call(expected, key) || !Object.prototype.hasOwnProperty.call(actual, key)) return drift(`${path}.${key}`, expected[key], actual[key]);
|
||||
const child = firstCanonicalDrift(expected[key], actual[key], `${path}.${key}`);
|
||||
if (child !== null) return child;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
return drift(path, expected, actual);
|
||||
}
|
||||
|
||||
function isProvenTektonAdmissionDefault(path: readonly (string | number)[], value: unknown): boolean {
|
||||
const normalized = path.map((segment) => typeof segment === "number" ? "*" : segment).join(".");
|
||||
const emptyRecord = isRecord(value) && Object.keys(value).length === 0;
|
||||
const atTaskSpec = (suffix: string): boolean => [
|
||||
`tasks.*.taskSpec.${suffix}`,
|
||||
`spec.tasks.*.taskSpec.${suffix}`,
|
||||
`spec.pipelineSpec.tasks.*.taskSpec.${suffix}`,
|
||||
].includes(normalized);
|
||||
if (atTaskSpec("metadata")) return emptyRecord;
|
||||
if (atTaskSpec("spec")) return value === null;
|
||||
if (atTaskSpec("params.*.type")) return value === "string";
|
||||
if (atTaskSpec("results.*.type")) return value === "string";
|
||||
if (atTaskSpec("steps.*.computeResources")) return emptyRecord;
|
||||
if (atTaskSpec("sidecars.*.computeResources")) return emptyRecord;
|
||||
return false;
|
||||
}
|
||||
|
||||
function drift(path: string, expected: unknown, actual: unknown): PacSourceArtifactDrift {
|
||||
return { path, expected: compactValue(expected), actual: compactValue(actual) };
|
||||
}
|
||||
|
||||
function compactValue(value: unknown): string {
|
||||
const raw = JSON.stringify(value);
|
||||
if (raw === undefined) return String(value);
|
||||
return raw.length <= 160 ? raw : `${raw.slice(0, 157)}...`;
|
||||
}
|
||||
|
||||
function unavailableRuntimeObservation(): PacSourceArtifactRuntimeObservation {
|
||||
const item: PacSourceArtifactRuntimeItem = { status: "unavailable", name: null, canonicalSha256: null, firstDrift: null, provenanceAligned: false, configRef: null, effectiveConfigSha256: null, sourceCommit: null, reason: "runtime-observer-not-configured", candidateCount: 0 };
|
||||
return { live: item, embedded: item };
|
||||
}
|
||||
|
||||
function parseYamlRecord(text: string, label: string): Record<string, unknown> {
|
||||
const parsed = Bun.YAML.parse(text) as unknown;
|
||||
if (!isRecord(parsed)) throw new Error(`${label} must contain one YAML object`);
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function yaml(value: Record<string, unknown>): string {
|
||||
return `${Bun.YAML.stringify(value).trim()}\n`;
|
||||
}
|
||||
|
||||
function requiredParam(binding: PacSourceArtifactBinding, key: string): string {
|
||||
return requiredString(binding.repository.params[key], `PaC repository ${binding.repository.id} params.${key}`);
|
||||
}
|
||||
|
||||
function requiredString(value: unknown, label: string): string {
|
||||
if (typeof value !== "string" || value.length === 0) throw new Error(`${label} must be a non-empty string`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function requiredRecord(value: unknown, label: string): Record<string, unknown> {
|
||||
if (!isRecord(value)) throw new Error(`${label} must be an object`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function arrayRecords(value: unknown, label: string): Record<string, unknown>[] {
|
||||
if (!Array.isArray(value) || value.some((item) => !isRecord(item))) throw new Error(`${label} must be an array of objects`);
|
||||
return value as Record<string, unknown>[];
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function record(value: unknown): Record<string, unknown> {
|
||||
return isRecord(value) ? value : {};
|
||||
}
|
||||
|
||||
function isSourceArtifactAction(value: string | undefined): value is PacSourceArtifactAction {
|
||||
return value === "plan" || value === "check" || value === "write" || value === "status" || value === "verify-runtime";
|
||||
}
|
||||
|
||||
function git(worktree: string, args: readonly string[]): string {
|
||||
const result = spawnSync("git", ["-C", worktree, ...args], { encoding: "utf8", timeout: 15_000, maxBuffer: 1024 * 1024 });
|
||||
if (result.status !== 0) throw new Error(`git ${args.join(" ")} failed for ${worktree}: ${(result.stderr || result.stdout).trim().slice(0, 1000)}`);
|
||||
return result.stdout.trim();
|
||||
}
|
||||
|
||||
function runChecked(command: string, args: readonly string[], cwd: string, timeout: number, label: string): void {
|
||||
const result = spawnSync(command, [...args], { cwd, encoding: "utf8", timeout, maxBuffer: 32 * 1024 * 1024 });
|
||||
if (result.status !== 0) throw new Error(`${label} failed: ${(result.stderr || result.stdout).trim().slice(-4000)}`);
|
||||
}
|
||||
|
||||
function shellQuote(value: string): string {
|
||||
return `'${value.replaceAll("'", `'"'"'`)}'`;
|
||||
}
|
||||
|
||||
function text(value: unknown): string {
|
||||
if (value === null || value === undefined || value === "") return "-";
|
||||
return String(value);
|
||||
}
|
||||
|
||||
function shortSha(value: unknown): string {
|
||||
return typeof value === "string" && value.length > 0 ? value.replace(/^sha256:/u, "").slice(0, 12) : "-";
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { createHash, randomBytes } from "node:crypto";
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { chmodSync, existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { dirname, join } from "node:path";
|
||||
import type { UniDeskConfig } from "./config";
|
||||
@@ -17,11 +17,21 @@ import {
|
||||
sha256Fingerprint,
|
||||
} from "./platform-infra-ops-library";
|
||||
import { materializeYamlComposition } from "./yaml-composition";
|
||||
import {
|
||||
canonicalizePacPipelineSpec,
|
||||
parsePacSourceArtifactOptions,
|
||||
renderPacSourceArtifactResult,
|
||||
runPacSourceArtifact,
|
||||
type PacSourceArtifactBinding,
|
||||
type PacSourceArtifactRuntimeObservation,
|
||||
type PacSourceArtifactSpec,
|
||||
} from "./platform-infra-pipelines-as-code-source-artifact";
|
||||
|
||||
const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml");
|
||||
const configLabel = "config/platform-infra/pipelines-as-code.yaml";
|
||||
const remoteScriptFile = rootPath("scripts", "src", "platform-infra-pipelines-as-code-remote.sh");
|
||||
const evaluatorFile = rootPath("scripts", "native", "cicd", "pac-status-evaluator.cjs");
|
||||
const sourceArtifactRuntimeObserverFile = rootPath("scripts", "native", "cicd", "pac-source-artifact-runtime.mjs");
|
||||
const fieldManager = "unidesk-platform-infra-pipelines-as-code";
|
||||
const y = createYamlFieldReader(configLabel);
|
||||
|
||||
@@ -132,6 +142,7 @@ interface PacConsumer {
|
||||
repositoryRef: string;
|
||||
closeoutGitOpsMirrorFlush: boolean;
|
||||
closeoutGitOpsMirrorLane: "v02" | "v03" | null;
|
||||
sourceArtifact: PacSourceArtifactSpec | null;
|
||||
}
|
||||
|
||||
interface CommonOptions {
|
||||
@@ -216,12 +227,78 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
|
||||
const result = await webhookTest(config, options);
|
||||
return options.json || options.full || options.raw ? result : renderWebhookTest(result);
|
||||
}
|
||||
if (action === "source-artifact") {
|
||||
if (args.length === 1 || args.slice(1).includes("--help") || args.slice(1).includes("-h")) return sourceArtifactHelp();
|
||||
const structuredError = args.includes("--json") || args.includes("--full");
|
||||
try {
|
||||
const options = parsePacSourceArtifactOptions(args.slice(1));
|
||||
const pac = readPacConfig();
|
||||
const target = resolveTarget(pac, options.targetId);
|
||||
const consumer = resolveConsumer(pac, options.consumerId);
|
||||
if (consumer.node.toLowerCase() !== target.id.toLowerCase()) throw new Error(`Pipelines-as-Code consumer ${consumer.id} belongs to ${consumer.node}, not target ${target.id}`);
|
||||
if (consumer.sourceArtifact === null) throw new Error(`Pipelines-as-Code consumer ${consumer.id} has no sourceArtifact owner in ${configLabel}`);
|
||||
const repository = resolveRepository(pac, consumer.repositoryRef);
|
||||
const binding: PacSourceArtifactBinding = {
|
||||
target: { id: target.id },
|
||||
consumer: {
|
||||
id: consumer.id,
|
||||
node: consumer.node,
|
||||
lane: consumer.lane,
|
||||
namespace: consumer.namespace,
|
||||
pipeline: consumer.pipeline,
|
||||
pipelineRunPrefix: consumer.pipelineRunPrefix,
|
||||
sourceArtifact: consumer.sourceArtifact,
|
||||
},
|
||||
repository: {
|
||||
id: repository.id,
|
||||
cloneUrl: repository.cloneUrl,
|
||||
owner: repository.owner,
|
||||
repo: repository.repo,
|
||||
params: repository.params,
|
||||
},
|
||||
};
|
||||
const result = await runPacSourceArtifact(binding, options, async ({ desiredSpec, provenance, sourceCommit }) => await observeSourceArtifactRuntime(config, target, consumer, desiredSpec, provenance, sourceCommit));
|
||||
return options.json || options.full ? result : renderPacSourceArtifactResult(result);
|
||||
} catch (error) {
|
||||
if (structuredError) throw error;
|
||||
return sourceArtifactValidationError(error);
|
||||
}
|
||||
}
|
||||
return { ok: false, error: "unsupported-platform-infra-pipelines-as-code-command", args, help: help() };
|
||||
}
|
||||
|
||||
function sourceArtifactHelp(): RenderedCliResult {
|
||||
const lines = [
|
||||
"PAC SOURCE ARTIFACT",
|
||||
"Generate and verify consumer-declared PaC source artifacts from owning YAML plus the shared domain renderer.",
|
||||
"",
|
||||
"Usage:",
|
||||
" ... source-artifact plan --target <node> --consumer <id> --source-worktree <absolute-path>",
|
||||
" ... source-artifact check --target <node> --consumer <id> --source-worktree <absolute-path>",
|
||||
" ... source-artifact write --target <node> --consumer <id> --source-worktree <absolute-path> --confirm",
|
||||
" ... source-artifact status --target <node> --consumer <id> --source-worktree <absolute-path> [--source-commit <full-sha>]",
|
||||
" ... source-artifact verify-runtime --target <node> --consumer <id> --source-worktree <absolute-path> --source-commit <full-sha>",
|
||||
"",
|
||||
"plan/check/write compare desired YAML-rendered state with source files and never access runtime.",
|
||||
"status is non-gating runtime diagnostics; verify-runtime is fail-closed and binds the PipelineRun to the exact source commit.",
|
||||
"Use --json or --full for explicit structured output; source specs and Secret values are never printed.",
|
||||
];
|
||||
return { ok: true, command: "platform-infra-pipelines-as-code-source-artifact-help", renderedText: `${lines.join("\n")}\n`, contentType: "text/plain" };
|
||||
}
|
||||
|
||||
function sourceArtifactValidationError(error: unknown): RenderedCliResult {
|
||||
const reason = String(error instanceof Error ? error.message : error).replace(/[\r\n]+/gu, " ").slice(0, 1000);
|
||||
return {
|
||||
ok: false,
|
||||
command: "platform-infra-pipelines-as-code-source-artifact-validation",
|
||||
renderedText: `PAC SOURCE ARTIFACT ERROR\nreason=${reason}\nnext=Fix the explicit target, consumer, source worktree, or owning YAML declaration; rerun plan before write.\n`,
|
||||
contentType: "text/plain",
|
||||
};
|
||||
}
|
||||
|
||||
function help(): Record<string, unknown> {
|
||||
return {
|
||||
command: "platform-infra pipelines-as-code plan|apply|status|closeout|history|debug-step",
|
||||
command: "platform-infra pipelines-as-code plan|apply|status|closeout|history|debug-step|source-artifact",
|
||||
configTruth: configLabel,
|
||||
usage: [
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01",
|
||||
@@ -232,6 +309,9 @@ function help(): Record<string, unknown> {
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 [--consumer hwlab-jd01-v03] [--limit 10]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code history --target JD01 --id <pipelinerun>",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code debug-step --target JD01 [--consumer <consumer>] [--id <pipelinerun>] [--json]",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact check --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact write --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --confirm",
|
||||
"bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runtime --target NC01 --consumer agentrun-nc01-v02 --source-worktree /abs/worktree --source-commit <full-sha>",
|
||||
],
|
||||
diagnostics: "webhook-test exists only for bounded connectivity diagnosis and must not be used as delivery evidence.",
|
||||
boundary: "Sole CI trigger path for GH-1552/GH-1607: GitHub PR merge -> GitHub webhook bridge -> Gitea mirror/snapshot -> Pipelines-as-Code -> Tekton -> GitOps/Argo/k8s runtime.",
|
||||
@@ -353,9 +433,112 @@ function parseConsumer(consumer: Record<string, unknown>, path: string, defaultR
|
||||
repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef,
|
||||
closeoutGitOpsMirrorFlush: y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path),
|
||||
closeoutGitOpsMirrorLane: consumer.closeoutGitOpsMirrorLane === undefined ? null : y.enumField(consumer, "closeoutGitOpsMirrorLane", path, ["v02", "v03"] as const),
|
||||
sourceArtifact: consumer.sourceArtifact === undefined ? null : parseSourceArtifact(y.objectField(consumer, "sourceArtifact", path), `${path}.sourceArtifact`),
|
||||
};
|
||||
}
|
||||
|
||||
function parseSourceArtifact(value: Record<string, unknown>, path: string): PacSourceArtifactSpec {
|
||||
const mode = y.enumField(value, "mode", path, ["embedded-pipeline-spec", "remote-pipeline-annotation"] as const);
|
||||
const renderer = y.enumField(value, "renderer", path, ["agentrun-control-plane", "hwlab-runtime-lane"] as const);
|
||||
const pipelineRunPath = sourceArtifactPath(y.stringField(value, "pipelineRunPath", path), `${path}.pipelineRunPath`);
|
||||
const pipelinePath = value.pipelinePath === undefined ? null : sourceArtifactPath(y.stringField(value, "pipelinePath", path), `${path}.pipelinePath`);
|
||||
if (mode === "embedded-pipeline-spec" && pipelinePath !== null) throw new Error(`${path}.pipelinePath is forbidden for embedded-pipeline-spec`);
|
||||
if (mode === "remote-pipeline-annotation" && pipelinePath === null) throw new Error(`${path}.pipelinePath is required for remote-pipeline-annotation`);
|
||||
if (renderer === "agentrun-control-plane" && mode !== "embedded-pipeline-spec") throw new Error(`${path}.renderer agentrun-control-plane requires embedded-pipeline-spec`);
|
||||
if (renderer === "hwlab-runtime-lane" && mode !== "remote-pipeline-annotation") throw new Error(`${path}.renderer hwlab-runtime-lane requires remote-pipeline-annotation`);
|
||||
return {
|
||||
mode,
|
||||
renderer,
|
||||
configRef: y.stringField(value, "configRef", path),
|
||||
pipelineRunPath,
|
||||
pipelinePath,
|
||||
maxKeepRuns: positiveInteger(value, "maxKeepRuns", path),
|
||||
};
|
||||
}
|
||||
|
||||
function sourceArtifactPath(value: string, path: string): string {
|
||||
if (value.startsWith("/") || value.split(/[\\/]/u).includes("..") || !value.endsWith(".yaml")) throw new Error(`${path} must be a worktree-relative .yaml path without ..`);
|
||||
return value;
|
||||
}
|
||||
|
||||
async function observeSourceArtifactRuntime(
|
||||
config: UniDeskConfig,
|
||||
target: PacTarget,
|
||||
consumer: PacConsumer,
|
||||
desiredSpec: Record<string, unknown>,
|
||||
provenance: { readonly configRef: string; readonly effectiveConfigSha256: string; readonly renderer: string },
|
||||
sourceCommit: string | null,
|
||||
): Promise<PacSourceArtifactRuntimeObservation> {
|
||||
const script = renderPacSourceArtifactRuntimeObserverShell({
|
||||
namespace: consumer.namespace,
|
||||
pipeline: consumer.pipeline,
|
||||
pipelineRunPrefix: consumer.pipelineRunPrefix,
|
||||
desiredSpec: canonicalizePacPipelineSpec(desiredSpec),
|
||||
provenance,
|
||||
sourceCommit,
|
||||
});
|
||||
|
||||
const captured = await capture(config, target.route, ["sh"], script);
|
||||
if (captured.exitCode !== 0) {
|
||||
const compact = compactCapture(captured);
|
||||
const detail = String(compact.stderrTail ?? compact.stdoutTail ?? "").trim().split(/\r?\n/u).filter(Boolean)[0]?.replace(/\s+/gu, " ").slice(0, 240);
|
||||
return unavailableSourceArtifactRuntime(`runtime-transport-exit-${captured.exitCode}${detail ? `:${detail}` : ""}`, sourceCommit);
|
||||
}
|
||||
const parsed = parseJsonOutput(captured.stdout);
|
||||
if (parsed === null) return unavailableSourceArtifactRuntime("runtime-observer-invalid-json", sourceCommit);
|
||||
return {
|
||||
live: runtimeSourceArtifactItem(parsed.live),
|
||||
embedded: runtimeSourceArtifactItem(parsed.embedded),
|
||||
};
|
||||
}
|
||||
|
||||
export function renderPacSourceArtifactRuntimeObserverShell(inputValue: Record<string, unknown>): string {
|
||||
const input = JSON.stringify(inputValue);
|
||||
const observer = readFileSync(sourceArtifactRuntimeObserverFile, "utf8").trimEnd();
|
||||
if (observer.includes("NODE_PAC_SOURCE_ARTIFACT_STATUS") || input.includes("JSON_PAC_SOURCE_ARTIFACT_INPUT")) throw new Error("PaC source artifact observer input contains a reserved heredoc delimiter");
|
||||
return `set -eu
|
||||
observer_input=$(mktemp)
|
||||
trap 'rm -f "$observer_input"' EXIT HUP INT TERM
|
||||
cat >"$observer_input" <<'JSON_PAC_SOURCE_ARTIFACT_INPUT'
|
||||
${input}
|
||||
JSON_PAC_SOURCE_ARTIFACT_INPUT
|
||||
PAC_SOURCE_ARTIFACT_INPUT_FILE="$observer_input" node --input-type=module <<'NODE_PAC_SOURCE_ARTIFACT_STATUS'
|
||||
${observer}
|
||||
NODE_PAC_SOURCE_ARTIFACT_STATUS
|
||||
`;
|
||||
}
|
||||
|
||||
function runtimeSourceArtifactItem(value: unknown): PacSourceArtifactRuntimeObservation["live"] {
|
||||
if (typeof value !== "object" || value === null || Array.isArray(value)) return unavailableSourceArtifactRuntime("runtime-observer-invalid-item", null).live;
|
||||
const item = value as Record<string, unknown>;
|
||||
const status = item.status;
|
||||
if (status !== "aligned" && status !== "drifted" && status !== "missing" && status !== "unavailable") return unavailableSourceArtifactRuntime("runtime-observer-invalid-status", null).live;
|
||||
const drift = item.firstDrift;
|
||||
return {
|
||||
status,
|
||||
name: typeof item.name === "string" ? item.name : null,
|
||||
canonicalSha256: typeof item.canonicalSha256 === "string" ? item.canonicalSha256 : null,
|
||||
firstDrift: typeof drift === "object" && drift !== null && !Array.isArray(drift)
|
||||
? {
|
||||
path: String((drift as Record<string, unknown>).path ?? "$"),
|
||||
expected: String((drift as Record<string, unknown>).expected ?? ""),
|
||||
actual: String((drift as Record<string, unknown>).actual ?? ""),
|
||||
}
|
||||
: null,
|
||||
provenanceAligned: item.provenanceAligned === true,
|
||||
configRef: typeof item.configRef === "string" ? item.configRef : null,
|
||||
effectiveConfigSha256: typeof item.effectiveConfigSha256 === "string" ? item.effectiveConfigSha256 : null,
|
||||
sourceCommit: typeof item.sourceCommit === "string" ? item.sourceCommit : null,
|
||||
reason: typeof item.reason === "string" ? item.reason : null,
|
||||
candidateCount: typeof item.candidateCount === "number" && Number.isInteger(item.candidateCount) && item.candidateCount >= 0 ? item.candidateCount : 0,
|
||||
};
|
||||
}
|
||||
|
||||
function unavailableSourceArtifactRuntime(reason: string, sourceCommit: string | null): PacSourceArtifactRuntimeObservation {
|
||||
const item = { status: "unavailable" as const, name: null, canonicalSha256: null, firstDrift: null, provenanceAligned: false, configRef: null, effectiveConfigSha256: null, sourceCommit, reason, candidateCount: 0 };
|
||||
return { live: item, embedded: item };
|
||||
}
|
||||
|
||||
function parseTarget(record: Record<string, unknown>, index: number): PacTarget {
|
||||
const path = `targets[${index}]`;
|
||||
return {
|
||||
|
||||
@@ -0,0 +1,12 @@
|
||||
import { createHash } from "node:crypto";
|
||||
|
||||
export function stableJsonValue(value: unknown): unknown {
|
||||
if (Array.isArray(value)) return value.map(stableJsonValue);
|
||||
if (typeof value !== "object" || value === null) return value;
|
||||
const input = value as Record<string, unknown>;
|
||||
return Object.fromEntries(Object.keys(input).sort().map((key) => [key, stableJsonValue(input[key])]));
|
||||
}
|
||||
|
||||
export function stableJsonSha256(value: unknown): string {
|
||||
return `sha256:${createHash("sha256").update(JSON.stringify(stableJsonValue(value))).digest("hex")}`;
|
||||
}
|
||||
Reference in New Issue
Block a user