diff --git a/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.6_Task_Report.md b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.6_Task_Report.md new file mode 100644 index 00000000..e272d522 --- /dev/null +++ b/docs/MDTODO/details/pr-merge-driven-automatic-delivery/R9.6_Task_Report.md @@ -0,0 +1,29 @@ +# R9.6 任务报告 + +## 结果 + +- 修复 [UniDesk #2203](https://github.com/pikasTech/unidesk/issues/2203) 的 PaC release manifest YAML stream 解析缺陷。 +- renderer 现在跳过空文档以及 `Bun.YAML.parse` 返回 `null` 或 `undefined` 的 comment-only 文档。 +- renderer 在 watcher probe 注入和后续集群 mutation 前,统一验证每个实际资源均具备非空 `apiVersion` 与 `kind`。 +- 无效资源通过 `PacReleaseManifestRenderError` 返回稳定错误类型、错误码、文档序号和缺失字段,不再把 `null` 转换为 `{}` 后交给 `kubectl`。 + +## 语义保持 + +- watcher Deployment 仍按 owning YAML 声明的 deployment、namespace 与 container 精确匹配。 +- startup、readiness 与 liveness probes 的注入逻辑未改变。 +- 有效资源的原始顺序保持不变;仅空文档和 comment-only/null 文档从输出 stream 中移除。 +- 未修改 PaC 上游 URL、版本、owning YAML、PK01 或其他 consumer 配置。 + +## Fixture 与验证 + +- `release-leading-comment-only.yaml` 覆盖官方 release manifest 形状:首个文档仅包含版权注释,后续 Deployment 与 Service 正常渲染,输出保持两个有效资源。 +- `release-missing-object-identity.yaml` 覆盖缺少 `apiVersion` 与 `kind` 的实际对象,renderer 在 mutation 前返回 `pac-release-manifest-object-identity-invalid`。 +- `bun test scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts`:14 项通过,0 项失败。 +- `bun --check scripts/src/platform-infra-pipelines-as-code.ts`:通过。 +- `git diff --check`:通过。 +- 按任务边界未运行 Vitest,未使用 `validate=false`、fallback、人工 PipelineRun、Argo、mirror、trigger 或 PK01 操作。 + +## 自动交付边界 + +- 本任务只提交源码、fixture、Bun 定向测试与报告,并通过正常 PR merge 事件触发 PaC 自动交付。 +- PR 合并后的 `hwlab-nc01-production` bootstrap/status 原入口复测由主代理在正常自动链上完成;源码验证、PR 或构建结果不能替代该运行面证据。 diff --git a/scripts/src/fixtures/pac/release-leading-comment-only.yaml b/scripts/src/fixtures/pac/release-leading-comment-only.yaml new file mode 100644 index 00000000..57b66fff --- /dev/null +++ b/scripts/src/fixtures/pac/release-leading-comment-only.yaml @@ -0,0 +1,24 @@ +# Copyright 2023 The Pipelines as Code Authors. +# Licensed under the Apache License, Version 2.0. +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pipelines-as-code-watcher + namespace: pipelines-as-code +spec: + template: + spec: + containers: + - name: pac-watcher + image: example.invalid/pac-watcher:v0 + readinessProbe: + httpGet: + path: /live + port: probes +--- +apiVersion: v1 +kind: Service +metadata: + name: pipelines-as-code-watcher + namespace: pipelines-as-code diff --git a/scripts/src/fixtures/pac/release-missing-object-identity.yaml b/scripts/src/fixtures/pac/release-missing-object-identity.yaml new file mode 100644 index 00000000..84c20bc2 --- /dev/null +++ b/scripts/src/fixtures/pac/release-missing-object-identity.yaml @@ -0,0 +1,14 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: pipelines-as-code-watcher + namespace: pipelines-as-code +spec: + template: + spec: + containers: + - name: pac-watcher + image: example.invalid/pac-watcher:v0 +--- +metadata: + name: invalid-release-resource diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts index 57b36d3b..1029fd9a 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.test.ts @@ -4,7 +4,7 @@ import { resolve } from "node:path"; import type { GiteaConfig, GiteaMirrorRepository } from "./platform-infra-gitea-config"; import { renderMirrorBootstrap } from "./platform-infra-gitea-render"; import { pacBootstrapYamlMatchFailure, projectPacBootstrapResult, renderPacBootstrap, resolvePacBootstrapMirrorRepository } from "./platform-infra-pipelines-as-code-bootstrap"; -import { parsePacConfigDocument, readPacConfig, renderPacReleaseManifest, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code"; +import { PacReleaseManifestRenderError, parsePacConfigDocument, readPacConfig, renderPacReleaseManifest, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code"; import { materializeYamlComposition } from "./yaml-composition"; const mirror: GiteaMirrorRepository = { @@ -187,29 +187,10 @@ test("PaC apply checks the Gitea repository before dry-run return and cluster mu test("PaC release manifest renders YAML-owned watcher startup backlog probes", () => { const pac = readPacConfig({ consumerId: "hwlab-nc01-v03", selectDefault: true }); - const manifest = renderPacReleaseManifest(pac, `apiVersion: apps/v1 -kind: Deployment -metadata: - name: pipelines-as-code-watcher - namespace: pipelines-as-code -spec: - template: - spec: - containers: - - name: pac-watcher - image: example.invalid/pac-watcher:v0 - readinessProbe: - httpGet: - path: /live - port: probes ---- -apiVersion: v1 -kind: Service -metadata: - name: pipelines-as-code-watcher - namespace: pipelines-as-code -`); + const source = readFileSync(resolve(import.meta.dir, "fixtures/pac/release-leading-comment-only.yaml"), "utf8"); + const manifest = renderPacReleaseManifest(pac, source); const documents = manifest.split(/^---\s*$/mu).map((item) => item.trim()).filter(Boolean).map((item) => Bun.YAML.parse(item) as any); + expect(documents).toHaveLength(2); const watcher = documents[0].spec.template.spec.containers[0]; expect(watcher.startupProbe).toEqual(pac.release.workloads.watcher.startupProbe); expect(watcher.readinessProbe).toEqual(pac.release.workloads.watcher.readinessProbe); @@ -217,6 +198,22 @@ metadata: expect(documents[1].kind).toBe("Service"); }); +test("PaC release manifest rejects resources without typed object identity", () => { + const pac = readPacConfig({ consumerId: "hwlab-nc01-v03", selectDefault: true }); + const source = readFileSync(resolve(import.meta.dir, "fixtures/pac/release-missing-object-identity.yaml"), "utf8"); + try { + renderPacReleaseManifest(pac, source); + throw new Error("expected release manifest renderer to reject the invalid resource"); + } catch (error) { + expect(error).toBeInstanceOf(PacReleaseManifestRenderError); + expect(error).toMatchObject({ + code: "pac-release-manifest-object-identity-invalid", + documentIndex: 2, + missingFields: ["apiVersion", "kind"], + }); + } +}); + test("platform bootstrap help advertises the composite entry before low-level apply", async () => { const help = await runPlatformInfraPipelinesAsCodeCommand({} as never, ["help", "platform-bootstrap"]); const usage = (help as Record).usage as string[]; diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index 44f97944..b52bf00d 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -1874,11 +1874,19 @@ export function renderPacReleaseManifest(pac: PacConfig, source: string): string let matched = false; const documents = source .split(/^---\s*$/mu) - .map((item) => item.trim()) - .filter(Boolean) - .map((item) => Bun.YAML.parse(item) as unknown) + .map((item, documentIndex) => ({ documentIndex, source: item.trim() })) + .filter((item) => item.source.length > 0) + .map((item) => ({ ...item, value: Bun.YAML.parse(item.source) as unknown })) + .filter((item) => item.value !== null && item.value !== undefined) .map((item) => { - const document = record(item); + const document = record(item.value); + const missingFields = ["apiVersion", "kind"].filter((field) => typeof document[field] !== "string" || document[field].trim().length === 0); + if (missingFields.length > 0) { + throw new PacReleaseManifestRenderError(item.documentIndex + 1, missingFields); + } + return document; + }) + .map((document) => { const metadata = record(document.metadata); if (document.kind !== "Deployment" || metadata.name !== workload.deploymentName || metadata.namespace !== pac.release.namespace) return document; const spec = record(document.spec); @@ -1897,6 +1905,19 @@ export function renderPacReleaseManifest(pac: PacConfig, source: string): string return `${documents.map((item) => Bun.YAML.stringify(item).trim()).join("\n---\n")}\n`; } +export class PacReleaseManifestRenderError extends Error { + readonly code = "pac-release-manifest-object-identity-invalid"; + readonly documentIndex: number; + readonly missingFields: readonly string[]; + + constructor(documentIndex: number, missingFields: readonly string[]) { + super(`${configLabel}.release.manifest document ${documentIndex} requires non-empty ${missingFields.join(" and ")}`); + this.name = "PacReleaseManifestRenderError"; + this.documentIndex = documentIndex; + this.missingFields = missingFields; + } +} + 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 { 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);