diff --git a/config/platform-infra/pipelines-as-code.yaml b/config/platform-infra/pipelines-as-code.yaml index 2736f2d2..e5d04e4a 100644 --- a/config/platform-infra/pipelines-as-code.yaml +++ b/config/platform-infra/pipelines-as-code.yaml @@ -211,41 +211,6 @@ repositories: runtime_service_port: "4317" health_path: /healthz health_url: http://selfmedia.selfmedia.svc.cluster.local:4317/healthz - - id: selfmedia-production-nc01 - name: selfmedia-production-nc01 - namespace: selfmedia-production-ci - providerType: gitea - url: https://gitea.pikapython.com/mirrors/pikainc-selfmedia - cloneUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git - owner: mirrors - repo: pikainc-selfmedia - secretName: pac-gitea-selfmedia-production-nc01 - tokenKey: token - webhookSecretKey: webhook.secret - concurrencyLimit: 1 - params: - git_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git - source_branch: release - source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-release-nc01 - node: NC01 - pipeline_name: selfmedia-production-nc01-pac - pipeline_run_prefix: selfmedia-production-nc01 - service_account: selfmedia-production-nc01-tekton-runner - pipeline_timeout: 2h0m0s - image_repository: 127.0.0.1:5000/selfmedia/newsroom-production - registry_probe_base: http://127.0.0.1:5000 - gitops_read_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git - gitops_write_url: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git - gitops_branch: nc01-selfmedia-production-gitops - gitops_username: unidesk-admin - gitops_secret_name: pac-gitea-selfmedia-production-nc01 - gitops_manifest_path: deploy/gitops/nc01-production/resources.yaml - runtime_namespace: selfmedia-production - runtime_deployment: selfmedia-production - runtime_service: selfmedia-production - runtime_service_port: "4317" - health_path: /healthz - health_url: http://selfmedia-production.selfmedia-production.svc.cluster.local:4317/healthz - id: pikaoa-nc01 name: pikaoa-nc01 namespace: pikaoa-ci @@ -447,15 +412,28 @@ consumers: dnsPolicy: ClusterFirstWithHostNet fsGroup: 1000 - id: selfmedia-production-nc01 - repositoryRef: selfmedia-production-nc01 + repositoryRef: selfmedia-nc01 node: NC01 lane: selfmedia-production - namespace: selfmedia-production-ci + namespace: selfmedia-ci pipeline: selfmedia-production-nc01-pac pipelineRunPrefix: selfmedia-production-nc01 argoNamespace: argocd argoApplication: selfmedia-production-nc01 closeoutGitOpsMirrorFlush: false + params: + source_branch: release + source_snapshot_prefix: refs/unidesk/snapshots/gitea-actions/selfmedia-release-nc01 + pipeline_name: selfmedia-production-nc01-pac + pipeline_run_prefix: selfmedia-production-nc01 + service_account: selfmedia-production-nc01-tekton-runner + image_repository: 127.0.0.1:5000/selfmedia/newsroom-production + gitops_branch: nc01-selfmedia-production-gitops + gitops_manifest_path: deploy/gitops/nc01-production/resources.yaml + runtime_namespace: selfmedia-production + runtime_deployment: selfmedia-production + runtime_service: selfmedia-production + health_url: http://selfmedia-production.selfmedia-production.svc.cluster.local:4317/healthz argoBootstrap: project: default repoUrl: http://gitea-http.devops-infra.svc.cluster.local:3000/mirrors/pikainc-selfmedia.git diff --git a/config/selfmedia.yaml b/config/selfmedia.yaml index 70a92415..59ff2d5d 100644 --- a/config/selfmedia.yaml +++ b/config/selfmedia.yaml @@ -20,6 +20,7 @@ delivery: serviceAccountName: selfmedia-nc01-tekton-runner serviceAccountAutomount: false roleBindingName: selfmedia-nc01-tekton-runner + parameterBinding: repository workspaceSize: 16Gi pipelineTimeout: 2h0m0s toolImage: 127.0.0.1:5000/hwlab/hwlab-ci-node-tools:node22-alpine-bun-v1 @@ -243,11 +244,12 @@ delivery: node: NC01 lane: selfmedia-production ci: - namespace: selfmedia-production-ci + namespace: selfmedia-ci pipeline: selfmedia-production-nc01-pac pipelineRunPrefix: selfmedia-production-nc01 serviceAccountName: selfmedia-production-nc01-tekton-runner roleBindingName: selfmedia-production-nc01-tekton-runner + parameterBinding: literal source: branch: release snapshotPrefix: refs/unidesk/snapshots/gitea-actions/selfmedia-release-nc01 @@ -259,7 +261,7 @@ delivery: branch: nc01-selfmedia-production-gitops manifestPath: deploy/gitops/nc01-production/resources.yaml releaseStatePath: deploy/gitops-state/nc01-production/release.json - credentialSecretName: pac-gitea-selfmedia-production-nc01 + credentialSecretName: pac-gitea-selfmedia-nc01 author: name: SelfMedia NC01 Production CI email: selfmedia-production-nc01-ci@unidesk.local 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 new file mode 100644 index 00000000..3e5ab471 --- /dev/null +++ b/docs/MDTODO/details/selfmedia-production-delivery/R3.1_Task_Report.md @@ -0,0 +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 并保留告警。 + +SELFMEDIA 开发与生产 consumer 共用仓库级 `selfmedia-nc01` Repository CR,production 通过 consumer 参数固定 release、独立 Pipeline、runner、GitOps 与 runtime。source artifact 已更新至 pikainc/selfmedia#29 的 `fba0019`,删除无语义空 `workspaces`。 + +验证: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、合并或生产触发。 diff --git a/docs/MDTODO/selfmedia-production-delivery.md b/docs/MDTODO/selfmedia-production-delivery.md index dd84fe23..39be3947 100644 --- a/docs/MDTODO/selfmedia-production-delivery.md +++ b/docs/MDTODO/selfmedia-production-delivery.md @@ -23,3 +23,7 @@ ## R3 [in_progress] 依据 [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 [completed] + +落实 [UniDesk #2007](https://github.com/pikasTech/unidesk/issues/2007) 的局部配置校验降级:显式选中的 target/consumer 字段、安全与 ownership 错误继续 fail-closed;其他未选 consumer 的一致性错误输出含 consumer、config path、code、blocking=false 的结构化 warning,不阻塞开发 source-artifact/bootstrap/status 与聚合只读查询,并补定向测试,完成任务后将详细报告写入[任务报告](./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 7e64dd45..4bdfdbe1 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 { runPlatformInfraPipelinesAsCodeCommand } from "./platform-infra-pipelines-as-code"; +import { readPacConfig, runPlatformInfraPipelinesAsCodeCommand, validatePacConfig } from "./platform-infra-pipelines-as-code"; const mirror: GiteaMirrorRepository = { key: "widgets-nc01", @@ -53,6 +53,48 @@ test("PaC bootstrap uses one exact YAML-owned Gitea repository", () => { })).toThrow("2 exact matches"); }); +test("selfmedia consumers share one PaC Repository while keeping branch parameters isolated", () => { + const config = Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record; + const repositories = config.repositories as Array>; + const consumers = config.consumers as Array>; + const selfmediaRepositories = repositories.filter((item) => item.url === "https://gitea.pikapython.com/mirrors/pikainc-selfmedia"); + expect(selfmediaRepositories).toHaveLength(1); + const development = consumers.find((item) => item.id === "selfmedia-nc01"); + const production = consumers.find((item) => item.id === "selfmedia-production-nc01"); + expect(development?.repositoryRef).toBe("selfmedia-nc01"); + expect(production?.repositoryRef).toBe("selfmedia-nc01"); + expect(development?.namespace).toBe("selfmedia-ci"); + expect(production?.namespace).toBe("selfmedia-ci"); + expect((selfmediaRepositories[0]?.params as Record).source_branch).toBe("master"); + expect((production?.params as Record).source_branch).toBe("release"); + expect((production?.params as Record).pipeline_name).toBe("selfmedia-production-nc01-pac"); +}); + +test("unselected production validation is a non-blocking warning while selected production fails closed", () => { + const config = structuredClone(readPacConfig({ consumerId: "selfmedia-nc01" })); + const development = config.consumers.find((consumer) => consumer.id === "selfmedia-nc01"); + const production = config.consumers.find((consumer) => consumer.id === "selfmedia-production-nc01"); + expect(development?.deliveryProvenance).not.toBeNull(); + expect(production?.deliveryProvenance).not.toBeNull(); + if (development?.deliveryProvenance === null || development?.deliveryProvenance === undefined || production?.deliveryProvenance === null || production?.deliveryProvenance === undefined) { + throw new Error("selfmedia delivery provenance fixtures are required"); + } + production.params.gitops_secret_name = "invalid-production-secret"; + production.deliveryProvenance.markerValue = development.deliveryProvenance.markerValue; + config.consumers = [production, development, ...config.consumers.filter((consumer) => consumer !== production && consumer !== development)]; + + const warnings = validatePacConfig(config, new Set(["selfmedia-nc01"])); + const warning = warnings.find((item) => item.consumer === "selfmedia-production-nc01"); + expect(warning).toEqual({ + code: "pac-unselected-consumer-invalid", + consumer: "selfmedia-production-nc01", + configPath: "config/platform-infra/pipelines-as-code.yaml#consumers.selfmedia-production-nc01", + blocking: false, + evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u), + }); + expect(() => validatePacConfig(config, new Set(["selfmedia-production-nc01"]))).toThrow(); +}); + 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 550abbf0..8ee0a4bb 100644 --- a/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts +++ b/scripts/src/platform-infra-pipelines-as-code-bootstrap.ts @@ -105,6 +105,7 @@ export function projectPacBootstrapResult(result: Record): Reco { id: "gitops-argo", ok: applySkipped ? null : applyReady, state: applySkipped ? "skipped" : repositoryMissing ? "waiting-for-gitea-confirm" : applyReady ? (dryRun ? "planned" : "ready") : "failed", mutation: apply.mutation === true }, ], repository: { id: repository.id, namespace: repository.namespace }, + warnings: result.warnings, blockers, next, valuesPrinted: false, @@ -119,6 +120,7 @@ export function renderPacBootstrap(result: Record): RenderedCli const preconditions = records(projection.preconditions); const stages = records(projection.stages); const blockers = records(projection.blockers); + const warnings = records(projection.warnings); const next = record(projection.next); const lines = [ "PLATFORM-INFRA PAC / TEKTON / GITOPS BOOTSTRAP", @@ -133,6 +135,11 @@ export function renderPacBootstrap(result: Record): RenderedCli "STAGES", ...table(["STAGE", "OK", "STATE", "MUTATION"], stages.map((item) => [stringValue(item.id), boolText(item.ok), stringValue(item.state), boolText(item.mutation)])), ...(blockers.length === 0 ? [] : ["", "BLOCKERS", ...blockers.map((item) => ` ${stringValue(item.code)}: ${compactLine(stringValue(item.reason))}`)]), + ...(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)])), + ]), "", "NEXT", ` ${stringValue(next.kind)}: ${stringValue(next.command)}`, diff --git a/scripts/src/platform-infra-pipelines-as-code-remote.sh b/scripts/src/platform-infra-pipelines-as-code-remote.sh index 14287745..51639095 100644 --- a/scripts/src/platform-infra-pipelines-as-code-remote.sh +++ b/scripts/src/platform-infra-pipelines-as-code-remote.sh @@ -189,7 +189,7 @@ spec: key: $UNIDESK_PAC_WEBHOOK_SECRET_KEY concurrency_limit: $UNIDESK_PAC_CONCURRENCY_LIMIT params: -$(printf '%s' "$UNIDESK_PAC_PARAMS_JSON" | node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(0,"utf8")); for (const [name,value] of Object.entries(p)) console.log(` - name: ${name}\n value: ${JSON.stringify(String(value))}`);') +$(printf '%s' "$UNIDESK_PAC_REPOSITORY_PARAMS_JSON" | node -e 'const fs=require("fs"); const p=JSON.parse(fs.readFileSync(0,"utf8")); for (const [name,value] of Object.entries(p)) console.log(` - name: ${name}\n value: ${JSON.stringify(String(value))}`);') EOF } 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 95899065..e9f36267 100644 --- a/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts +++ b/scripts/src/platform-infra-pipelines-as-code-source-artifact.ts @@ -414,6 +414,7 @@ export function renderPacSourceArtifactResult(result: Record): const firstDrift = source.firstDrift === null ? null : record(source.firstDrift); 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 lines = [ "PAC SOURCE ARTIFACT", `target=${text(result.target)} consumer=${text(result.consumer)} node=${text(result.node)} lane=${text(result.lane)}`, @@ -427,6 +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)}`), ]; if (typeof result.next === "string") lines.push(`next=${result.next}`); return renderedCliResult(result.ok !== false, String(result.action), `${lines.join("\n")}\n`); @@ -817,6 +819,9 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor if (name === "source-stage-ref") return { name, value: "{{ source_snapshot_prefix }}/{{ revision }}" }; if (name === "git-url") return { name, value: "{{ repo_url }}" }; const repositoryParam = name.replaceAll("-", "_"); + if (binding.consumer.sourceArtifact.renderer === "selfmedia-runtime" && Object.prototype.hasOwnProperty.call(param, "default")) { + return { name, value: param.default }; + } if (Object.prototype.hasOwnProperty.call(binding.repository.params, repositoryParam)) { return { name, value: `{{ ${repositoryParam} }}` }; } @@ -826,6 +831,7 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor } function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: Record): Record[] { + if (desiredSpec.workspaces === undefined) return []; return arrayRecords(desiredSpec.workspaces, "Pipeline.spec.workspaces").map((workspace) => { const name = requiredString(workspace.name, "Pipeline.spec.workspaces[].name"); if (name === "source") { diff --git a/scripts/src/platform-infra-pipelines-as-code.ts b/scripts/src/platform-infra-pipelines-as-code.ts index cd42a8da..f22df5dd 100644 --- a/scripts/src/platform-infra-pipelines-as-code.ts +++ b/scripts/src/platform-infra-pipelines-as-code.ts @@ -64,7 +64,7 @@ interface PacTarget { enabled: boolean; } -interface PacConfig { +export interface PacConfig { version: number; kind: "platform-infra-pipelines-as-code"; metadata: { @@ -139,6 +139,15 @@ interface PacConfig { }; repositories: PacRepository[]; consumers: PacConsumer[]; + validationWarnings: PacConfigValidationWarning[]; +} + +export interface PacConfigValidationWarning { + readonly code: "pac-unselected-consumer-invalid"; + readonly consumer: string; + readonly configPath: string; + readonly blocking: false; + readonly evidence: string; } interface PacRepository { @@ -185,6 +194,7 @@ interface PacConsumer { gitOps: { repoUrl: string; targetRevision: string }; } | null; repositoryRef: string; + params: Record; closeoutGitOpsMirrorFlush: boolean; closeoutGitOpsMirrorLane: "v02" | "v03" | null; sourceArtifact: PacSourceArtifactSpec | null; @@ -294,7 +304,7 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf const structuredError = args.includes("--json") || args.includes("--full"); try { const options = parsePacSourceArtifactOptions(args.slice(1)); - const pac = readPacConfig(); + 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}`); @@ -325,10 +335,13 @@ export async function runPlatformInfraPipelinesAsCodeCommand(config: UniDeskConf cloneUrl: repository.cloneUrl, owner: repository.owner, repo: repository.repo, - params: repository.params, + params: consumerParams(repository, consumer), }, }; - const result = 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, + }; return options.json || options.full ? result : renderPacSourceArtifactResult(result); } catch (error) { const safeError = pacSourceArtifactSafeError(error); @@ -424,7 +437,7 @@ function help(scope: string | null): Record { }; } -function readPacConfig(): PacConfig { +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; const release = y.objectField(root, "release", ""); const deliveryProvenance = y.objectField(root, "deliveryProvenance", ""); @@ -527,11 +540,23 @@ function readPacConfig(): PacConfig { }, repositories, consumers, + validationWarnings: [], }; - validateConfig(parsed); + const selectedConsumerIds = selectedPacConsumerIds(parsed, selection); + parsed.validationWarnings = validateConfig(parsed, selectedConsumerIds); return parsed; } +function selectedPacConsumerIds( + config: PacConfig, + 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(); +} + function parseRepository(repository: Record, path: string): PacRepository { const name = y.kubernetesNameField(repository, "name", path); return { @@ -582,6 +607,7 @@ function parseConsumer(consumer: Record, path: string, defaultR }, deliveryProvenance, repositoryRef: typeof consumer.repositoryRef === "string" && consumer.repositoryRef.length > 0 ? consumer.repositoryRef : defaultRepositoryRef, + params: consumer.params === undefined ? {} : stringRecord(y.objectField(consumer, "params", path), `${path}.params`), closeoutGitOpsMirrorFlush: y.booleanField(consumer, "closeoutGitOpsMirrorFlush", path), closeoutGitOpsMirrorLane: consumer.closeoutGitOpsMirrorLane === undefined ? null : y.enumField(consumer, "closeoutGitOpsMirrorLane", path, ["v02", "v03"] as const), sourceArtifact: consumer.sourceArtifact === undefined ? null : parseSourceArtifact(y.objectField(consumer, "sourceArtifact", path), `${path}.sourceArtifact`), @@ -819,7 +845,11 @@ function parseTarget(record: Record, index: number): PacTarget }; } -function validateConfig(config: PacConfig): void { +export function validatePacConfig(config: PacConfig, selectedConsumerIds: ReadonlySet): PacConfigValidationWarning[] { + return validateConfig(config, selectedConsumerIds); +} + +function validateConfig(config: PacConfig, selectedConsumerIds: ReadonlySet): 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`); @@ -836,10 +866,40 @@ function validateConfig(config: PacConfig): void { 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 { + validateConsumerConfig(config, consumer, markerValues, reservedServiceAccounts); + 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)), + }); + } + } + if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`); + validateTimeZone(config.display.timeZone, `${configLabel}.display.timeZone`); + return warnings; +} + +function validateConsumerConfig( + config: PacConfig, + consumer: PacConsumer, + markerValues: Set, + reservedServiceAccounts: Set, +): void { const repository = resolveRepository(config, consumer.repositoryRef); + const params = consumerParams(repository, consumer); if (consumer.id.startsWith("sentinel-")) { if (repository.params.otel_traces_endpoint !== config.observability.tracesEndpoint) { throw new Error(`${configLabel}.repositories.${repository.id}.params.otel_traces_endpoint must match observability.tracesEndpoint`); @@ -855,7 +915,6 @@ function validateConfig(config: PacConfig): void { if (consumer.deliveryProvenance !== null) { if (consumer.sourceArtifact === null) throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance requires sourceArtifact ownership`); if (markerValues.has(consumer.deliveryProvenance.markerValue)) throw new Error(`${configLabel}.consumers deliveryProvenance.markerValue must be unique: ${consumer.deliveryProvenance.markerValue}`); - markerValues.add(consumer.deliveryProvenance.markerValue); if (consumer.deliveryProvenance.executionServiceAccountName === "default") { throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must not reserve the shared default ServiceAccount`); } @@ -863,9 +922,8 @@ function validateConfig(config: PacConfig): void { if (reservedServiceAccounts.has(serviceAccountKey)) { throw new Error(`${configLabel}.consumers deliveryProvenance.executionServiceAccountName must be unique per target: ${consumer.deliveryProvenance.executionServiceAccountName}`); } - reservedServiceAccounts.add(serviceAccountKey); - if (repository.params.service_account !== consumer.deliveryProvenance.executionServiceAccountName) { - throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must match repository params.service_account`); + if (params.service_account !== consumer.deliveryProvenance.executionServiceAccountName) { + throw new Error(`${configLabel}.consumers.${consumer.id}.deliveryProvenance.executionServiceAccountName must match effective params.service_account`); } if (consumer.argoBootstrap !== null && (consumer.argoBootstrap.repoUrl !== consumer.deliveryProvenance.gitOps.repoUrl @@ -885,13 +943,10 @@ function validateConfig(config: PacConfig): void { throw new Error(`${configLabel}.consumers.${consumer.id}.argoBootstrap.repositoryCredential is required for the private selfmedia repository`); } if (consumer.closeoutGitOpsMirrorFlush) throw new Error(`${configLabel}.consumers.${consumer.id}.closeoutGitOpsMirrorFlush must be false for Gitea writeback GitOps`); - if (repository.params.gitops_secret_name !== repository.secretName) throw new Error(`${configLabel}.repositories.${repository.id}.params.gitops_secret_name must match secretName`); - if (repository.params.gitops_username !== consumer.argoBootstrap.repositoryCredential.username) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo and Tekton Gitea usernames must match`); - if (repository.params.gitops_read_url !== consumer.argoBootstrap.repoUrl) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo repoUrl must match params.gitops_read_url`); + if (params.gitops_secret_name !== repository.secretName) throw new Error(`${configLabel}.consumers.${consumer.id}.effective params.gitops_secret_name must match repository secretName`); + if (params.gitops_username !== consumer.argoBootstrap.repositoryCredential.username) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo and Tekton Gitea usernames must match`); + if (params.gitops_read_url !== consumer.argoBootstrap.repoUrl) throw new Error(`${configLabel}.consumers.${consumer.id}.Argo repoUrl must match effective params.gitops_read_url`); } - } - if (!config.gitea.webhook.events.includes("push")) throw new Error(`${configLabel}.gitea.webhook.events must include push`); - validateTimeZone(config.display.timeZone, `${configLabel}.display.timeZone`); } function numberInRange(value: unknown, minimum: number, maximum: number, path: string): number { @@ -933,8 +988,17 @@ function resolveRepository(config: PacConfig, repositoryRef: string): PacReposit return repository; } +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())); +} + function plan(options: CommonOptions): Record { - const pac = readPacConfig(); + const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true }); const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); const repository = resolveRepository(pac, consumer.repositoryRef); @@ -950,12 +1014,13 @@ function plan(options: CommonOptions): Record { consumer: consumerObservationSummary(consumer), secrets, policy: policyChecks(repository), + warnings: pac.validationWarnings, next: nextCommands(target.id, consumer.id, pac.defaults.consumerId), }; } async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { - const pac = readPacConfig(); + const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true }); const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); const repository = resolveRepository(pac, consumer.repositoryRef); @@ -975,12 +1040,13 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise> { - const pac = readPacConfig(); + const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true }); const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); if (consumer.node.toLowerCase() !== target.id.toLowerCase()) { @@ -991,14 +1057,14 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise< try { mirror = resolvePacBootstrapMirrorRepository(readGiteaConfig(), target.id, { ...repository, - sourceBranch: repository.params.source_branch, + sourceBranch: consumerParams(repository, consumer).source_branch, }); } catch (error) { - return pacBootstrapYamlMatchFailure(target.id, consumer.id, error); + return { ...pacBootstrapYamlMatchFailure(target.id, consumer.id, error), warnings: pac.validationWarnings }; } const githubPreflight = record(await runGiteaMirrorBootstrapPreflight(config, target.id, mirror.key)); if (githubPreflight.ok !== true) { - return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, { ok: false, mutation: false, mode: "skipped", githubPreflight, valuesPrinted: false }, { ok: false, mutation: false, mode: "skipped", valuesPrinted: false }); + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, { ok: false, mutation: false, mode: "skipped", githubPreflight, valuesPrinted: false }, { ok: false, mutation: false, mode: "skipped", valuesPrinted: false }, pac.validationWarnings); } const giteaArgs = ["mirror", "bootstrap", "--target", target.id, "--repo", mirror.key, ...(options.confirm ? ["--confirm"] : []), "--full"]; const giteaBootstrap = record(await runPlatformInfraGiteaCommand(config, giteaArgs)); @@ -1009,10 +1075,10 @@ async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise< mode: "skipped", error: "gitea-bootstrap-failed", valuesPrinted: false, - }); + }, pac.validationWarnings); } const pacApply = await apply(config, options); - return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, pacApply); + return bootstrapResult(target, consumer, repository, mirror, options, githubPreflight, giteaBootstrap, pacApply, pac.validationWarnings); } function bootstrapResult( @@ -1024,6 +1090,7 @@ function bootstrapResult( githubPreflight: Record, giteaBootstrap: Record, pacApply: Record, + warnings: readonly PacConfigValidationWarning[], ): Record { const remote = record(pacApply.remote); const repositoryMissing = remote.error === "gitea-repository-missing"; @@ -1053,6 +1120,7 @@ function bootstrapResult( ], giteaBootstrap, pipelinesAsCodeApply: pacApply, + warnings, next: options.confirm ? { deliveryTrigger: `Merge the prepared GitHub PR for ${mirror.upstream.repository}@${mirror.upstream.branch}; the automatic chain owns mirror, PaC, Tekton, GitOps, Argo, and runtime convergence.`, @@ -1067,7 +1135,7 @@ function bootstrapResult( } async function status(config: UniDeskConfig, options: CommonOptions): Promise> { - const pac = readPacConfig(); + const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true }); const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); const repository = resolveRepository(pac, consumer.repositoryRef); @@ -1087,6 +1155,7 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise consumer.node.toLowerCase() === nodeId.toLowerCase()); + const consumers = validPacConsumers(pac).filter((consumer) => consumer.node.toLowerCase() === nodeId.toLowerCase()); const startedAt = Date.now(); const statuses = await Promise.all(consumers.map(async (consumer) => { const repository = resolveRepository(pac, consumer.repositoryRef); @@ -1139,6 +1208,7 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC valuesPrinted: false, }, consumers: statuses, + warnings: pac.validationWarnings, summary: { ready: configured && ready, status: configured ? (ready ? "ready" : "blocked") : "warning", @@ -1157,7 +1227,7 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC } async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promise> { - const pac = readPacConfig(); + const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true }); const target = resolveTarget(pac, options.targetId); const consumer = resolveConsumer(pac, options.consumerId); const startedAt = Date.now(); @@ -1278,9 +1348,9 @@ function printCloseoutProgress(target: PacTarget, consumer: PacConsumer, stage: } async function history(config: UniDeskConfig, options: HistoryOptions): Promise> { - const pac = readPacConfig(); + const pac = readPacConfig({ consumerId: options.consumerId }); const target = resolveTarget(pac, options.targetId); - const selection = resolvePacHistoryConsumerSelection(pac.consumers, target.id, options.consumerId, options.detailId); + const selection = resolvePacHistoryConsumerSelection(validPacConsumers(pac), target.id, options.consumerId, options.detailId); 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"); @@ -1306,6 +1376,7 @@ async function history(config: UniDeskConfig, options: HistoryOptions): Promise< detailId: options.detailId, rows: arrayRecords(record(remote).rows), historyErrors, + warnings: pac.validationWarnings, controlPlane: parsed === null ? undefined : { crdPresent: parsed.crdPresent === true, controllerReady: parsed.controllerReady, @@ -1361,9 +1432,9 @@ export function resolvePacHistoryConsumerSelection( } async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promise> { - const pac = readPacConfig(); + const pac = readPacConfig({ consumerId: options.consumerId }); const target = resolveTarget(pac, options.targetId); - const selection = resolvePacHistoryConsumerSelection(pac.consumers, target.id, options.consumerId, options.detailId); + const selection = resolvePacHistoryConsumerSelection(validPacConsumers(pac), target.id, options.consumerId, options.detailId); if (selection.selectedConsumerIds.length !== 1) { throw new Error("debug-step requires --consumer or --id resolving exactly one Pipelines-as-Code consumer"); } @@ -1447,7 +1518,8 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac UNIDESK_PAC_TOKEN_KEY: repository.tokenKey, UNIDESK_PAC_WEBHOOK_SECRET_KEY: repository.webhookSecretKey, UNIDESK_PAC_CONCURRENCY_LIMIT: String(repository.concurrencyLimit), - UNIDESK_PAC_PARAMS_JSON: JSON.stringify(repository.params), + 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_PIPELINE_NAME: consumer.pipeline, UNIDESK_PAC_PIPELINE_RUN_PREFIX: consumer.pipelineRunPrefix, @@ -1483,11 +1555,12 @@ function remoteScript(action: "apply" | "status" | "history" | "debug-step", pac } function registryApplicability(consumer: PacConsumer, repository: PacRepository): "configured" | "not-configured" { + const params = consumerParams(repository, consumer); const prefix = consumer.id === "unidesk-host" ? "unidesk_host_" : ""; - const imageRepository = repository.params[`${prefix}image_repository`] ?? repository.params.image_repository; + const imageRepository = params[`${prefix}image_repository`] ?? params.image_repository; if (typeof imageRepository !== "string" || imageRepository.length === 0) return "not-configured"; if (prefix.length > 0) return "configured"; - return repository.params.pipeline_name === consumer.pipeline ? "configured" : "not-configured"; + return params.pipeline_name === consumer.pipeline ? "configured" : "not-configured"; } function historyConsumerConfig(pac: PacConfig, consumer: PacConsumer): Record { @@ -1889,6 +1962,7 @@ function consumerObservationSummary(consumer: PacConsumer): Record[] { - return pac.consumers.filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()).map((consumer) => { + return validPacConsumers(pac).filter((consumer) => consumer.node.toLowerCase() === targetId.toLowerCase()).map((consumer) => { const repository = resolveRepository(pac, consumer.repositoryRef); return { consumer: consumer.id, - source: `${repository.owner}/${repository.repo}@${repository.params.source_branch ?? "-"}`, + source: `${repository.owner}/${repository.repo}@${consumerParams(repository, consumer).source_branch ?? "-"}`, namespace: consumer.namespace, pipeline: consumer.pipeline, argoApplication: consumer.argoApplication, @@ -1945,11 +2019,13 @@ function compactPlanJson(result: Record): Record): Record): RenderedCliResult { const internalPublish = record(record(config.capabilities).sentinelInternalPublish); const secrets = arrayRecords(result.secrets); const policy = arrayRecords(result.policy); + const warnings = arrayRecords(result.warnings); const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE PLAN", ...table(["TARGET", "NAMESPACE", "RELEASE", "REPOSITORY", "CONSUMER"], [[stringValue(target.id), stringValue(repository.namespace), stringValue(record(config.release).version), stringValue(repository.name), stringValue(consumer.id)]]), @@ -2193,6 +2271,7 @@ function renderPlan(result: Record): RenderedCliResult { "", "POLICY", ...table(["NAME", "OK", "DETAIL"], policy.map((item) => [stringValue(item.name), boolText(item.ok), stringValue(item.detail)])), + ...renderPacConfigWarnings(warnings), "", "CAPABILITIES", ...table(["NAME", "ENABLED", "PROVENANCE", "TRACKING"], [[ @@ -2214,6 +2293,7 @@ function renderApply(result: Record): RenderedCliResult { const remote = record(result.remote); const preflight = record(remote.preflight); const errorText = compactLine(stringValue(remote.stderrTail, stringValue(remote.error, stringValue(result.error, "")))); + const warnings = arrayRecords(result.warnings); const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE APPLY", ...table(["TARGET", "MODE", "MUTATION", "OK"], [[stringValue(target.id), stringValue(result.mode), boolText(result.mutation), boolText(result.ok)]]), @@ -2227,6 +2307,7 @@ function renderApply(result: Record): RenderedCliResult { ]), ...(result.ok === false && errorText.length > 0 ? ["", `ERROR ${errorText}`] : []), ...(remote.error === "gitea-repository-missing" ? ["HELP bun scripts/cli.ts platform-infra pipelines-as-code help platform-bootstrap"] : []), + ...renderPacConfigWarnings(warnings), "", "NEXT", ` status: ${stringValue(record(result.next).status)}`, @@ -2240,6 +2321,7 @@ export function renderStatus(result: Record): RenderedCliResult const deliveryAuthority = record(result.deliveryAuthority); const internalPublish = record(record(record(result.config).capabilities).sentinelInternalPublish); const coverage = arrayRecords(result.coverage); + const warnings = arrayRecords(result.warnings); const latest = record(summary.latestPipelineRun); const taskRuns = arrayRecords(summary.taskRuns); const artifact = record(summary.artifact); @@ -2258,6 +2340,7 @@ export function renderStatus(result: Record): RenderedCliResult const webhooks = arrayRecords(summary.webhooks); const lines = [ "PLATFORM-INFRA PIPELINES-AS-CODE STATUS", + ...renderPacConfigWarnings(warnings), ...(coverage.length === 0 ? [] : [ "COVERAGE", ...table(["CONSUMER", "SOURCE", "NAMESPACE", "PIPELINE", "ARGO_APP"], coverage.map((item) => [stringValue(item.consumer), stringValue(item.source), stringValue(item.namespace), stringValue(item.pipeline), stringValue(item.argoApplication)])), @@ -2344,6 +2427,20 @@ export function renderStatus(result: Record): RenderedCliResult return rendered(result, "platform-infra pipelines-as-code status", lines); } +function renderPacConfigWarnings(warnings: readonly Record[]): string[] { + if (warnings.length === 0) return []; + return [ + "", + "NON-BLOCKING WARNINGS", + ...table(["CONSUMER", "BLOCKING", "CODE", "CONFIG"], warnings.map((item) => [ + stringValue(item.consumer), + boolText(item.blocking), + stringValue(item.code), + stringValue(item.configPath), + ])), + ]; +} + function renderCloseout(result: Record): RenderedCliResult { const target = record(result.target); const consumer = record(result.consumer); @@ -2414,7 +2511,7 @@ function renderCloseout(result: Record): RenderedCliResult { } async function deliveryTiming(config: UniDeskConfig, options: CommonOptions): Promise> { - const pac = readPacConfig(); + const pac = readPacConfig({ consumerId: options.consumerId, selectDefault: true }); 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}`); diff --git a/scripts/src/selfmedia-config.ts b/scripts/src/selfmedia-config.ts index 144b6c58..8c1d3157 100644 --- a/scripts/src/selfmedia-config.ts +++ b/scripts/src/selfmedia-config.ts @@ -17,6 +17,7 @@ export interface SelfMediaDeliveryTarget { readonly serviceAccountName: string; readonly serviceAccountAutomount: false; readonly roleBindingName: string; + readonly parameterBinding: "repository" | "literal"; readonly workspaceSize: string; readonly pipelineTimeout: string; readonly toolImage: string; @@ -193,6 +194,7 @@ function parseDeliveryTarget(id: string, value: Record): SelfMe serviceAccountName: kubernetesName(ci.serviceAccountName, `${path}.ci.serviceAccountName`), serviceAccountAutomount: false, roleBindingName: kubernetesName(ci.roleBindingName, `${path}.ci.roleBindingName`), + parameterBinding: enumValue(ci.parameterBinding, `${path}.ci.parameterBinding`, ["repository", "literal"] as const), workspaceSize: text(ci.workspaceSize, `${path}.ci.workspaceSize`), pipelineTimeout: text(ci.pipelineTimeout, `${path}.ci.pipelineTimeout`), toolImage: text(ci.toolImage, `${path}.ci.toolImage`), @@ -349,6 +351,12 @@ function boolean(value: unknown, path: string): boolean { return value; } +function enumValue(value: unknown, path: string, allowed: readonly T[]): T { + const result = text(value, path); + if (!allowed.includes(result as T)) throw new Error(`${selfMediaConfigRef}.${path} must be one of ${allowed.join(", ")}`); + return result as T; +} + function stringArray(value: unknown, path: string): string[] { if (!Array.isArray(value) || value.some((item) => typeof item !== "string" || item.length === 0 || item.includes("\n"))) throw new Error(`${selfMediaConfigRef}.${path} must be an array of non-empty single-line strings`); return [...value] as string[]; diff --git a/scripts/src/selfmedia-delivery-renderer.ts b/scripts/src/selfmedia-delivery-renderer.ts index 2ab5d3fd..c6c97ea4 100644 --- a/scripts/src/selfmedia-delivery-renderer.ts +++ b/scripts/src/selfmedia-delivery-renderer.ts @@ -75,7 +75,16 @@ function pipeline(target: SelfMediaDeliveryTarget, effectiveDeploymentB64: strin "gitops-username", "gitops-secret-name", ]; - const taskParams = params.map((name) => ({ name, type: "string" })); + const literalDefaults: Readonly> = { + "source-snapshot-prefix": target.source.snapshotPrefix, + "git-read-url": target.source.readUrl, + "gitops-read-url": target.gitops.readUrl, + "gitops-write-url": target.gitops.writeUrl, + "gitops-username": target.gitops.credentialUsername, + "gitops-secret-name": target.gitops.credentialSecretName, + }; + const defaults = target.ci.parameterBinding === "literal" ? literalDefaults : {}; + const taskParams = params.map((name) => ({ name, type: "string", ...(defaults[name] === undefined ? {} : { default: defaults[name] }) })); const taskParamBindings = params.map((name) => ({ name, value: `$(params.${name})` })); return { apiVersion: "tekton.dev/v1",