diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index 3b2ff7bc..3cd79bb5 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -17,6 +17,7 @@ import { canonicalizePacPipelineSpec, firstPacSourceArtifactDrift, inspectPacSourceEntrypoints, + inspectPacServiceSelection, pacSourceArtifactSafeError, pacSourceArtifactProvenanceFromManifest, pipelineRunLabels, @@ -100,6 +101,28 @@ function runtimeInput(commit: string | null = sourceCommit): Record { + test("consumer service selection drift is typed partial evidence and non-blocking", () => { + const result = inspectPacServiceSelection({ + deployDeclarations: { source: "deploy/deploy.yaml#**.serviceId", serviceIds: ["api", "worker"] }, + pacSelected: { source: ".tekton/consumer.yaml#spec.params[name=services]", serviceIds: ["api", "worker"] }, + laneServiceIds: { source: "config/lanes.yaml#lanes.v03.serviceIds", serviceIds: ["api"] }, + renderedGitOps: { source: "deploy/gitops/runtime-v03", serviceIds: ["api"] }, + }); + + expect(result).toMatchObject({ + code: "pac-consumer-service-selection-drift", + status: "partial", + warning: true, + blocking: false, + authority: "owning YAML/source-of-truth", + comparisonBaseline: "consumer deploy declarations", + drift: [ + { set: "laneServiceIds", missing: ["worker"], unexpected: [] }, + { set: "renderedGitOps", missing: ["worker"], unexpected: [] }, + ], + }); + }); + test("accepts PikaOA development runtime provenance", () => { const expected = { configRef: "config/pikaoa.yaml#developmentRuntime.targets.NC01", diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index 273b4585..01233532 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -139,6 +139,42 @@ interface DesiredArtifact { readonly provenance: PacSourceArtifactProvenance; readonly desiredSpec: Record; readonly files: readonly { readonly path: string; readonly content: string }[]; + readonly serviceSelection: PacServiceSelectionEvidence | null; +} + +export interface PacServiceSelectionSource { + readonly source: string; + readonly serviceIds: readonly string[]; +} + +export interface PacServiceSelectionDrift { + readonly set: "pacSelected" | "laneServiceIds" | "renderedGitOps"; + readonly source: string; + readonly missing: readonly string[]; + readonly unexpected: readonly string[]; +} + +export interface PacServiceSelectionEvidence { + readonly code: "pac-consumer-service-selection-aligned" | "pac-consumer-service-selection-drift"; + readonly status: "aligned" | "partial"; + readonly warning: boolean; + readonly blocking: false; + readonly authority: "owning YAML/source-of-truth"; + readonly comparisonBaseline: "consumer deploy declarations"; + readonly sets: { + readonly deployDeclarations: PacServiceSelectionSource; + readonly pacSelected: PacServiceSelectionSource; + readonly laneServiceIds: PacServiceSelectionSource; + readonly renderedGitOps: PacServiceSelectionSource; + }; + readonly drift: readonly PacServiceSelectionDrift[]; + readonly impact: string | null; +} + +interface HwlabServiceSelectionRender { + readonly deployDeclarations: PacServiceSelectionSource; + readonly laneServiceIds: PacServiceSelectionSource; + readonly renderedGitOps: PacServiceSelectionSource; } interface SourceInspection { @@ -329,6 +365,8 @@ export async function runPacSourceArtifact( pipeline: binding.consumer.pipeline, namespace: binding.consumer.namespace, }, + serviceSelection: desired.serviceSelection, + warnings: desired.serviceSelection?.warning === true ? [desired.serviceSelection] : [], source, sourceEntrypoints, sourceWorktreeState, @@ -471,6 +509,8 @@ export function renderPacSourceArtifactResult(result: Record): const liveDrift = live?.firstDrift === null ? null : record(live?.firstDrift); const embeddedDrift = embedded?.firstDrift === null ? null : record(embedded?.firstDrift); const warnings = Array.isArray(result.warnings) ? result.warnings.map(record) : []; + const serviceSelection = result.serviceSelection === null ? null : record(result.serviceSelection); + const selectionSets = serviceSelection === null ? null : record(serviceSelection.sets); const lines = [ "PAC SOURCE ARTIFACT", `target=${text(result.target)} consumer=${text(result.consumer)} node=${text(result.node)} lane=${text(result.lane)}`, @@ -485,7 +525,13 @@ export function renderPacSourceArtifactResult(result: Record): `embedded=status:${text(embedded?.status)} name:${text(embedded?.name)} hash:${shortSha(embedded?.canonicalSha256)} provenance:${text(embedded?.provenanceAligned)} drift:${formatDrift(embeddedDrift)}`, `runtimeReason=live:${text(live?.reason)} embedded:${text(embedded?.reason)}`, `written=${Array.isArray(files.written) && files.written.length > 0 ? files.written.join(",") : "-"}`, - ...warnings.map((warning) => `warning=object:${text(record(warning.object).kind)}:${text(record(warning.object).id)} blocking:${text(warning.blocking)} code:${text(warning.code)} config:${text(warning.configPath)}`), + ...(serviceSelection === null ? [] : [ + `serviceSelection=status:${text(serviceSelection.status)} deploy:${selectionServiceCount(selectionSets, "deployDeclarations")} pac:${selectionServiceCount(selectionSets, "pacSelected")} lane:${selectionServiceCount(selectionSets, "laneServiceIds")} rendered:${selectionServiceCount(selectionSets, "renderedGitOps")}`, + `serviceSelectionDrift=${selectionDriftText(serviceSelection.drift)}`, + ]), + ...warnings.map((warning) => record(warning.object).kind === undefined + ? `warning=blocking:${text(warning.blocking)} code:${text(warning.code)} status:${text(warning.status)}` + : `warning=object:${text(record(warning.object).kind)}:${text(record(warning.object).id)} blocking:${text(warning.blocking)} code:${text(warning.code)} config:${text(warning.configPath)}`), ]; if (typeof result.next === "string") lines.push(`next=${result.next}`); return renderedCliResult(result.ok !== false, String(result.action), `${lines.join("\n")}\n`); @@ -572,13 +618,22 @@ function renderDesiredArtifact(binding: PacSourceArtifactBinding, sourceWorktree const pipelineRun = sourceArtifact.mode === "embedded-pipeline-spec" ? embeddedPipelineRun(binding, desiredSpec, rendered.provenance) : remotePipelineRun(binding, pipeline, rendered.provenance); + const serviceSelection = sourceArtifact.renderer === "hwlab-runtime-lane" + ? inspectPacServiceSelection({ + ...rendered.serviceSelection, + pacSelected: { + source: `${sourceArtifact.pipelineRunPath}#spec.params[name=services]`, + serviceIds: pipelineRunServiceIds(pipelineRun), + }, + }) + : null; const files = sourceArtifact.mode === "embedded-pipeline-spec" ? [{ path: sourceArtifact.pipelineRunPath, content: pacSourceArtifactYaml(pipelineRun) }] : [ { path: requiredString(sourceArtifact.pipelinePath, "sourceArtifact.pipelinePath"), content: pacSourceArtifactYaml(pipeline) }, { path: sourceArtifact.pipelineRunPath, content: pacSourceArtifactYaml(pipelineRun) }, ]; - return { pipeline, pipelineRun, provenance: rendered.provenance, desiredSpec, files }; + return { pipeline, pipelineRun, provenance: rendered.provenance, desiredSpec, files, serviceSelection }; } function renderAgentRunDesiredPipeline(binding: PacSourceArtifactBinding): { pipeline: Record; provenance: PacSourceArtifactProvenance } { @@ -606,7 +661,7 @@ function renderAgentRunDesiredPipeline(binding: PacSourceArtifactBinding): { pip }; } -function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record; provenance: PacSourceArtifactProvenance } { +function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record; provenance: PacSourceArtifactProvenance; serviceSelection: HwlabServiceSelectionRender } { 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 provenance = hwlabRuntimePipelineProvenance(spec); @@ -624,10 +679,11 @@ function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWor sourceBranch: spec.sourceBranch, gitopsBranch: spec.gitopsBranch, }); - const pipeline = renderHwlabPipelineFromOwningYaml(spec, sourceWorktree); + const rendered = renderHwlabPipelineFromOwningYaml(spec, sourceWorktree); return { - pipeline, + pipeline: rendered.pipeline, provenance, + serviceSelection: rendered.serviceSelection, }; } @@ -657,7 +713,7 @@ function renderPikaoaPipeline(binding: PacSourceArtifactBinding, sourceWorktree: }; } -function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): Record { +function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): { readonly pipeline: Record; readonly serviceSelection: HwlabServiceSelectionRender } { const temporaryRoot = mkdtempSync(resolve(tmpdir(), "unidesk-pac-source-artifact-")); const temporarySource = resolve(temporaryRoot, "source"); const output = resolve(temporaryRoot, "rendered"); @@ -667,7 +723,8 @@ function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWor 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 overlaidDeploy = applyNodeRuntimeDeployYamlOverlay(deploy, overlayRecord); + writeFileSync(deployPath, `${Bun.YAML.stringify(overlaidDeploy).trim()}\n`); const renderArgs = [ "scripts/run-bun.mjs", "scripts/gitops-render.mjs", @@ -702,12 +759,101 @@ function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWor runChecked("sh", [], temporarySource, 120_000, "HWLAB UniDesk domain postprocess/verify renderer", postprocess); 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); + const deployDeclarations = collectServiceIds(overlaidDeploy); + return { + pipeline: parseYamlRecord(readFileSync(path, "utf8"), path), + serviceSelection: { + deployDeclarations: { source: "deploy/deploy.yaml#**.serviceId", serviceIds: deployDeclarations }, + laneServiceIds: { source: `config/hwlab-node-lanes.yaml#lanes.${spec.lane}.serviceIds`, serviceIds: spec.serviceIds }, + renderedGitOps: { + source: `${spec.runtimePath}/{services.yaml,workloads.yaml}`, + serviceIds: collectRenderedGitOpsServiceIds(output, spec.runtimeRenderDir, deployDeclarations), + }, + }, + }; } finally { rmSync(temporaryRoot, { recursive: true, force: true }); } } +export function inspectPacServiceSelection(input: { + readonly deployDeclarations: PacServiceSelectionSource; + readonly pacSelected: PacServiceSelectionSource; + readonly laneServiceIds: PacServiceSelectionSource; + readonly renderedGitOps: PacServiceSelectionSource; +}): PacServiceSelectionEvidence { + const sets = { + deployDeclarations: normalizedServiceSelectionSource(input.deployDeclarations), + pacSelected: normalizedServiceSelectionSource(input.pacSelected), + laneServiceIds: normalizedServiceSelectionSource(input.laneServiceIds), + renderedGitOps: normalizedServiceSelectionSource(input.renderedGitOps), + }; + const expected = new Set(sets.deployDeclarations.serviceIds); + const drift = (["pacSelected", "laneServiceIds", "renderedGitOps"] as const).flatMap((set) => { + const actual = new Set(sets[set].serviceIds); + const missing = [...expected].filter((serviceId) => !actual.has(serviceId)); + const unexpected = [...actual].filter((serviceId) => !expected.has(serviceId)); + return missing.length === 0 && unexpected.length === 0 ? [] : [{ set, source: sets[set].source, missing, unexpected }]; + }); + const warning = drift.length > 0; + return { + code: warning ? "pac-consumer-service-selection-drift" : "pac-consumer-service-selection-aligned", + status: warning ? "partial" : "aligned", + warning, + blocking: false, + authority: "owning YAML/source-of-truth", + comparisonBaseline: "consumer deploy declarations", + sets, + drift, + impact: warning ? "PaC, lane owning YAML, or rendered GitOps may omit declared consumer services." : null, + }; +} + +function normalizedServiceSelectionSource(input: PacServiceSelectionSource): PacServiceSelectionSource { + return { source: input.source, serviceIds: [...new Set(input.serviceIds.map((item) => item.trim()).filter(Boolean))].sort() }; +} + +function pipelineRunServiceIds(pipelineRun: Record): string[] { + const spec = requiredRecord(pipelineRun.spec, "rendered PipelineRun.spec"); + const param = arrayRecords(spec.params, "PipelineRun.spec.params").find((item) => item.name === "services"); + if (param === undefined) return []; + return requiredString(param.value, "PipelineRun.spec.params[services].value").split(","); +} + +function collectServiceIds(value: unknown): string[] { + const serviceIds = new Set(); + const visit = (item: unknown): void => { + if (Array.isArray(item)) { + for (const child of item) visit(child); + return; + } + if (!isRecord(item)) return; + if (typeof item.serviceId === "string" && item.serviceId.length > 0) serviceIds.add(item.serviceId); + for (const child of Object.values(item)) visit(child); + }; + visit(value); + return [...serviceIds].sort(); +} + +function collectRenderedGitOpsServiceIds(output: string, runtimeRenderDir: string, declarations: readonly string[]): string[] { + const declared = new Set(declarations); + const serviceIds = new Set(); + for (const file of ["services.yaml", "workloads.yaml"]) { + const path = resolve(output, runtimeRenderDir, file); + if (!existsSync(path)) continue; + const manifest = parseYamlRecord(readFileSync(path, "utf8"), path); + for (const item of arrayRecords(manifest.items, `${file}.items`)) { + const metadata = record(item.metadata); + const labels = record(metadata.labels); + const candidate = typeof labels["app.kubernetes.io/name"] === "string" + ? labels["app.kubernetes.io/name"] + : typeof metadata.name === "string" ? metadata.name : null; + if (candidate !== null && declared.has(candidate)) serviceIds.add(candidate); + } + } + return [...serviceIds].sort(); +} + function copySourceWorktreeForRender(sourceWorktree: string, destination: string): void { const excluded = new Set([".git", ".worktree", ".state", "node_modules", "coverage", "tmp", ".tmp"]); cpSync(sourceWorktree, destination, { @@ -1325,6 +1471,22 @@ function shellQuote(value: string): string { return `'${value.replaceAll("'", `'"'"'`)}'`; } +function selectionServiceCount(sets: Record | null, key: string): number { + if (sets === null) return 0; + const serviceIds = record(sets[key]).serviceIds; + return Array.isArray(serviceIds) ? serviceIds.length : 0; +} + +function selectionDriftText(value: unknown): string { + if (!Array.isArray(value) || value.length === 0) return "-"; + return value.map((item) => { + const drift = record(item); + const missing = Array.isArray(drift.missing) ? drift.missing.join(",") : ""; + const unexpected = Array.isArray(drift.unexpected) ? drift.unexpected.join(",") : ""; + return `${text(drift.set)}:missing=${missing || "-"}:unexpected=${unexpected || "-"}`; + }).join(";"); +} + function text(value: unknown): string { if (value === null || value === undefined || value === "") return "-"; return String(value); diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 490dae9e..0a85f512 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -426,9 +426,10 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf params: consumerParams(repository, consumer), }, }; + const sourceArtifactResult = await runPacSourceArtifact(binding, options, async ({ desiredSpec, desiredWrapper, provenance, sourceCommit }) => await observeSourceArtifactRuntime(config, target, consumer, desiredSpec, desiredWrapper, provenance, sourceCommit)); const result = { - ...await runPacSourceArtifact(binding, options, async ({ desiredSpec, desiredWrapper, provenance, sourceCommit }) => await observeSourceArtifactRuntime(config, target, consumer, desiredSpec, desiredWrapper, provenance, sourceCommit)), - warnings: pac.validationWarnings, + ...sourceArtifactResult, + warnings: [...pac.validationWarnings, ...(Array.isArray(sourceArtifactResult.warnings) ? sourceArtifactResult.warnings : [])], }; return options.json || options.full ? result : renderPacSourceArtifactResult(result); } catch (error) {