Merge pull request #2863 from pikasTech/fix/2820-materialization-observe
补全物化发布实际范围与完成时间证据
This commit is contained in:
@@ -81,6 +81,7 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help
|
||||
- 范围审阅必须分别比较 build、rollout 与 affected:任一类别出现计划外对象都属于 `expanded`,禁止只用 affected 集合掩盖额外镜像构建或额外滚动;
|
||||
- 只物化 GitOps/控制面资源的 consumer 必须在 owning YAML 中声明 `materializationOnly=true` 和非空 `materializationPaths`,其计划不得从共享 Repository 参数继承业务镜像构建;
|
||||
- 对应 Pipeline 必须校验 webhook 中的 reviewed plan,并输出 `pac-delivery-plan` 与 `release-artifacts` 终态,使 status/history 直接显示实际 build、rollout、affected;
|
||||
- `debug-step --id` 必须保留实际三类服务列表;materialization-only 固定要求 build 为零和 GitOps commit 可见,不得因没有业务镜像 artifact catalog 误判失败;
|
||||
- Pipeline 内的独立下游按各自 owning YAML 输入过滤,未命中时在 clone、归档、跨 host 传输和运行面调用前返回 no-op;
|
||||
- 对应 Argo Application、repository credential 和下游 webhook 必须由 consumer `argoBootstrap` 指向同一 Gitea repository/branch,live URL 命中退役 `git-mirror` 时先修 YAML/control-plane,不得人工 sync;
|
||||
- 计划范围扩大时先修复 source range、源码、YAML 或项目 planner,再重新 plan;
|
||||
@@ -167,6 +168,7 @@ bun scripts/cli.ts hwlab nodes control-plane legacy-cicd --help
|
||||
- Pipeline completion 只输出 `pipelineLowerBoundSeconds`;只有 source-bound delivery observe 给出 Argo operation 与 workload rollout completion 后才计算真实端到端耗时;
|
||||
- Argo operation 或 workload rollout completion 早于本次 webhook admission 时属于 retained 旧证据,必须返回 `partial` 与 typed evidence gap;禁止把负时差钳成 `0s`、冒充 no-runtime-change 或作为预算通过证据;
|
||||
- 端到端耗时严格超过 owning YAML 预算时输出 `pac-manual-delivery-over-budget`,固定 `blocking=false`、`mutation=false`,并直接披露最长 TaskRun 与 budget evidence gaps;
|
||||
- runtime evidence 为 `not-applicable` 时,delivery completion 使用 Argo 对当前 revision 的 `reconciledAt`;沿用的旧 operation 时间早于本次 webhook admission 时继续返回 `partial`;
|
||||
- 超预算提示只下钻 env identity、依赖缓存、BuildKit/cache 和 stage timing,不给出人工恢复命令;
|
||||
- 该入口固定只读且 `mutation=false`,不得用于同步、触发、补跑或运行面写入。
|
||||
- 验收 registry cache 性能时,按 [Gitea + PaC](references/gitea-pac.md) 的冷热两次正常 PR 流程执行,不用人工 PipelineRun 或重复 status/timing 试探。
|
||||
|
||||
@@ -101,6 +101,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code bootstrap --target <NODE> --
|
||||
- 只物化 GitOps/控制面资源的 consumer:
|
||||
- owning YAML 同时声明 `materializationOnly`、真实输入集合和 `argoBootstrap`;
|
||||
- Pipeline 在任何运行面调用前校验 reviewed plan,并用 `pac-delivery-plan` 与 `release-artifacts` 输出实际三类范围;
|
||||
- `debug-step --id` 直接显示实际 build、rollout、affected 列表;零构建的 materialization 只要求 GitOps commit,不要求不适用的业务镜像 catalog;
|
||||
- 一个 Task 内组合多个独立下游时,各下游按自己的 YAML 输入做 source-range 判定,未命中必须在 clone、archive 和 trans 前 no-op;
|
||||
- status 出现 Argo `ComparisonError` 且 repo URL 仍为退役 `git-mirror` 时,先收敛 consumer 的 Gitea Argo authority 与 branch 精确 downstream hook,禁止人工 sync/refresh。
|
||||
- 只有出现自动链故障或用户明确要求验收时才读取状态:
|
||||
@@ -184,6 +185,7 @@ bun scripts/cli.ts platform-infra pipelines-as-code source-artifact verify-runti
|
||||
- 同时披露 trigger wait、PipelineRun、端到端耗时及 Argo/runtime closeout;
|
||||
- `PIPELINE_DONE` 只形成 `PIPELINE_LOWER_BOUND_S`,`DELIVERY_DONE` 必须取 Argo operation 与 workload rollout completion 的较晚值;
|
||||
- 缺少 PR、时间戳或 provenance 时返回 `partial` 与 `evidenceGaps`,不得用 commit 时间替代 `mergedAt`;
|
||||
- runtime evidence 不适用时,以 Argo 对当前 revision 的 `reconciledAt` 作为 completion;旧 operation 时间早于手动 admission 时不得算作端到端完成;
|
||||
- 整个入口保持 `mutation=false`,不写入历史库,不触发、同步或补跑交付。
|
||||
- registry cache 性能验收:
|
||||
- 冷缓存样本:
|
||||
|
||||
@@ -534,9 +534,13 @@ function extractPacSourceObservation(recordsInput) {
|
||||
sourceCommit,
|
||||
plan: {
|
||||
affectedServiceCount: affected?.length ?? null,
|
||||
affectedServices: affected ?? [],
|
||||
rolloutServiceCount: rollout?.length ?? null,
|
||||
rolloutServices: rollout ?? [],
|
||||
buildServiceCount: build?.length ?? null,
|
||||
buildServices: build ?? [],
|
||||
reusedServiceCount: reused?.length ?? null,
|
||||
reusedServices: reused ?? [],
|
||||
},
|
||||
terminal: {
|
||||
planArtifacts: effectivePlanTask?.status || (plan === null ? "missing" : "observed"),
|
||||
@@ -577,6 +581,7 @@ function extractPacArtifactEvidence(recordsInput, logText) {
|
||||
|| item.status === "built") || null;
|
||||
const logLines = String(logText || "").split(/\r?\n/u);
|
||||
const gitopsCommit = stringOrNull(image?.gitopsCommit)
|
||||
|| stringOrNull(image?.gitOpsCommit)
|
||||
|| [...logLines].reverse().map((line) => line.match(/^\[[^\]]+\s+([0-9a-f]{7,64})\]/u)?.[1] || null).find(Boolean)
|
||||
|| null;
|
||||
const catalogDigests = catalog === null
|
||||
@@ -1144,6 +1149,7 @@ function evaluatePacNodeStatusSnapshot(inputValue) {
|
||||
const operationFinishedAt = operationState.phase === "Succeeded" && operationSyncResult.revision === appSync.revision
|
||||
? firstString(operationState.finishedAt)
|
||||
: null;
|
||||
const reconciledAt = firstString(appStatus.reconciledAt);
|
||||
const appSpec = record(application?.spec);
|
||||
const appSource = record(appSpec.source ?? (Array.isArray(appSpec.sources) ? appSpec.sources[0] : null));
|
||||
const argoDegraded = argoDegradation(application);
|
||||
@@ -1212,6 +1218,7 @@ function evaluatePacNodeStatusSnapshot(inputValue) {
|
||||
health: firstString(appHealth.status),
|
||||
revision: firstString(appSync.revision),
|
||||
operationFinishedAt,
|
||||
reconciledAt,
|
||||
repoURL: firstString(appSource.repoURL),
|
||||
targetRevision: firstString(appSource.targetRevision),
|
||||
...argoDegraded,
|
||||
|
||||
@@ -105,6 +105,12 @@ test("public edge skips unrelated source ranges before archive and trans", () =>
|
||||
expect(source).toContain('runChecked("git", ["diff", "--name-only", baseCommit, sourceCommit]');
|
||||
expect(source.indexOf("if (!sourceRange.changed)")).toBeLessThan(source.indexOf("const token = process.env"));
|
||||
expect(source.indexOf("if (!sourceRange.changed)")).toBeLessThan(source.indexOf("createArchive(sourceRoot"));
|
||||
const remote = readFileSync(resolve(root, "scripts/src/platform-infra-pipelines-as-code-remote.sh"), "utf8");
|
||||
expect(remote).toContain("process.env.UNIDESK_PAC_MATERIALIZATION_ONLY === '1'");
|
||||
expect(remote).toContain("materialization-build-scope-nonzero");
|
||||
expect(remote).toContain("if (materializationOnly)");
|
||||
const command = readFileSync(resolve(root, "scripts/src/platform-infra-pipelines-as-code.ts"), "utf8");
|
||||
expect(command).toContain('UNIDESK_PAC_MATERIALIZATION_ONLY: consumer.materializationOnly ? "1" : "0"');
|
||||
});
|
||||
|
||||
test("owning YAML renders one child Application and the complete durable bridge desired state", () => {
|
||||
|
||||
@@ -172,6 +172,7 @@ test("delivery observe preserves a typed snapshot read failure", () => {
|
||||
|
||||
test("delivery observe completes a configuration-only consumer without runtime workloads", () => {
|
||||
const status = fixture({ runtime: { workloads: [] } });
|
||||
(status.summary as Record<string, any>).argo.reconciledAt = "2026-07-22T06:54:16Z";
|
||||
status.consumer = {
|
||||
id: "fixture",
|
||||
deliveryObservation: {
|
||||
@@ -185,6 +186,8 @@ test("delivery observe completes a configuration-only consumer without runtime w
|
||||
assert.equal(result.status, "complete");
|
||||
assert.equal((result.runtime as Record<string, unknown>).applicable, false);
|
||||
assert.equal((result.runtime as Record<string, unknown>).ready, null);
|
||||
assert.equal((result.runtime as Record<string, unknown>).deliveryCompletedAt, "2026-07-22T06:54:16Z");
|
||||
assert.equal((result.argo as Record<string, unknown>).reconciledAt, "2026-07-22T06:54:16Z");
|
||||
assert.deepEqual(result.firstBreak, null);
|
||||
assert.deepEqual((result.warnings as Record<string, unknown>[])[0], {
|
||||
code: "runtime-evidence-not-applicable",
|
||||
|
||||
@@ -63,7 +63,10 @@ export function projectPacDeliveryObserve(input: PacDeliveryObserveInput): Recor
|
||||
const readErrors = arrayRecords(input.status.readErrors);
|
||||
const rolloutCompletedAt = latestTimestamp(matchingWorkloads.map((item) => item.rolloutCompletedAt));
|
||||
const argoOperationFinishedAt = stringOrNull(argo.operationFinishedAt);
|
||||
const deliveryCompletedAt = latestTimestamp([argoOperationFinishedAt, rolloutCompletedAt]);
|
||||
const argoReconciledAt = stringOrNull(argo.reconciledAt);
|
||||
const deliveryCompletedAt = runtimeEvidenceRequired
|
||||
? latestTimestamp([argoOperationFinishedAt, rolloutCompletedAt])
|
||||
: latestTimestamp([argoOperationFinishedAt, argoReconciledAt]);
|
||||
const state = !observationReady || !sourceMatched || !pipelineSucceeded || (runtimeEvidenceRequired && matchingWorkloads.length === 0)
|
||||
? "blocked"
|
||||
: (!runtimeEvidenceRequired || runtimeReady) && argoReady
|
||||
@@ -128,6 +131,7 @@ export function projectPacDeliveryObserve(input: PacDeliveryObserveInput): Recor
|
||||
firstDegradedResource: argo.firstDegradedResource ?? null,
|
||||
firstCondition: argo.firstCondition ?? null,
|
||||
operationFinishedAt: argoOperationFinishedAt,
|
||||
reconciledAt: argoReconciledAt,
|
||||
},
|
||||
runtime: {
|
||||
evidenceMode: runtimeEvidence,
|
||||
|
||||
@@ -228,6 +228,28 @@ describe("PaC 失败证据合同", () => {
|
||||
expect(output).toContain("module not found: scripts/missing.ts");
|
||||
});
|
||||
|
||||
test("debug-step 默认显示 materialization 实际三类范围", () => {
|
||||
const output = renderDebugStep({
|
||||
realRun: {
|
||||
found: true,
|
||||
pipelineRun: { id: "fixture", status: "True" },
|
||||
actualScope: {
|
||||
materializationOnly: true,
|
||||
buildServiceCount: 0,
|
||||
buildServices: [],
|
||||
rolloutServiceCount: 1,
|
||||
rolloutServices: ["platform-infra-gitea-nc01"],
|
||||
affectedServiceCount: 1,
|
||||
affectedServices: ["platform-infra-gitea-nc01"],
|
||||
},
|
||||
},
|
||||
checks: [], target: {}, next: {},
|
||||
}).renderedText;
|
||||
expect(output).toContain("ACTUAL SCOPE");
|
||||
expect(output).toContain("platform-infra-gitea-nc01");
|
||||
expect(output).toMatch(/build\s+0\s+-\s+true/u);
|
||||
});
|
||||
|
||||
test("debug-step 默认 JSON 只保留有界失败 fixture 与运行面摘要", () => {
|
||||
const checks = Array.from({ length: 35 }, (_, index) => ({
|
||||
id: `fixture-${index}`,
|
||||
|
||||
@@ -367,6 +367,48 @@ test("AgentRun rendered delivery plan and real TaskRun terminals close the statu
|
||||
})).toMatchObject({ ok: true, code: "pac-ready-gitops-exact" });
|
||||
});
|
||||
|
||||
test("materialization delivery preserves actual scope and camel-case GitOps commit evidence", () => {
|
||||
const sourceCommit = "c".repeat(40);
|
||||
const gitOpsCommit = "b".repeat(40);
|
||||
const records = [{
|
||||
event: "pac-delivery-plan",
|
||||
schemaVersion: "v1",
|
||||
sourceCommitId: sourceCommit,
|
||||
affectedServices: ["platform-infra-gitea-nc01"],
|
||||
rolloutServices: ["platform-infra-gitea-nc01"],
|
||||
buildServices: [],
|
||||
reusedServices: [],
|
||||
}, {
|
||||
event: "pac-task-terminal",
|
||||
pipelineTask: "release-artifacts",
|
||||
taskRun: "fixture-release-artifacts",
|
||||
status: "succeeded",
|
||||
reason: "Succeeded",
|
||||
results: [],
|
||||
}, {
|
||||
ok: true,
|
||||
phase: "gitops-publish",
|
||||
status: "published",
|
||||
sourceCommit,
|
||||
gitOpsCommit,
|
||||
}];
|
||||
const artifact = evaluator.extractPacArtifactEvidence(records, "");
|
||||
expect(artifact.gitopsCommit).toBe(gitOpsCommit);
|
||||
expect(artifact.sourceObservation).toMatchObject({
|
||||
contract: "pac-delivery-plan-v1",
|
||||
mode: "delivery",
|
||||
valid: true,
|
||||
plan: {
|
||||
buildServiceCount: 0,
|
||||
buildServices: [],
|
||||
rolloutServiceCount: 1,
|
||||
rolloutServices: ["platform-infra-gitea-nc01"],
|
||||
affectedServiceCount: 1,
|
||||
affectedServices: ["platform-infra-gitea-nc01"],
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("provenance fixtures keep outer PaC delivery eligible while reporting admission warnings", () => {
|
||||
const result = evaluator.runPacStatusFixtureChecks();
|
||||
expect(result.ok).toBe(true);
|
||||
|
||||
@@ -2426,9 +2426,11 @@ const historyErrors = Array.isArray(history.errors) ? history.errors : [];
|
||||
const row = record(rows[0]);
|
||||
const cp = require('node:child_process');
|
||||
const sourceObservation = record(row.sourceObservation);
|
||||
const plan = record(sourceObservation.plan);
|
||||
const artifact = record(row.artifact);
|
||||
const catalog = record(artifact.catalog);
|
||||
const collector = record(row.collector);
|
||||
const materializationOnly = process.env.UNIDESK_PAC_MATERIALIZATION_ONLY === '1';
|
||||
const terminal = [
|
||||
terminalRow(sourceObservation, 'planArtifacts', 'plan-artifacts'),
|
||||
terminalRow(sourceObservation, 'collectArtifacts', 'collect-artifacts'),
|
||||
@@ -2486,9 +2488,13 @@ for (const item of terminal) {
|
||||
}
|
||||
if (Number(collector.logErrorCount || 0) > 0) breakAt('contract-log-read-failed', 'collector', 'one or more contract TaskRun logs could not be read');
|
||||
if (sourceObservation.mode === 'delivery') {
|
||||
if (catalog.present !== true || catalog.status !== 'published') breakAt('artifact-catalog-missing', 'collect-artifacts', 'published artifact catalog evidence is missing');
|
||||
if (catalog.registryVerified !== true) breakAt('artifact-catalog-unverified', 'collect-artifacts', 'artifact catalog registry verification is not true');
|
||||
if (!Array.isArray(artifact.digests) || artifact.digests.length === 0) breakAt('artifact-digest-missing', 'collect-artifacts', 'artifact catalog did not expose any sha256 digest');
|
||||
if (materializationOnly) {
|
||||
if (plan.buildServiceCount !== 0) breakAt('materialization-build-scope-nonzero', 'plan-artifacts', 'materialization-only delivery reported a non-zero build scope');
|
||||
} else {
|
||||
if (catalog.present !== true || catalog.status !== 'published') breakAt('artifact-catalog-missing', 'collect-artifacts', 'published artifact catalog evidence is missing');
|
||||
if (catalog.registryVerified !== true) breakAt('artifact-catalog-unverified', 'collect-artifacts', 'artifact catalog registry verification is not true');
|
||||
if (!Array.isArray(artifact.digests) || artifact.digests.length === 0) breakAt('artifact-digest-missing', 'collect-artifacts', 'artifact catalog did not expose any sha256 digest');
|
||||
}
|
||||
if (typeof artifact.gitopsCommit !== 'string' || artifact.gitopsCommit.length === 0) breakAt('gitops-commit-missing', 'gitops-promote', 'GitOps commit is not visible in the successful promotion TaskRun');
|
||||
}
|
||||
const realRun = {
|
||||
@@ -2509,6 +2515,18 @@ const realRun = {
|
||||
},
|
||||
mode: sourceObservation.mode || null,
|
||||
valid: sourceObservation.valid === true,
|
||||
actualScope: {
|
||||
materializationOnly,
|
||||
affectedServiceCount: plan.affectedServiceCount ?? null,
|
||||
affectedServices: Array.isArray(plan.affectedServices) ? plan.affectedServices : [],
|
||||
rolloutServiceCount: plan.rolloutServiceCount ?? null,
|
||||
rolloutServices: Array.isArray(plan.rolloutServices) ? plan.rolloutServices : [],
|
||||
buildServiceCount: plan.buildServiceCount ?? null,
|
||||
buildServices: Array.isArray(plan.buildServices) ? plan.buildServices : [],
|
||||
reusedServiceCount: plan.reusedServiceCount ?? null,
|
||||
reusedServices: Array.isArray(plan.reusedServices) ? plan.reusedServices : [],
|
||||
valuesPrinted: false,
|
||||
},
|
||||
terminal,
|
||||
artifact: {
|
||||
imageStatus: artifact.imageStatus || null,
|
||||
|
||||
@@ -1588,6 +1588,7 @@ function remoteScript(action: "apply" | "status" | "history" | "diagnose-regress
|
||||
UNIDESK_PAC_REPOSITORY_PARAMS_JSON: JSON.stringify(repository.params),
|
||||
UNIDESK_PAC_PARAMS_JSON: JSON.stringify(consumerParams(repository, consumer)),
|
||||
UNIDESK_PAC_REGISTRY_APPLICABILITY: registryApplicability(consumer, repository),
|
||||
UNIDESK_PAC_MATERIALIZATION_ONLY: consumer.materializationOnly ? "1" : "0",
|
||||
UNIDESK_PAC_PIPELINE_NAME: consumer.pipeline,
|
||||
UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix,
|
||||
UNIDESK_PAC_CONSUMER_CONFIG_B64: Buffer.from(JSON.stringify(consumerConfig), "utf8").toString("base64"),
|
||||
|
||||
@@ -912,6 +912,7 @@ export function renderDebugStep(result: Record<string, unknown>): RenderedCliRes
|
||||
const realRun = record(result.realRun);
|
||||
const pipelineRun = record(realRun.pipelineRun);
|
||||
const artifact = record(realRun.artifact);
|
||||
const actualScope = record(realRun.actualScope);
|
||||
const catalog = record(artifact.catalog);
|
||||
const terminal = arrayRecords(realRun.terminal);
|
||||
const firstBreak = record(realRun.firstBreak);
|
||||
@@ -947,6 +948,13 @@ export function renderDebugStep(result: Record<string, unknown>): RenderedCliRes
|
||||
stringValue(firstBreak.code),
|
||||
]]),
|
||||
"",
|
||||
"ACTUAL SCOPE",
|
||||
...table(["TYPE", "COUNT", "SERVICES", "MATERIALIZATION_ONLY"], [
|
||||
["build", stringValue(actualScope.buildServiceCount), compactResultNames(actualScope.buildServices), boolText(actualScope.materializationOnly)],
|
||||
["rollout", stringValue(actualScope.rolloutServiceCount), compactResultNames(actualScope.rolloutServices), boolText(actualScope.materializationOnly)],
|
||||
["affected", stringValue(actualScope.affectedServiceCount), compactResultNames(actualScope.affectedServices), boolText(actualScope.materializationOnly)],
|
||||
]),
|
||||
"",
|
||||
"TERMINAL EVIDENCE",
|
||||
...(terminal.length === 0 ? ["-"] : table(["STEP", "PRESENT", "STATUS", "SOURCE", "MARKER", "TASKRUN", "RESULTS"], terminal.map((item) => [
|
||||
stringValue(item.step),
|
||||
|
||||
Reference in New Issue
Block a user