Merge pull request #2040 from pikasTech/fix/selfmedia-production-shared-pac-repository
Pipelines as Code CI / hwlab-web-probe-sentinel-nc01- Success
Pipelines as Code CI / platform-infra-gitea-nc01- Success
Pipelines as Code CI / unidesk-host- Success

fix: 共享 SELFMEDIA PaC Repository 并隔离生产 consumer
This commit is contained in:
Lyon
2026-07-14 23:21:48 +08:00
committed by GitHub
12 changed files with 480 additions and 106 deletions
+15 -37
View File
@@ -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
+4 -2
View File
@@ -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
@@ -0,0 +1,7 @@
# R3.1 任务报告
已将 PaC 配置解析与语义校验按显式选中对象隔离。选中 consumer 及其唯一 repository 的解析、字段、权限、Secret、ownership 与 mutation target 继续 fail-closed;非选中 consumer/repository 的解析或一致性错误输出 `code``object.kind/id``configPath``blocking=false` 和脱敏 `evidence`。无法安全解析或归属的对象从聚合 status/history 候选中排除,同时保留 warning。
`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 定向测试 19 passsource-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)。
+1 -1
View File
@@ -24,6 +24,6 @@
依据 [UniDesk #2007](https://github.com/pikasTech/unidesk/issues/2007) 为 pikainc/selfmedia release 分支建立独立生产 PaC/GitOps 流水线:master 保持开发 consumerrelease 精确驱动独立生产 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)。
@@ -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 { runPlatformInfraPipelinesAsCodeCommand } 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<string, unknown> {
const source = Bun.YAML.parse(readFileSync(resolve(import.meta.dir, "../../config/platform-infra/pipelines-as-code.yaml"), "utf8")) as Record<string, unknown>;
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",
@@ -53,6 +59,90 @@ 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<string, unknown>;
const repositories = config.repositories as Array<Record<string, unknown>>;
const consumers = config.consumers as Array<Record<string, unknown>>;
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<string, unknown>).source_branch).toBe("master");
expect((production?.params as Record<string, unknown>).source_branch).toBe("release");
expect((production?.params as Record<string, unknown>).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",
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,
evidence: expect.stringMatching(/^type=string,length=\d+,sha256=[0-9a-f]{64}$/u),
});
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<Record<string, unknown>>).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<Record<string, unknown>>).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<Record<string, unknown>>;
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,
@@ -105,6 +105,7 @@ export function projectPacBootstrapResult(result: Record<string, unknown>): 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<string, unknown>): 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<string, unknown>): 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(["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",
` ${stringValue(next.kind)}: ${stringValue(next.command)}`,
@@ -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
}
@@ -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<string, unkn
}
describe("PaC source artifact CLI contract", () => {
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",
@@ -414,6 +414,7 @@ export function renderPacSourceArtifactResult(result: Record<string, unknown>):
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<string, unknown>):
`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=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`);
@@ -643,6 +645,7 @@ function copySourceWorktreeForRender(sourceWorktree: string, destination: string
}
function embeddedPipelineRun(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>, provenance: PacSourceArtifactProvenance): Record<string, unknown> {
const workspaces = pipelineRunWorkspaces(binding, desiredSpec);
return {
apiVersion: "tekton.dev/v1",
kind: "PipelineRun",
@@ -664,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<string, unknown>, provenance: PacSourceArtifactProvenance): Record<string, unknown> {
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),
@@ -704,12 +708,16 @@ function remotePipelineRun(binding: PacSourceArtifactBinding, pipeline: Record<s
timeouts: { pipeline: binding.repository.params.pipeline_timeout },
taskRunTemplate: taskRunTemplate(binding),
pipelineRef: { name: binding.consumer.pipeline },
workspaces: pipelineRunWorkspaces(binding, desiredSpec),
...optionalPipelineRunWorkspaces(workspaces),
params: pipelineRunParams(binding, desiredSpec),
},
};
}
export function optionalPipelineRunWorkspaces(workspaces: readonly Record<string, unknown>[]): Record<string, unknown> {
return workspaces.length === 0 ? {} : { workspaces };
}
function pipelineRunAnnotations(binding: PacSourceArtifactBinding, provenance: PacSourceArtifactProvenance): Record<string, string> {
const branch = requiredParam(binding, "source_branch");
return {
@@ -817,6 +825,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 +837,7 @@ function pipelineRunParams(binding: PacSourceArtifactBinding, desiredSpec: Recor
}
function pipelineRunWorkspaces(binding: PacSourceArtifactBinding, desiredSpec: Record<string, unknown>): Record<string, unknown>[] {
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") {
+315 -61
View File
@@ -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,20 @@ interface PacConfig {
};
repositories: PacRepository[];
consumers: PacConsumer[];
validationWarnings: PacConfigValidationWarning[];
}
export interface PacConfigValidationWarning {
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;
}
interface PacRepository {
@@ -185,6 +199,7 @@ interface PacConsumer {
gitOps: { repoUrl: string; targetRevision: string };
} | null;
repositoryRef: string;
params: Record<string, string>;
closeoutGitOpsMirrorFlush: boolean;
closeoutGitOpsMirrorLane: "v02" | "v03" | null;
sourceArtifact: PacSourceArtifactSpec | null;
@@ -294,7 +309,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 +340,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,8 +442,15 @@ function help(scope: string | null): Record<string, unknown> {
};
}
function readPacConfig(): PacConfig {
export function readPacConfig(selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean } = {}): PacConfig {
const root = materializeYamlComposition(readYamlRecord<Record<string, unknown>>(configFile, "platform-infra-pipelines-as-code"), { label: configLabel }).value;
return parsePacConfigDocument(root, selection);
}
export function parsePacConfigDocument(
root: Record<string, unknown>,
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");
@@ -441,13 +466,19 @@ function readPacConfig(): PacConfig {
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",
@@ -459,7 +490,7 @@ function readPacConfig(): PacConfig {
},
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"),
@@ -525,13 +556,140 @@ function readPacConfig(): PacConfig {
branch: y.stringField(webhook, "branch", "gitea.webhook"),
},
},
repositories,
consumers,
repositories: parsedRepositories.values,
consumers: parsedConsumers.values,
validationWarnings: [...parsedRepositories.warnings, ...parsedConsumers.warnings],
};
validateConfig(parsed);
parsed.validationWarnings = validateConfig(parsed, selectedConsumerIds, parsed.validationWarnings);
return parsed;
}
function selectedPacConsumerIds(
defaultConsumerId: string,
selection: { readonly consumerId?: string | null; readonly selectDefault?: boolean },
): ReadonlySet<string> {
if (typeof selection.consumerId === "string" && selection.consumerId.length > 0) {
return new Set([selection.consumerId.toLowerCase()]);
}
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<string>,
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<string>,
defaultRepositoryRef: string,
): ReadonlySet<string> {
const refs = new Set<string>();
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<string>,
): { 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<string>,
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<string, unknown>, path: string): PacRepository {
const name = y.kubernetesNameField(repository, "name", path);
return {
@@ -582,6 +740,7 @@ function parseConsumer(consumer: Record<string, unknown>, 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,27 +978,67 @@ function parseTarget(record: Record<string, unknown>, index: number): PacTarget
};
}
function validateConfig(config: PacConfig): void {
export function validatePacConfig(config: PacConfig, selectedConsumerIds: ReadonlySet<string>): PacConfigValidationWarning[] {
return validateConfig(config, selectedConsumerIds, []);
}
function validateConfig(
config: PacConfig,
selectedConsumerIds: ReadonlySet<string>,
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<string>();
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<string>();
const markerValues = new Set<string>();
const reservedServiceAccounts = new Set<string>();
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(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`);
validateTimeZone(config.display.timeZone, `${configLabel}.display.timeZone`);
return warnings;
}
function validateConsumerConfig(
config: PacConfig,
consumer: PacConsumer,
markerValues: Set<string>,
reservedServiceAccounts: Set<string>,
): 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 +1054,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 +1061,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 +1082,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 {
@@ -922,19 +1116,42 @@ 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<string, string> {
return { ...repository.params, ...consumer.params };
}
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<string, unknown> {
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 +1167,13 @@ function plan(options: CommonOptions): Record<string, unknown> {
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<Record<string, unknown>> {
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 +1193,13 @@ async function apply(config: UniDeskConfig, options: ApplyOptions): Promise<Reco
consumer: consumerObservationSummary(consumer),
secrets: secretSummaries(pac),
remote: parsed ?? compactCapture(result, { full: true }),
warnings: pac.validationWarnings,
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
};
}
async function bootstrap(config: UniDeskConfig, options: ApplyOptions): Promise<Record<string, unknown>> {
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 +1210,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 +1228,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 +1243,7 @@ function bootstrapResult(
githubPreflight: Record<string, unknown>,
giteaBootstrap: Record<string, unknown>,
pacApply: Record<string, unknown>,
warnings: readonly PacConfigValidationWarning[],
): Record<string, unknown> {
const remote = record(pacApply.remote);
const repositoryMissing = remote.error === "gitea-repository-missing";
@@ -1053,6 +1273,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 +1288,7 @@ function bootstrapResult(
}
async function status(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
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 +1308,7 @@ async function status(config: UniDeskConfig, options: CommonOptions): Promise<Re
coverage: consumerCoverage(pac, target.id),
summary,
remote: parsed === null ? compactCapture(result, { full: true }) : options.raw ? parsed : undefined,
warnings: pac.validationWarnings,
next: nextCommands(target.id, consumer.id, pac.defaults.consumerId),
};
}
@@ -1095,7 +1317,7 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
const pac = readPacConfig();
const target = resolveTarget(pac, options.targetId ?? options.nodeId);
const nodeId = options.nodeId ?? target.id;
const consumers = pac.consumers.filter((consumer) => 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 +1361,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 +1380,7 @@ export async function getPlatformInfraPipelinesAsCodeNodeStatus(config: UniDeskC
}
async function closeout(config: UniDeskConfig, options: CloseoutOptions): Promise<Record<string, unknown>> {
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 +1501,9 @@ function printCloseoutProgress(target: PacTarget, consumer: PacConsumer, stage:
}
async function history(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
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 +1529,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 +1585,9 @@ export function resolvePacHistoryConsumerSelection(
}
async function debugStep(config: UniDeskConfig, options: HistoryOptions): Promise<Record<string, unknown>> {
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 +1671,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 +1708,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<string, unknown> {
@@ -1889,6 +2115,7 @@ function consumerObservationSummary(consumer: PacConsumer): Record<string, unkno
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
params: Object.keys(consumer.params).sort(),
runnerServiceAccount: consumer.runnerServiceAccount,
argoRepositoryCredential: consumer.argoBootstrap?.repositoryCredential === null || consumer.argoBootstrap?.repositoryCredential === undefined ? null : {
secretName: consumer.argoBootstrap.repositoryCredential.secretName,
@@ -1899,11 +2126,11 @@ function consumerObservationSummary(consumer: PacConsumer): Record<string, unkno
}
function consumerCoverage(pac: PacConfig, targetId: string): Record<string, unknown>[] {
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 +2172,13 @@ function compactPlanJson(result: Record<string, unknown>): Record<string, unknow
argoNamespace: consumer.argoNamespace,
argoApplication: consumer.argoApplication,
repositoryRef: consumer.repositoryRef,
params: consumer.params,
runnerServiceAccount: consumer.runnerServiceAccount,
argoRepositoryCredential: consumer.argoRepositoryCredential,
},
secrets: result.secrets,
policy: result.policy,
warnings: result.warnings,
next: result.next,
valuesPrinted: false,
};
@@ -1972,6 +2201,7 @@ function compactStatusJson(result: Record<string, unknown>): Record<string, unkn
},
deliveryAuthority: result.deliveryAuthority,
capabilities: record(result.config).capabilities,
warnings: result.warnings,
summary: compactStatusSummary(record(result.summary)),
next: result.next,
valuesPrinted: false,
@@ -2176,6 +2406,7 @@ function renderPlan(result: Record<string, unknown>): 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 +2424,7 @@ function renderPlan(result: Record<string, unknown>): 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 +2446,7 @@ function renderApply(result: Record<string, unknown>): 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 +2460,7 @@ function renderApply(result: Record<string, unknown>): 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 +2474,7 @@ export function renderStatus(result: Record<string, unknown>): 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 +2493,7 @@ export function renderStatus(result: Record<string, unknown>): 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 +2580,20 @@ export function renderStatus(result: Record<string, unknown>): RenderedCliResult
return rendered(result, "platform-infra pipelines-as-code status", lines);
}
function renderPacConfigWarnings(warnings: readonly Record<string, unknown>[]): string[] {
if (warnings.length === 0) return [];
return [
"",
"NON-BLOCKING WARNINGS",
...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),
])),
];
}
function renderCloseout(result: Record<string, unknown>): RenderedCliResult {
const target = record(result.target);
const consumer = record(result.consumer);
@@ -2414,7 +2664,7 @@ function renderCloseout(result: Record<string, unknown>): RenderedCliResult {
}
async function deliveryTiming(config: UniDeskConfig, options: CommonOptions): Promise<Record<string, unknown>> {
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}`);
@@ -2858,6 +3108,10 @@ function record(value: unknown): Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function arrayRecords(value: unknown): Record<string, unknown>[] {
return Array.isArray(value) ? value.filter((item) => typeof item === "object" && item !== null && !Array.isArray(item)) as Record<string, unknown>[] : [];
}
+8
View File
@@ -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<string, unknown>): 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<const T extends string>(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[];
+10 -1
View File
@@ -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<Record<string, string>> = {
"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",