feat: 增加 PaC 回退窗口诊断

This commit is contained in:
Codex
2026-07-16 02:37:18 +02:00
parent 09bd068c9a
commit 97cca89b3f
6 changed files with 720 additions and 13 deletions
@@ -45,6 +45,7 @@ import {
observePacStatusDeliveryBudget,
type PacDeliveryTimingPolicy,
} from "./platform-infra-pac-delivery-timing";
import { diagnosePacRegression, renderPacRegressionDiagnosis } from "./platform-infra-pac-regression-diagnosis";
const configFile = rootPath("config", "platform-infra", "pipelines-as-code.yaml");
const configLabel = "config/platform-infra/pipelines-as-code.yaml";
@@ -263,6 +264,8 @@ export interface PipelinesAsCodeNodeStatusOptions {
interface HistoryOptions extends CommonOptions {
limit: number;
detailId: string | null;
summaryOnly?: boolean;
diagnosisConsumerIds?: readonly string[];
}
export interface PacHistoryConsumerIdentity {
@@ -297,7 +300,7 @@ interface SecretMaterial {
webhookPath: string;
}
type PacReadOnlyAction = "status" | "history" | "debug-step";
type PacReadOnlyAction = "status" | "history" | "diagnose-regression" | "debug-step";
interface PacReadOnlyCaptureObservation {
readonly ok: boolean;
@@ -363,6 +366,12 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf
const result = await history(config, options);
return options.raw ? result : options.full ? compactHistoryJson(result, true) : options.json ? compactHistoryJson(result) : renderHistory(result);
}
if (action === "diagnose-regression" || action === "regression") {
const options = parseDiagnoseRegressionOptions(args.slice(1));
const result = await diagnoseRegression(config, options);
if (options.full || options.raw || options.json) return result;
return rendered(result, "platform-infra pipelines-as-code diagnose-regression", renderPacRegressionDiagnosis(result).trimEnd().split("\n"));
}
if (action === "delivery-timing" || action === "timing") {
const options = parseCommonOptions(args.slice(1));
const result = await deliveryTiming(config, options);
@@ -493,12 +502,13 @@ function help(scope: string | null): Record<string, unknown> {
};
}
return {
command: "platform-infra pipelines-as-code plan|status|history|delivery-timing|debug-step|source-artifact",
command: "platform-infra pipelines-as-code plan|status|history|diagnose-regression|delivery-timing|debug-step|source-artifact",
configTruth: configLabel,
usage: [
"bun scripts/cli.ts platform-infra pipelines-as-code plan --target JD01",
"bun scripts/cli.ts platform-infra pipelines-as-code status --target JD01 [--json|--full|--raw]",
"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 diagnose-regression --target NC01 --consumer agentrun-nc01-v02 [--limit 20] [--json|--full]",
"bun scripts/cli.ts platform-infra pipelines-as-code delivery-timing --target NC01 --consumer selfmedia-nc01 [--json]",
"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]",
@@ -1675,7 +1685,12 @@ function printCloseoutProgress(target: PacTarget, consumer: PacConsumer, stage:
async function history(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
const pac = readPacConfig({ consumerId: options.consumerId });
const target = resolveTarget(pac, options.targetId);
const selection = resolvePacHistoryConsumerSelection(validPacConsumers(pac), target.id, options.consumerId, options.detailId);
const selection = options.diagnosisConsumerIds === undefined
? resolvePacHistoryConsumerSelection(validPacConsumers(pac), target.id, options.consumerId, options.detailId)
: {
selectedConsumerIds: [...options.diagnosisConsumerIds],
nextConsumerId: options.diagnosisConsumerIds[0] ?? null,
};
const selectedConsumers = selection.selectedConsumerIds.map((id) => resolveConsumer(pac, id));
const firstConsumer = selectedConsumers[0];
if (firstConsumer === undefined) throw new Error("no Pipelines-as-Code consumers are configured");
@@ -1684,7 +1699,8 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
const next = selection.nextConsumerId === null
? pacNodeReadOnlyNext(target.id)
: pacReadOnlyNext(target.id, selection.nextConsumerId);
const observed = await capturePacReadOnly(config, pac, "history", target, firstConsumer, remoteScript("history", pac, target, firstRepository, firstConsumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", selectedConsumers), stringValue(record(next).history));
const remoteAction = options.summaryOnly === true ? "diagnose-regression" : "history";
const observed = await capturePacReadOnly(config, pac, remoteAction, target, firstConsumer, remoteScript(remoteAction, pac, target, firstRepository, firstConsumer, { ...options, confirm: false, dryRun: true, wait: false }, secrets, "", selectedConsumers), stringValue(record(next).history));
const { result, parsed, observation } = observed;
const remote = parsed ?? compactCapture(result, { full: true });
const historyErrors = arrayRecords(record(remote).historyErrors);
@@ -1733,6 +1749,53 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise<
};
}
async function diagnoseRegression(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
if (options.consumerId === null) throw new Error("pipelines-as-code diagnose-regression requires --consumer <consumer>");
const pac = readPacConfig({ consumerId: options.consumerId });
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}`);
}
const authority = resolveCicdDeliveryAuthority({ consumerId: consumer.id, node: consumer.node, lane: consumer.lane });
if (authority.kind !== "pac-pr-merge") throw new Error(`delivery authority is ${authority.kind}: ${authority.reason}`);
const diagnosisConsumers = validPacConsumers(pac)
.filter((item) => item.node.toLowerCase() === target.id.toLowerCase() && item.repositoryRef === consumer.repositoryRef);
const historyResult = await history(config, {
...options,
consumerId: null,
detailId: null,
summaryOnly: true,
diagnosisConsumerIds: diagnosisConsumers.map((item) => item.id),
});
const diagnosis = diagnosePacRegression({
targetId: target.id,
consumerId: consumer.id,
sourceRepository: authority.consumer.sourceRepository,
limitPerConsumer: options.limit,
rows: arrayRecords(historyResult.rows),
consumerBindings: validPacConsumers(pac)
.filter((item) => item.node.toLowerCase() === target.id.toLowerCase())
.map((item) => ({ id: item.id, repositoryRef: item.repositoryRef })),
});
const evidence = record(diagnosis.evidence);
const readOk = historyResult.ok === true;
return {
ok: readOk,
action: "platform-infra-pipelines-as-code-diagnose-regression",
...diagnosis,
state: readOk ? diagnosis.state : "read-failed",
incident: readOk ? diagnosis.incident : null,
evidence: options.full || options.raw
? evidence
: { matchingFailureCount: arrayRecords(evidence.matchingFailures).length, valuesPrinted: false },
observation: historyResult.observation,
historyErrors: historyResult.historyErrors,
warnings: historyResult.warnings,
valuesPrinted: false,
};
}
export function resolvePacHistoryConsumerSelection(
consumers: readonly PacHistoryConsumerIdentity[],
targetId: string,
@@ -1966,7 +2029,7 @@ export class PacReleaseManifestRenderError extends Error {
}
}
function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
function remoteScript(action: "apply" | "status" | "history" | "diagnose-regression" | "debug-step", pac: PacConfig, target: PacTarget, repository: PacRepository, consumer: PacConsumer, options: ApplyOptions | HistoryOptions, secrets: SecretMaterial, releaseManifest: string, historyConsumers: PacConsumer[] = [consumer]): string {
const webhookUrl = `${pac.gitea.internalBaseUrl.replace(/\/+$/u, "").replace(/gitea-http\.[^.]+\.svc\.cluster\.local:3000/u, `${pac.release.controllerServiceName}.${pac.release.namespace}.svc.cluster.local:${pac.release.controllerServicePort}`)}`;
const admissionIdentity = pacAdmissionDesiredIdentity(target.id);
const rbacIdentity = pacConsumerRbacDesiredIdentity(target.id);
@@ -3268,9 +3331,9 @@ function parseCloseoutOptions(args: string[]): CloseoutOptions {
return { ...parseCommonOptions(commonArgs), sourceCommit, wait, timeoutMs };
}
function parseHistoryOptions(args: string[]): HistoryOptions {
function parseHistoryOptions(args: string[], defaultLimit = 5): HistoryOptions {
const commonArgs: string[] = [];
let limit = 5;
let limit = defaultLimit;
let detailId: string | null = null;
for (let index = 0; index < args.length; index += 1) {
const arg = args[index];
@@ -3298,6 +3361,12 @@ function parseHistoryOptions(args: string[]): HistoryOptions {
return { ...parseCommonOptions(commonArgs), limit, detailId };
}
function parseDiagnoseRegressionOptions(args: string[]): HistoryOptions {
const options = parseHistoryOptions(args, 20);
if (options.detailId !== null) throw new Error("pipelines-as-code diagnose-regression does not accept --id");
return options;
}
function parseCommonOptions(args: string[]): CommonOptions {
let targetId: string | null = null;
let consumerId: string | null = null;