feat: 增加 consumer 服务选择漂移告警
This commit is contained in:
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user