From 4e2e7f6268c3eb4f876725a870d0383694ddf799 Mon Sep 17 00:00:00 2001 From: Codex Date: Tue, 14 Jul 2026 17:17:20 +0200 Subject: [PATCH] fix: isolate PaC validation by selected consumer --- .../R3.1_Task_Report.md | 6 +- docs/MDTODO/selfmedia-production-delivery.md | 2 +- ...-infra-pipelines-as-code-bootstrap.test.ts | 50 +++- ...tform-infra-pipelines-as-code-bootstrap.ts | 2 +- ...-pipelines-as-code-source-artifact.test.ts | 7 + ...infra-pipelines-as-code-source-artifact.ts | 12 +- .../src/platform-infra-pipelines-as-code.ts | 247 ++++++++++++++---- 7 files changed, 272 insertions(+), 54 deletions(-) diff --git a/docs/MDTODO/details/selfmedia-production-delivery/R3.1_Task_Report.md b/docs/MDTODO/details/selfmedia-production-delivery/R3.1_Task_Report.md index 3e5ab471..afd05810 100644 --- a/docs/MDTODO/details/selfmedia-production-delivery/R3.1_Task_Report.md +++ b/docs/MDTODO/details/selfmedia-production-delivery/R3.1_Task_Report.md @@ -1,7 +1,7 @@ # R3.1 任务报告 -已将 PaC 配置校验收敛为显式选中 consumer 的局部 fail-closed 与未选 consumer 的结构化非阻塞告警。YAML 解析、全局核心字段、选中 consumer 的 repository、namespace、安全、ownership 和有效参数错误继续阻塞;其他 consumer 的一致性错误输出 `consumer`、`configPath`、`code`、`blocking=false` 和脱敏 evidence。无效 consumer 不写入 marker 或 ServiceAccount 唯一性集合,聚合状态与历史只处理有效 consumer 并保留告警。 +已将 PaC 配置解析与语义校验按显式选中对象隔离。选中 consumer 及其唯一 repository 的解析、字段、权限、Secret、ownership 与 mutation target 继续 fail-closed;非选中 consumer/repository 的解析或一致性错误输出 `code`、`object.kind/id`、`configPath`、`blocking=false` 和脱敏 `evidence`。无法安全解析或归属的对象从聚合 status/history 候选中排除,同时保留 warning。 -SELFMEDIA 开发与生产 consumer 共用仓库级 `selfmedia-nc01` Repository CR,production 通过 consumer 参数固定 release、独立 Pipeline、runner、GitOps 与 runtime。source artifact 已更新至 pikainc/selfmedia#29 的 `fba0019`,删除无语义空 `workspaces`。 +`embeddedPipelineRun` 与 `remotePipelineRun` 在没有 workspace 绑定时完全省略外层 `spec.workspaces`,有绑定时保持原结构。生产 artifact 已由 [pikainc/selfmedia#29](https://github.com/pikainc/selfmedia/pull/29) 的 `783f0d3` 对齐;开发 artifact 已由 [pikainc/selfmedia#32](https://github.com/pikainc/selfmedia/pull/32) 的 `e9d5234` 对齐。两边执行 source-artifact write 均为 `mutation=false`,随后 check 均为 aligned 且 worktree clean。 -验证:PaC/Gitea 定向测试 18 pass;source artifact 专项测试 31 pass;`check --syntax-only` 11/11;production source-artifact check aligned 且 worktree clean;production bootstrap dry-run `ok=true`、`mutation=false`、branch=release、repository=selfmedia-nc01/selfmedia-ci;`git diff --check` 通过。未执行 bootstrap confirm、人工 mirror、PipelineRun、Argo sync、合并或生产触发。 +验证结果:PaC/Gitea 定向测试 19 pass;source-artifact 专项测试 32 pass;`check --syntax-only` 11/11;`git diff --check` 通过。production bootstrap dry-run 返回 `ok=true`、`mutation=false`、branch=release、repository=selfmedia-nc01/selfmedia-ci。未执行 bootstrap confirm、人工 mirror、PipelineRun、Argo sync、PR 合并或生产触发。本任务关联 [pikasTech/unidesk#2007](https://github.com/pikasTech/unidesk/issues/2007) 与 [PR #2040](https://github.com/pikasTech/unidesk/pull/2040)。 diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index cf16af5f..97aa6cc4 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -24,6 +24,6 @@ 依据 [UniDesk #2007](https://github.com/pikasTech/unidesk/issues/2007) 为 pikainc/selfmedia release 分支建立独立生产 PaC/GitOps 流水线:master 保持开发 consumer,release 精确驱动独立生产 namespace、Pipeline、GitOps branch/Application、runtime/PVC/Secret 与 YAML-first 公网端口;禁止 branch follower、手工同步和共享可写状态,以正常 release PR merge 验收生产公网入口、登录、digest 与 health,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R3_Task_Report.md)。 -### R3.1 [in_progress] +### R3.1 [completed] 按用户最新要求将非选中 consumer 的全局配置一致性错误降级为结构化 non-blocking warning,避免开发 source-artifact/状态/交付被生产配置阻塞;当前选中 target/consumer 的字段、权限、ownership 与安全校验仍 fail-closed,并由定向测试证明两类边界,完成任务后将详细报告写入[任务报告](./details/selfmedia-production-delivery/R3.1_Task_Report.md)。 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 4bdfdbe1..7244c5d8 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,8 @@ 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 { readPacConfig, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig } from "./platform-infra-pipelines-as-code"; +import { parsePacConfigDocument, readPacConfig, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig, validPacConsumers } from "./platform-infra-pipelines-as-code"; +import { materializeYamlComposition } from "./yaml-composition"; const mirror: GiteaMirrorRepository = { key: "widgets-nc01", @@ -20,6 +21,11 @@ function fixtureConfig(repositories: GiteaMirrorRepository[]): GiteaConfig { return { sourceAuthority: { repositories } } as unknown as GiteaConfig; } +function pacConfigDocument(): Record { + const source = Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record; + return structuredClone(materializeYamlComposition(source, { label: "config/platform-infra/pipelines-as-code.yaml" }).value); +} + test("PaC bootstrap uses one exact YAML-owned Gitea repository", () => { const selected = resolvePacBootstrapMirrorRepository(fixtureConfig([mirror]), "nc01", { id: "widgets-nc01", @@ -87,6 +93,7 @@ test("unselected production validation is a non-blocking warning while selected const warning = warnings.find((item) => item.consumer === "selfmedia-production-nc01"); expect(warning).toEqual({ code: "pac-unselected-consumer-invalid", + object: { kind: "consumer", id: "selfmedia-production-nc01" }, consumer: "selfmedia-production-nc01", configPath: "config/platform-infra/pipelines-as-code.yaml#consumers.selfmedia-production-nc01", blocking: false, @@ -95,6 +102,47 @@ test("unselected production validation is a non-blocking warning while selected expect(() => validatePacConfig(config, new Set(["selfmedia-production-nc01"]))).toThrow(); }); +test("unselected parse and repository errors remain warnings while selected objects fail closed", () => { + const invalidConsumerDocument = pacConfigDocument(); + const invalidConsumer = (invalidConsumerDocument.consumers as Array>).find((item) => item.id === "selfmedia-production-nc01"); + if (invalidConsumer === undefined) throw new Error("production consumer fixture is required"); + invalidConsumer.namespace = 42; + const development = parsePacConfigDocument(invalidConsumerDocument, { consumerId: "selfmedia-nc01" }); + expect(development.consumers.some((consumer) => consumer.id === "selfmedia-production-nc01")).toBe(false); + expect(development.validationWarnings).toContainEqual({ + code: "pac-unselected-consumer-parse-invalid", + object: { kind: "consumer", id: "selfmedia-production-nc01" }, + consumer: "selfmedia-production-nc01", + configPath: expect.stringMatching(/#consumers\[\d+\]$/u), + blocking: false, + evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u), + }); + expect(() => parsePacConfigDocument(invalidConsumerDocument, { consumerId: "selfmedia-production-nc01" })).toThrow("namespace"); + + const invalidRepositoryDocument = pacConfigDocument(); + const invalidRepository = (invalidRepositoryDocument.repositories as Array>).find((item) => item.id === "pikaoa-nc01"); + if (invalidRepository === undefined) throw new Error("pikaoa repository fixture is required"); + invalidRepository.cloneUrl = "https://gitea.example.invalid/mirrors/pikaoa.git"; + const isolated = parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "selfmedia-nc01" }); + expect(isolated.validationWarnings).toContainEqual({ + code: "pac-unselected-repository-invalid", + object: { kind: "repository", id: "pikaoa-nc01" }, + consumer: null, + configPath: "config/platform-infra/pipelines-as-code.yaml#repositories.pikaoa-nc01", + blocking: false, + evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u), + }); + expect(validPacConsumers(isolated).some((consumer) => consumer.repositoryRef === "pikaoa-nc01")).toBe(false); + expect(() => parsePacConfigDocument(invalidRepositoryDocument, { consumerId: "pikaoa-nc01" })).toThrow("cloneUrl"); + + const duplicateSelectedRepository = pacConfigDocument(); + const repositories = duplicateSelectedRepository.repositories as Array>; + const selfmediaRepository = repositories.find((item) => item.id === "selfmedia-nc01"); + if (selfmediaRepository === undefined) throw new Error("selfmedia repository fixture is required"); + repositories.push(structuredClone(selfmediaRepository)); + expect(() => parsePacConfigDocument(duplicateSelectedRepository, { consumerId: "selfmedia-nc01" })).toThrow("must match exactly one YAML object"); +}); + test("Gitea mirror bootstrap defaults to a visible dry-run instead of an empty refusal", () => { const rendered = renderMirrorBootstrap({ ok: true, diff --git a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts index 8ee0a4bb..1bd3f6fa 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -138,7 +138,7 @@ export function renderPacBootstrap(result: Record): RenderedCli ...(warnings.length === 0 ? [] : [ "", "NON-BLOCKING WARNINGS", - ...table(["CONSUMER", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [stringValue(item.consumer), boolText(item.blocking), stringValue(item.code), stringValue(item.configPath)])), + ...table(["OBJECT", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [`${stringValue(record(item.object).kind)}:${stringValue(record(item.object).id)}`, boolText(item.blocking), stringValue(item.code), stringValue(item.configPath)])), ]), "", "NEXT", diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts index f3a92889..b35ac4e8 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.test.ts @@ -19,6 +19,7 @@ import { pacSourceArtifactSafeError, pacValueEvidence, pacSourceArtifactYaml, + optionalPipelineRunWorkspaces, parsePacSourceArtifactOptions, sourceArtifactRuntimeAlignment, } from "./platform-infra-pipelines-as-code-source-artifact"; @@ -95,6 +96,12 @@ function runtimeInput(commit: string | null = sourceCommit): Record { + test("omits empty PipelineRun workspaces and preserves declared bindings", () => { + expect(optionalPipelineRunWorkspaces([])).toEqual({}); + const workspaces = [{ name: "source", emptyDir: {} }]; + expect(optionalPipelineRunWorkspaces(workspaces)).toEqual({ workspaces }); + }); + test("verify-runtime requires and normalizes a full source commit", () => { expect(() => parsePacSourceArtifactOptions([ "verify-runtime", "--target", "NC01", "--consumer", "agentrun-nc01-v02", "--source-worktree", "/tmp/repo", diff --git a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts index e9f36267..cc319174 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -428,7 +428,7 @@ export function renderPacSourceArtifactResult(result: Record): `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=consumer:${text(warning.consumer)} blocking:${text(warning.blocking)} code:${text(warning.code)} config:${text(warning.configPath)}`), + ...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)}`), ]; if (typeof result.next === "string") lines.push(`next=${result.next}`); return renderedCliResult(result.ok !== false, String(result.action), `${lines.join("\n")}\n`); @@ -645,6 +645,7 @@ function copySourceWorktreeForRender(sourceWorktree: string, destination: string } function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Record, provenance: PacSourceArtifactProvenance): Record { + const workspaces = pipelineRunWorkspaces(binding, desiredSpec); return { apiVersion: "tekton.dev/v1", kind: "PipelineRun", @@ -666,13 +667,14 @@ function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Rec pipelineSpec: desiredSpec, taskRunTemplate: taskRunTemplate(binding), params: pipelineRunParams(binding, desiredSpec), - workspaces: pipelineRunWorkspaces(binding, desiredSpec), + ...optionalPipelineRunWorkspaces(workspaces), }, }; } function remotePipelineRun(binding: PacSourceArtifactBinding, pipeline: Record, provenance: PacSourceArtifactProvenance): Record { const desiredSpec = requiredRecord(pipeline.spec, "rendered Pipeline.spec"); + const workspaces = pipelineRunWorkspaces(binding, desiredSpec); const pipelinePath = requiredString(binding.consumer.sourceArtifact.pipelinePath, "sourceArtifact.pipelinePath"); const annotations = { ...pipelineRunAnnotations(binding, provenance), @@ -706,12 +708,16 @@ function remotePipelineRun(binding: PacSourceArtifactBinding, pipeline: Record[]): Record { + return workspaces.length === 0 ? {} : { workspaces }; +} + function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: PacSourceArtifactProvenance): Record { const branch = requiredParam(binding, "source_branch"); return { diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index f22df5dd..6667168e 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -143,8 +143,13 @@ export interface PacConfig { } export interface PacConfigValidationWarning { - readonly code: "pac-unselected-consumer-invalid"; - readonly consumer: string; + readonly code: + | "pac-unselected-consumer-parse-invalid" + | "pac-unselected-consumer-invalid" + | "pac-unselected-repository-parse-invalid" + | "pac-unselected-repository-invalid"; + readonly object: { readonly kind: "consumer" | "repository"; readonly id: string }; + readonly consumer: string | null; readonly configPath: string; readonly blocking: false; readonly evidence: string; @@ -439,6 +444,13 @@ function help(scope: string | null): Record { export function readPacConfig(selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean } = {}): PacConfig { const root = materializeYamlComposition(readYamlRecord>(configFile, "platform-infra-pipelines-as-code"), { label: configLabel }).value; + return parsePacConfigDocument(root, selection); +} + +export function parsePacConfigDocument( + root: Record, + selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean } = {}, +): PacConfig { const release = y.objectField(root, "release", ""); const deliveryProvenance = y.objectField(root, "deliveryProvenance", ""); const provenanceController = y.objectField(deliveryProvenance, "controller", "deliveryProvenance"); @@ -454,13 +466,19 @@ export function readPacConfig(selection: { readonly consumerId?: string | null; const exporterFailure = y.objectField(observability, "exporterFailure", "observability"); const admin = y.objectField(gitea, "admin", "gitea"); const webhook = y.objectField(gitea, "webhook", "gitea"); - const repositories = root.repositories === undefined - ? [parseRepository(y.objectField(root, "repository", ""), "repository")] - : y.arrayOfRecords(root.repositories, "repositories").map((item, index) => parseRepository(item, `repositories[${index}]`)); - const consumers = root.consumers === undefined - ? [parseConsumer(y.objectField(root, "consumer", ""), "consumer", repositories[0]?.id ?? "default")] - : y.arrayOfRecords(root.consumers, "consumers").map((item, index) => parseConsumer(item, `consumers[${index}]`, repositories[0]?.id ?? "default")); const defaults = y.objectField(root, "defaults", ""); + const repositoryEntries = pacConfigObjectEntries(root.repositories, root.repository, "repositories", "repository"); + const consumerEntries = pacConfigObjectEntries(root.consumers, root.consumer, "consumers", "consumer"); + const defaultRepositoryRef = rawPacObjectId(repositoryEntries[0]?.value, repositoryEntries[0]?.path ?? "repository", "repository"); + const defaultConsumerId = typeof defaults.consumerId === "string" && defaults.consumerId.length > 0 + ? defaults.consumerId + : rawPacObjectId(consumerEntries[0]?.value, consumerEntries[0]?.path ?? "consumer", "consumer"); + const selectedConsumerIds = selectedPacConsumerIds(defaultConsumerId, selection); + assertSelectedRawObjectsUnique(consumerEntries, selectedConsumerIds, "consumer"); + const selectedRepositoryRefs = selectedPacRepositoryRefs(consumerEntries, selectedConsumerIds, defaultRepositoryRef); + assertSelectedRawObjectsUnique(repositoryEntries, selectedRepositoryRefs, "repository"); + const parsedRepositories = parsePacRepositories(repositoryEntries, selectedRepositoryRefs); + const parsedConsumers = parsePacConsumers(consumerEntries, selectedConsumerIds, defaultRepositoryRef); const parsed: PacConfig = { version: y.integerField(root, "version", ""), kind: "platform-infra-pipelines-as-code", @@ -472,7 +490,7 @@ export function readPacConfig(selection: { readonly consumerId?: string | null; }, defaults: { targetId: y.stringField(defaults, "targetId", "defaults"), - consumerId: typeof defaults.consumerId === "string" && defaults.consumerId.length > 0 ? defaults.consumerId : consumers[0]?.id ?? "default", + consumerId: defaultConsumerId, }, display: { timeZone: y.stringField(display, "timeZone", "display"), @@ -538,23 +556,138 @@ export function readPacConfig(selection: { readonly consumerId?: string | null; branch: y.stringField(webhook, "branch", "gitea.webhook"), }, }, - repositories, - consumers, - validationWarnings: [], + repositories: parsedRepositories.values, + consumers: parsedConsumers.values, + validationWarnings: [...parsedRepositories.warnings, ...parsedConsumers.warnings], }; - const selectedConsumerIds = selectedPacConsumerIds(parsed, selection); - parsed.validationWarnings = validateConfig(parsed, selectedConsumerIds); + parsed.validationWarnings = validateConfig(parsed, selectedConsumerIds, parsed.validationWarnings); return parsed; } function selectedPacConsumerIds( - config: PacConfig, + defaultConsumerId: string, selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean }, ): ReadonlySet { if (typeof selection.consumerId === "string" && selection.consumerId.length > 0) { return new Set([selection.consumerId.toLowerCase()]); } - return selection.selectDefault === true ? new Set([config.defaults.consumerId.toLowerCase()]) : new Set(); + return selection.selectDefault === true ? new Set([defaultConsumerId.toLowerCase()]) : new Set(); +} + +interface PacConfigObjectEntry { + readonly value: unknown; + readonly path: string; +} + +function pacConfigObjectEntries(plural: unknown, singular: unknown, pluralLabel: string, singularLabel: string): PacConfigObjectEntry[] { + if (plural === undefined) { + if (singular === undefined) throw new Error(`${configLabel}.${singularLabel} must be an object`); + return [{ value: singular, path: singularLabel }]; + } + if (!Array.isArray(plural)) throw new Error(`${configLabel}.${pluralLabel} must be an array`); + return plural.map((value, index) => ({ value, path: `${pluralLabel}[${index}]` })); +} + +function rawPacObjectId(value: unknown, path: string, kind: "consumer" | "repository"): string { + if (!isRecord(value)) return path; + if (typeof value.id === "string" && value.id.length > 0) return value.id; + if (kind === "repository" && typeof value.name === "string" && value.name.length > 0) return value.name; + if (kind === "consumer" && typeof value.node === "string" && value.node.length > 0 && typeof value.lane === "string" && value.lane.length > 0) { + return `${value.node}-${value.lane}`; + } + return path; +} + +function rawPacObjectRefs(entry: PacConfigObjectEntry, kind: "consumer" | "repository"): string[] { + if (!isRecord(entry.value)) return [entry.path.toLowerCase()]; + const refs = [rawPacObjectId(entry.value, entry.path, kind)]; + if (kind === "repository" && typeof entry.value.name === "string" && entry.value.name.length > 0) refs.push(entry.value.name); + return [...new Set(refs.map((value) => value.toLowerCase()))]; +} + +function assertSelectedRawObjectsUnique( + entries: readonly PacConfigObjectEntry[], + selectedRefs: ReadonlySet, + kind: "consumer" | "repository", +): void { + for (const selectedRef of selectedRefs) { + const matches = entries.filter((entry) => rawPacObjectRefs(entry, kind).includes(selectedRef)); + if (matches.length !== 1) { + throw new Error(`selected Pipelines-as-Code ${kind} ${selectedRef} must match exactly one YAML object; matched ${matches.length}`); + } + } +} + +function selectedPacRepositoryRefs( + consumerEntries: readonly PacConfigObjectEntry[], + selectedConsumerIds: ReadonlySet, + defaultRepositoryRef: string, +): ReadonlySet { + const refs = new Set(); + for (const entry of consumerEntries) { + if (!rawPacObjectRefs(entry, "consumer").some((value) => selectedConsumerIds.has(value))) continue; + const repositoryRef = isRecord(entry.value) && typeof entry.value.repositoryRef === "string" && entry.value.repositoryRef.length > 0 + ? entry.value.repositoryRef + : defaultRepositoryRef; + refs.add(repositoryRef.toLowerCase()); + } + return refs; +} + +function parsePacRepositories( + entries: readonly PacConfigObjectEntry[], + selectedRepositoryRefs: ReadonlySet, +): { readonly values: PacRepository[]; readonly warnings: PacConfigValidationWarning[] } { + const values: PacRepository[] = []; + const warnings: PacConfigValidationWarning[] = []; + for (const entry of entries) { + const id = rawPacObjectId(entry.value, entry.path, "repository"); + try { + if (!isRecord(entry.value)) throw new Error(`${configLabel}.${entry.path} must be an object`); + values.push(parseRepository(entry.value, entry.path)); + } catch (error) { + if (rawPacObjectRefs(entry, "repository").some((value) => selectedRepositoryRefs.has(value))) throw error; + warnings.push(pacConfigWarning("pac-unselected-repository-parse-invalid", "repository", id, entry.path, error)); + } + } + return { values, warnings }; +} + +function parsePacConsumers( + entries: readonly PacConfigObjectEntry[], + selectedConsumerIds: ReadonlySet, + defaultRepositoryRef: string, +): { readonly values: PacConsumer[]; readonly warnings: PacConfigValidationWarning[] } { + const values: PacConsumer[] = []; + const warnings: PacConfigValidationWarning[] = []; + for (const entry of entries) { + const id = rawPacObjectId(entry.value, entry.path, "consumer"); + try { + if (!isRecord(entry.value)) throw new Error(`${configLabel}.${entry.path} must be an object`); + values.push(parseConsumer(entry.value, entry.path, defaultRepositoryRef)); + } catch (error) { + if (selectedConsumerIds.has(id.toLowerCase())) throw error; + warnings.push(pacConfigWarning("pac-unselected-consumer-parse-invalid", "consumer", id, entry.path, error)); + } + } + return { values, warnings }; +} + +function pacConfigWarning( + code: PacConfigValidationWarning["code"], + kind: PacConfigValidationWarning["object"]["kind"], + id: string, + path: string, + error: unknown, +): PacConfigValidationWarning { + return { + code, + object: { kind, id }, + consumer: kind === "consumer" ? id : null, + configPath: `${configLabel}#${path}`, + blocking: false, + evidence: pacValueEvidence(error instanceof Error ? error.message : String(error)), + }; } function parseRepository(repository: Record, path: string): PacRepository { @@ -846,45 +979,51 @@ function parseTarget(record: Record, index: number): PacTarget } export function validatePacConfig(config: PacConfig, selectedConsumerIds: ReadonlySet): PacConfigValidationWarning[] { - return validateConfig(config, selectedConsumerIds); + return validateConfig(config, selectedConsumerIds, []); } -function validateConfig(config: PacConfig, selectedConsumerIds: ReadonlySet): PacConfigValidationWarning[] { +function validateConfig( + config: PacConfig, + selectedConsumerIds: ReadonlySet, + initialWarnings: readonly PacConfigValidationWarning[], +): PacConfigValidationWarning[] { if (config.version !== 1) throw new Error(`${configLabel}.version must be 1`); resolveTarget(config, config.defaults.targetId); if (config.release.waitTimeoutSeconds > 55) throw new Error(`${configLabel}.release.waitTimeoutSeconds must fit the 60s trans budget`); if (config.closeout.gitOpsMirrorFlush.waitTimeoutSeconds > 55) throw new Error(`${configLabel}.closeout.gitOpsMirrorFlush.waitTimeoutSeconds must fit the 60s trans budget`); - resolveConsumer(config, config.defaults.consumerId); + if (selectedConsumerIds.has(config.defaults.consumerId.toLowerCase())) resolveConsumer(config, config.defaults.consumerId); + const warnings: PacConfigValidationWarning[] = [...initialWarnings]; + const selectedRepositoryRefs = new Set(config.consumers + .filter((consumer) => selectedConsumerIds.has(consumer.id.toLowerCase())) + .map((consumer) => consumer.repositoryRef.toLowerCase())); const ids = new Set(); for (const repository of config.repositories) { - if (ids.has(repository.id)) throw new Error(`${configLabel}.repositories id must be unique: ${repository.id}`); - ids.add(repository.id); - if (repository.providerType !== "gitea") throw new Error(`${configLabel}.repositories.${repository.id}.providerType must be gitea`); - if (isInternalRepositoryMatchUrl(repository.url)) throw new Error(`${configLabel}.repositories.${repository.id}.url must be the public Gitea URL used in webhook payloads; keep internal service URLs in cloneUrl/params.git_read_url`); - if (new URL(repository.cloneUrl).origin !== new URL(config.gitea.internalBaseUrl).origin) throw new Error(`${configLabel}.repositories.${repository.id}.cloneUrl must use the configured internal Gitea base URL`); + try { + if (ids.has(repository.id)) throw new Error(`${configLabel}.repositories id must be unique: ${repository.id}`); + if (repository.providerType !== "gitea") throw new Error(`${configLabel}.repositories.${repository.id}.providerType must be gitea`); + if (isInternalRepositoryMatchUrl(repository.url)) throw new Error(`${configLabel}.repositories.${repository.id}.url must be the public Gitea URL used in webhook payloads; keep internal service URLs in cloneUrl/params.git_read_url`); + if (new URL(repository.cloneUrl).origin !== new URL(config.gitea.internalBaseUrl).origin) throw new Error(`${configLabel}.repositories.${repository.id}.cloneUrl must use the configured internal Gitea base URL`); + ids.add(repository.id); + } catch (error) { + if (selectedRepositoryRefs.has(repository.id.toLowerCase()) || selectedRepositoryRefs.has(repository.name.toLowerCase())) throw error; + warnings.push(pacConfigWarning("pac-unselected-repository-invalid", "repository", repository.id, `repositories.${repository.id}`, error)); + } } const consumerIds = new Set(); const markerValues = new Set(); const reservedServiceAccounts = new Set(); - const warnings: PacConfigValidationWarning[] = []; for (const consumer of config.consumers) { - if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`); - consumerIds.add(consumer.id); try { + if (consumerIds.has(consumer.id)) throw new Error(`${configLabel}.consumers id must be unique: ${consumer.id}`); validateConsumerConfig(config, consumer, markerValues, reservedServiceAccounts); + consumerIds.add(consumer.id); if (consumer.deliveryProvenance !== null) { markerValues.add(consumer.deliveryProvenance.markerValue); reservedServiceAccounts.add(`${consumer.node.toLowerCase()}\u0000${consumer.deliveryProvenance.executionServiceAccountName}`); } } catch (error) { if (selectedConsumerIds.has(consumer.id.toLowerCase())) throw error; - warnings.push({ - code: "pac-unselected-consumer-invalid", - consumer: consumer.id, - configPath: `${configLabel}#consumers.${consumer.id}`, - blocking: false, - evidence: pacValueEvidence(error instanceof Error ? error.message : String(error)), - }); + warnings.push(pacConfigWarning("pac-unselected-consumer-invalid", "consumer", consumer.id, `consumers.${consumer.id}`, error)); } } if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`); @@ -977,24 +1116,38 @@ function resolveTarget(config: PacConfig, targetId: string | null): PacTarget { function resolveConsumer(config: PacConfig, consumerId: string | null): PacConsumer { const id = consumerId ?? config.defaults.consumerId; - const consumer = config.consumers.find((item) => item.id.toLowerCase() === id.toLowerCase()); - if (consumer === undefined) throw new Error(`unknown Pipelines-as-Code consumer ${id}; known consumers: ${config.consumers.map((item) => item.id).join(", ")}`); - return consumer; + const matches = config.consumers.filter((item) => item.id.toLowerCase() === id.toLowerCase()); + if (matches.length !== 1) throw new Error(`Pipelines-as-Code consumer ${id} must resolve exactly once; matched ${matches.length}; known consumers: ${config.consumers.map((item) => item.id).join(", ")}`); + return matches[0] as PacConsumer; } function resolveRepository(config: PacConfig, repositoryRef: string): PacRepository { - const repository = config.repositories.find((item) => item.id === repositoryRef || item.name === repositoryRef); - if (repository === undefined) throw new Error(`unknown Pipelines-as-Code repository ${repositoryRef}; known repositories: ${config.repositories.map((item) => item.id).join(", ")}`); - return repository; + const matches = config.repositories.filter((item) => item.id === repositoryRef || item.name === repositoryRef); + if (matches.length !== 1) throw new Error(`Pipelines-as-Code repository ${repositoryRef} must resolve exactly once; matched ${matches.length}; known repositories: ${config.repositories.map((item) => item.id).join(", ")}`); + return matches[0] as PacRepository; } function consumerParams(repository: PacRepository, consumer: PacConsumer): Record { return { ...repository.params, ...consumer.params }; } -function validPacConsumers(config: PacConfig): PacConsumer[] { - const invalid = new Set(config.validationWarnings.map((warning) => warning.consumer.toLowerCase())); - return config.consumers.filter((consumer) => !invalid.has(consumer.id.toLowerCase())); +export function validPacConsumers(config: PacConfig): PacConsumer[] { + const invalidConsumers = new Set(config.validationWarnings + .filter((warning) => warning.object.kind === "consumer") + .map((warning) => warning.object.id.toLowerCase())); + const invalidRepositories = new Set(config.validationWarnings + .filter((warning) => warning.object.kind === "repository") + .map((warning) => warning.object.id.toLowerCase())); + for (const repository of config.repositories) { + if (invalidRepositories.has(repository.id.toLowerCase()) || invalidRepositories.has(repository.name.toLowerCase())) { + invalidRepositories.add(repository.id.toLowerCase()); + invalidRepositories.add(repository.name.toLowerCase()); + } + } + return config.consumers.filter((consumer) => ( + !invalidConsumers.has(consumer.id.toLowerCase()) + && !invalidRepositories.has(consumer.repositoryRef.toLowerCase()) + )); } function plan(options: CommonOptions): Record { @@ -2432,8 +2585,8 @@ function renderPacConfigWarnings(warnings: readonly Record[]): return [ "", "NON-BLOCKING WARNINGS", - ...table(["CONSUMER", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [ - stringValue(item.consumer), + ...table(["OBJECT", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [ + `${stringValue(record(item.object).kind)}:${stringValue(record(item.object).id)}`, boolText(item.blocking), stringValue(item.code), stringValue(item.configPath), @@ -2955,6 +3108,10 @@ function record(value: unknown): Record { return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record : {}; } +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + function arrayRecords(value: unknown): Record[] { return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record[] : []; }