feat: 增加 consumer 服务选择漂移告警

This commit is contained in:
Codex
2026-07-17 17:36:02 +02:00
parent f5ae0ba55d
commit 6ca1f6ebdc
3 changed files with 196 additions and 10 deletions
@@ -17,6 +17,7 @@ import {
canonicalizePacPipelineSpec,
firstPacSourceArtifactDrift,
inspectPacSourceEntrypoints,
inspectPacServiceSelection,
pacSourceArtifactSafeError,
pacSourceArtifactProvenanceFromManifest,
pipelineRunLabels,
@@ -100,6 +101,28 @@ function runtimeInput(commit: string | null = sourceCommit): Record<string, unkn
}
describe("PaC source artifact CLI contract", () => {
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",
@@ -139,6 +139,42 @@ interface DesiredArtifact {
readonly provenance: PacSourceArtifactProvenance;
readonly desiredSpec: Record<string, unknown>;
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<string, unknown>):
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<string, unknown>):
`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<string, unknown>; provenance: PacSourceArtifactProvenance } {
@@ -606,7 +661,7 @@ function renderAgentRunDesiredPipeline(binding: PacSourceArtifactBinding): { pip
};
}
function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record<string, unknown>; provenance: PacSourceArtifactProvenance } {
function renderHwlabDesiredPipeline(binding: PacSourceArtifactBinding, sourceWorktree: string): { pipeline: Record<string, unknown>; 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<string, unknown> {
function renderHwlabPipelineFromOwningYaml(spec: HwlabRuntimeLaneSpec, sourceWorktree: string): { readonly pipeline: Record<string, unknown>; 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, unknown>): 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<string>();
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<string>();
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<string, unknown> | 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);
@@ -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) {