From 2f8ccee0a5d520a9bcb0c52bfdd08c7850359355 Mon Sep 17 00:00:00 2001 From: Codex Date: Sun, 12 Jul 2026 00:35:27 +0200 Subject: [PATCH] =?UTF-8?q?fix(cicd):=20=E6=94=B6=E7=B4=A7=20managed=20rep?= =?UTF-8?q?ository=20=E9=85=8D=E7=BD=AE=E4=B8=8E=E7=8A=B6=E6=80=81?= =?UTF-8?q?=E5=90=88=E5=90=8C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config/agentrun.yaml | 4 + scripts/src/agentrun-lanes.ts | 19 ++ ...trun-managed-repository-reconciler.test.ts | 168 ++++++++++++++++++ .../agentrun-managed-repository-reconciler.ts | 24 ++- scripts/src/agentrun/git-mirror.ts | 49 ++++- 5 files changed, 257 insertions(+), 7 deletions(-) diff --git a/config/agentrun.yaml b/config/agentrun.yaml index be845349..6464a2dd 100644 --- a/config/agentrun.yaml +++ b/config/agentrun.yaml @@ -246,6 +246,8 @@ controlPlane: deploymentName: agentrun-nc01-v02-managed-repository-reconciler configMapName: agentrun-nc01-v02-managed-repository-reconciler serviceAccountName: agentrun-nc01-v02-managed-repository-reconciler + hostNetwork: true + dnsPolicy: ClusterFirstWithHostNet imagePullPolicy: IfNotPresent cacheMountPath: /cache stateDirectory: .agentrun-managed-repositories/nc01-v02 @@ -264,6 +266,8 @@ controlPlane: periodSeconds: 10 timeoutSeconds: 5 failureThreshold: 6 + status: + defaultRepositoryLimit: 8 lifecycle: undeclaredRepositoryPolicy: retain managedRefs: source-branch-only diff --git a/scripts/src/agentrun-lanes.ts b/scripts/src/agentrun-lanes.ts index af88d634..f43fc3d7 100644 --- a/scripts/src/agentrun-lanes.ts +++ b/scripts/src/agentrun-lanes.ts @@ -30,6 +30,8 @@ export interface AgentRunManagedRepositoryReconcilerSpec { readonly deploymentName: string; readonly configMapName: string; readonly serviceAccountName: string; + readonly hostNetwork: boolean; + readonly dnsPolicy: "ClusterFirst" | "ClusterFirstWithHostNet" | "Default"; readonly imagePullPolicy: "Always" | "IfNotPresent" | "Never"; readonly cacheMountPath: string; readonly stateDirectory: string; @@ -51,6 +53,9 @@ export interface AgentRunManagedRepositoryReconcilerSpec { readonly timeoutSeconds: number; readonly failureThreshold: number; }; + readonly status: { + readonly defaultRepositoryLimit: number; + }; readonly lifecycle: { readonly undeclaredRepositoryPolicy: "retain"; readonly managedRefs: "source-branch-only"; @@ -1126,14 +1131,25 @@ function parseManagedRepositoryReconciler(input: Record, path: const retry = recordField(input, "retry", path); const freshness = recordField(input, "freshness", path); const readiness = recordField(input, "readiness", path); + const status = recordField(input, "status", path); const lifecycle = recordField(input, "lifecycle", path); const stateDirectory = relativePathField(input, "stateDirectory", path); + const hostNetwork = booleanField(input, "hostNetwork", path); + const dnsPolicy = enumField(input, "dnsPolicy", path, ["ClusterFirst", "ClusterFirstWithHostNet", "Default"]); if (stateDirectory === "." || stateDirectory.length === 0) throw new Error(`${path}.stateDirectory must name a dedicated relative directory`); + if (hostNetwork && dnsPolicy !== "ClusterFirstWithHostNet") { + throw new Error(`${path}.dnsPolicy must be ClusterFirstWithHostNet when ${path}.hostNetwork is true`); + } + if (!hostNetwork && dnsPolicy === "ClusterFirstWithHostNet") { + throw new Error(`${path}.dnsPolicy must not be ClusterFirstWithHostNet when ${path}.hostNetwork is false`); + } return { enabled: booleanField(input, "enabled", path), deploymentName: kubernetesNameField(input, "deploymentName", path), configMapName: kubernetesNameField(input, "configMapName", path), serviceAccountName: kubernetesNameField(input, "serviceAccountName", path), + hostNetwork, + dnsPolicy, imagePullPolicy: enumField(input, "imagePullPolicy", path, ["Always", "IfNotPresent", "Never"]), cacheMountPath: absolutePathField(input, "cacheMountPath", path), stateDirectory, @@ -1155,6 +1171,9 @@ function parseManagedRepositoryReconciler(input: Record, path: timeoutSeconds: positiveIntegerField(readiness, "timeoutSeconds", `${path}.readiness`), failureThreshold: positiveIntegerField(readiness, "failureThreshold", `${path}.readiness`), }, + status: { + defaultRepositoryLimit: positiveIntegerField(status, "defaultRepositoryLimit", `${path}.status`), + }, lifecycle: { undeclaredRepositoryPolicy: enumField(lifecycle, "undeclaredRepositoryPolicy", `${path}.lifecycle`, ["retain"]), managedRefs: enumField(lifecycle, "managedRefs", `${path}.lifecycle`, ["source-branch-only"]), diff --git a/scripts/src/agentrun-managed-repository-reconciler.test.ts b/scripts/src/agentrun-managed-repository-reconciler.test.ts index 0320fb43..51765736 100644 --- a/scripts/src/agentrun-managed-repository-reconciler.test.ts +++ b/scripts/src/agentrun-managed-repository-reconciler.test.ts @@ -11,9 +11,12 @@ import { join } from "node:path"; import { describe, expect, test } from "bun:test"; import { agentRunManagedRepositoryDesiredConfig, + agentRunManagedRepositoryRenderHash, renderAgentRunManagedRepositoryReconcilerFragment, } from "./agentrun-managed-repository-reconciler"; import { resolveAgentRunLaneTarget } from "./agentrun-lanes"; +import { resolveCicdDeliveryAuthority } from "./cicd-delivery-authority"; +import { renderGitMirrorStatusEnvelope } from "./agentrun/git-mirror"; import { yamlLaneGitMirrorStatusScript } from "./agentrun/secrets"; import { renderPlatformInfraGiteaDesiredFragments } from "./platform-infra-gitea-desired-fragments"; @@ -43,6 +46,8 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { expect(JSON.parse(documents[1].data["config.json"]).repositories).toEqual(desired.repositories); expect(documents[2].spec.strategy.type).toBe("Recreate"); expect(documents[2].spec.template.spec.automountServiceAccountToken).toBe(false); + expect(documents[2].spec.template.spec.hostNetwork).toBe(spec.gitMirror.repositoryReconciler.hostNetwork); + expect(documents[2].spec.template.spec.dnsPolicy).toBe(spec.gitMirror.repositoryReconciler.dnsPolicy); expect(documents[2].spec.template.spec.containers[0].readinessProbe.exec.command).toContain("--health"); expect(renderAgentRunManagedRepositoryReconcilerFragment("JD01")).toBe(""); @@ -112,6 +117,62 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { } }); + test("renders host networking only from the explicit reconciler fields", () => { + const temporary = mkdtempSync("/tmp/unidesk-agentrun-repository-network-"); + try { + const source = readFileSync(new URL("../../config/agentrun.yaml", import.meta.url), "utf8"); + const nonLoopbackProxy = source.replace( + "githubProxy:\n host: 127.0.0.1\n port: 10808", + "githubProxy:\n host: proxy.internal.example\n port: 10808", + ); + const nonLoopbackPath = join(temporary, "non-loopback.yaml"); + writeFileSync(nonLoopbackPath, nonLoopbackProxy); + const nonLoopbackEnv = { ...process.env, AGENTRUN_CONTROL_PLANE_CONFIG: nonLoopbackPath }; + const nonLoopbackDeployment = renderAgentRunManagedRepositoryReconcilerFragment("NC01", nonLoopbackEnv) + .split(/^---\s*$/mu) + .map((item) => Bun.YAML.parse(item) as any) + .find((item) => item.kind === "Deployment"); + expect(nonLoopbackDeployment.spec.template.spec.hostNetwork).toBe(true); + expect(nonLoopbackDeployment.spec.template.spec.dnsPolicy).toBe("ClusterFirstWithHostNet"); + + const clusterNetwork = source.replace( + "hostNetwork: true\n dnsPolicy: ClusterFirstWithHostNet", + "hostNetwork: false\n dnsPolicy: ClusterFirst", + ); + const clusterNetworkPath = join(temporary, "cluster-network.yaml"); + writeFileSync(clusterNetworkPath, clusterNetwork); + const clusterNetworkEnv = { ...process.env, AGENTRUN_CONTROL_PLANE_CONFIG: clusterNetworkPath }; + const clusterNetworkSpec = resolveAgentRunLaneTarget( + { node: "NC01", lane: "nc01-v02" }, + clusterNetworkEnv, + ).spec; + const clusterNetworkDeployment = renderAgentRunManagedRepositoryReconcilerFragment("NC01", clusterNetworkEnv) + .split(/^---\s*$/mu) + .map((item) => Bun.YAML.parse(item) as any) + .find((item) => item.kind === "Deployment"); + expect(clusterNetworkDeployment.spec.template.spec.hostNetwork).toBe(false); + expect(clusterNetworkDeployment.spec.template.spec.dnsPolicy).toBe("ClusterFirst"); + const defaultSpec = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }).spec; + expect(agentRunManagedRepositoryRenderHash(clusterNetworkSpec)).not.toBe(agentRunManagedRepositoryRenderHash(defaultSpec)); + + const invalidHostNetworkPath = join(temporary, "invalid-host-network.yaml"); + writeFileSync(invalidHostNetworkPath, source.replace("dnsPolicy: ClusterFirstWithHostNet", "dnsPolicy: ClusterFirst")); + expect(() => resolveAgentRunLaneTarget( + { node: "NC01", lane: "nc01-v02" }, + { ...process.env, AGENTRUN_CONTROL_PLANE_CONFIG: invalidHostNetworkPath }, + )).toThrow("must be ClusterFirstWithHostNet when"); + + const invalidClusterNetworkPath = join(temporary, "invalid-cluster-network.yaml"); + writeFileSync(invalidClusterNetworkPath, source.replace("hostNetwork: true", "hostNetwork: false")); + expect(() => resolveAgentRunLaneTarget( + { node: "NC01", lane: "nc01-v02" }, + { ...process.env, AGENTRUN_CONTROL_PLANE_CONFIG: invalidClusterNetworkPath }, + )).toThrow("must not be ClusterFirstWithHostNet when"); + } finally { + rmSync(temporary, { recursive: true, force: true }); + } + }); + test("materializes source refs, exposes health, and retains undeclared cache content", () => { const temporary = mkdtempSync("/tmp/unidesk-agentrun-managed-repository-"); try { @@ -224,6 +285,113 @@ describe("AgentRun YAML-owned managed repository reconciler", () => { } }); + test("keeps the legacy default summary contract and bounds the final CLI envelope", () => { + const target = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }); + const spec = target.spec; + const authority = resolveCicdDeliveryAuthority({ + node: spec.nodeId, + lane: spec.lane, + sourceRepository: spec.source.repository, + sourceBranch: spec.source.branch, + declaredSourceAuthorityMode: spec.source.sourceAuthority?.mode ?? null, + declaredConfigRef: target.configPath, + }); + expect(authority.kind).toBe("pac-pr-merge"); + const defaultRepositoryLimit = spec.gitMirror.repositoryReconciler.status.defaultRepositoryLimit; + const fixtureRepositoryTotal = defaultRepositoryLimit + 2; + const repositories = Array.from({ length: fixtureRepositoryTotal }, (_, index) => ({ + key: `repo-${index}`, + repository: `acme/repo-${index}`, + sourceBranch: "main", + cacheCommit: String(index).repeat(40), + cacheRefPresent: true, + servedCommit: String(index).repeat(40), + servedRefPresent: true, + refAligned: true, + remoteFingerprintAligned: true, + fresh: true, + })); + const summary = { + ok: true, + repository: "pikasTech/agentrun", + sourceBranch: "v0.2", + gitopsBranch: "nc01-v0.2-gitops", + sourceCommit: "a".repeat(40), + gitopsCommit: "b".repeat(40), + controller: { + deploymentReady: true, + configRendered: true, + workloadRendered: true, + statusAligned: true, + heartbeatFresh: true, + }, + repositories, + firstDrift: null, + valuesPrinted: false, + }; + const observation = { + ok: true, + summary, + raw: JSON.stringify(summary), + result: { exitCode: 0, stdout: JSON.stringify(summary), stderr: "" }, + } as any; + const baseOptions = { node: "NC01", lane: "nc01-v02" }; + const compact = renderGitMirrorStatusEnvelope( + target, + authority, + observation, + { ...baseOptions, full: false, raw: false }, + ) as any; + expect(compact.summary).toMatchObject({ + repository: "pikasTech/agentrun", + sourceBranch: "v0.2", + gitopsBranch: "nc01-v0.2-gitops", + sourceCommit: "a".repeat(40), + gitopsCommit: "b".repeat(40), + }); + expect(compact.summary.repositories).toHaveLength(defaultRepositoryLimit); + expect(compact.summary.repositoryView).toEqual({ + limit: defaultRepositoryLimit, + total: fixtureRepositoryTotal, + shown: defaultRepositoryLimit, + omitted: fixtureRepositoryTotal - defaultRepositoryLimit, + }); + expect(compact.target.repositoryView).toEqual({ + limit: defaultRepositoryLimit, + total: spec.gitMirror.repositories.length, + shown: spec.gitMirror.repositories.length, + omitted: 0, + }); + expect(compact.probe.stdoutTailOmitted).toBe(true); + expect(compact.disclosure.probeTailOmitted).toBe(true); + expect(compact.next).toMatchObject({ status: expect.any(String), history: expect.any(String) }); + expect(compact.next.sync).toBeUndefined(); + + for (const options of [ + { ...baseOptions, full: true, raw: false }, + { ...baseOptions, full: false, raw: true }, + ]) { + const expanded = renderGitMirrorStatusEnvelope(target, authority, observation, options) as any; + expect(expanded.summary.repositories).toHaveLength(fixtureRepositoryTotal); + expect(expanded.summary.repositoryView).toEqual({ + limit: null, + total: fixtureRepositoryTotal, + shown: fixtureRepositoryTotal, + omitted: 0, + }); + expect(expanded.target.repositories).toHaveLength(spec.gitMirror.repositories.length); + expect(expanded.target.repositoryView).toEqual({ + limit: null, + total: spec.gitMirror.repositories.length, + shown: spec.gitMirror.repositories.length, + omitted: 0, + }); + expect(expanded.probe.stdoutTailOmitted).toBe(!options.raw); + expect(expanded.probe.stdoutTail).toBe(options.raw ? JSON.stringify(summary) : undefined); + expect(expanded.disclosure.probeTailOmitted).toBe(!options.raw); + } + }); + test("keeps legacy main-repository fields and bounds per-repository probes", () => { const spec = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }).spec; const script = yamlLaneGitMirrorStatusScript(spec); diff --git a/scripts/src/agentrun-managed-repository-reconciler.ts b/scripts/src/agentrun-managed-repository-reconciler.ts index b1dc1c39..08b93751 100644 --- a/scripts/src/agentrun-managed-repository-reconciler.ts +++ b/scripts/src/agentrun-managed-repository-reconciler.ts @@ -90,6 +90,7 @@ export function agentRunManagedRepositoryRenderHash(spec: AgentRunLaneSpec): str const config = agentRunManagedRepositoryDesiredConfig(spec); return sha256([ JSON.stringify(config), + JSON.stringify(managedRepositoryManifestIdentity(spec)), readFileSync(controllerSourcePath, "utf8"), readFileSync(proxySourcePath, "utf8"), ].join("\0")); @@ -151,7 +152,6 @@ function managedRepositoryObjects(spec: AgentRunLaneSpec): Record { + const reconciler = spec.gitMirror.repositoryReconciler; + return { + namespace: spec.gitMirror.namespace, + deploymentName: reconciler.deploymentName, + configMapName: reconciler.configMapName, + serviceAccountName: reconciler.serviceAccountName, + hostNetwork: reconciler.hostNetwork, + dnsPolicy: reconciler.dnsPolicy, + image: spec.gitMirror.toolsImage, + imagePullPolicy: reconciler.imagePullPolicy, + cachePvc: spec.gitMirror.cachePvc, + cacheHostPath: spec.gitMirror.cacheHostPath, + sshSecretName: spec.gitMirror.sshSecretName, + readiness: reconciler.readiness, + resources: reconciler.resources, + }; +} + function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); } diff --git a/scripts/src/agentrun/git-mirror.ts b/scripts/src/agentrun/git-mirror.ts index 0148cb2d..b402da07 100644 --- a/scripts/src/agentrun/git-mirror.ts +++ b/scripts/src/agentrun/git-mirror.ts @@ -482,18 +482,33 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS declaredConfigRef: target.configPath, }); const observation = await readGitMirrorStatus(config, target); + return renderGitMirrorStatusEnvelope(target, deliveryAuthority, observation, options); +} + +export function renderGitMirrorStatusEnvelope( + target: ReturnType, + deliveryAuthority: ReturnType, + observation: Awaited>, + options: GitMirrorStatusOptions, +): Record { + const spec = target.spec; const summary = observation.summary; - const outputSummary = options.full || options.raw ? summary : compactManagedRepositoryStatus(summary); const expanded = options.full || options.raw; + const repositoryLimit = spec.gitMirror.repositoryReconciler.status.defaultRepositoryLimit; + const compactTargetRepositories = spec.gitMirror.repositories + .slice(0, repositoryLimit) + .map((repository) => ({ key: repository.key, sourceBranch: repository.sourceBranch })); const compactTarget = { node: spec.nodeId, lane: spec.lane, namespace: spec.gitMirror.namespace, controller: spec.gitMirror.repositoryReconciler.deploymentName, - repositories: spec.gitMirror.repositories.map((repository) => ({ key: repository.key, sourceBranch: repository.sourceBranch })), + repositories: compactTargetRepositories, + repositoryView: repositoryListView(spec.gitMirror.repositories.length, compactTargetRepositories.length, repositoryLimit), }; const fullTarget = { ...compactTarget, + repositories: spec.gitMirror.repositories.map((repository) => ({ key: repository.key, sourceBranch: repository.sourceBranch })), version: spec.version, sourceAuthority: spec.source.sourceAuthority, gitMirror: { @@ -515,7 +530,15 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS remoteConfigured: true, })), }, + repositoryView: repositoryListView(spec.gitMirror.repositories.length, spec.gitMirror.repositories.length, null), }; + const summaryRepositories = Array.isArray(summary.repositories) ? summary.repositories : []; + const outputSummary = expanded + ? { + ...summary, + repositoryView: repositoryListView(summaryRepositories.length, summaryRepositories.length, null), + } + : compactManagedRepositoryStatus(summary, repositoryLimit); const outputNext = deliveryAuthority.kind === "pac-pr-merge" ? { status: `bun scripts/cli.ts platform-infra pipelines-as-code status --target ${deliveryAuthority.consumer.node} --consumer ${deliveryAuthority.consumer.consumerId}`, @@ -549,7 +572,7 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS full: options.full, raw: options.raw, rawOmitted: !options.raw, - probeTailOmitted: !(options.full || options.raw), + probeTailOmitted: !options.raw, expandWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --full`, rawWith: `bun scripts/cli.ts agentrun git-mirror status --node ${spec.nodeId} --lane ${spec.lane} --raw`, }, @@ -578,9 +601,9 @@ function compactDeliveryAuthority(authority: ReturnType): Record { +function compactManagedRepositoryStatus(summary: Record, limit: number): Record { const controller = record(summary.controller); - const repositories = Array.isArray(summary.repositories) + const allRepositories = Array.isArray(summary.repositories) ? summary.repositories.map((value) => record(value)).map((repository) => ({ key: repository.key ?? null, sourceBranch: repository.sourceBranch ?? null, @@ -592,8 +615,14 @@ function compactManagedRepositoryStatus(summary: Record): Recor aligned: repository.refAligned === true && repository.remoteFingerprintAligned === true, })) : []; + const repositories = allRepositories.slice(0, limit); return { ok: summary.ok === true, + repository: summary.repository ?? null, + sourceBranch: summary.sourceBranch ?? null, + gitopsBranch: summary.gitopsBranch ?? null, + sourceCommit: summary.sourceCommit ?? null, + gitopsCommit: summary.gitopsCommit ?? null, controller: { deploymentReady: controller.deploymentReady === true, desiredRendered: controller.configRendered === true && controller.workloadRendered === true, @@ -601,11 +630,21 @@ function compactManagedRepositoryStatus(summary: Record): Recor heartbeatFresh: controller.heartbeatFresh === true, }, repositories, + repositoryView: repositoryListView(allRepositories.length, repositories.length, limit), firstDrift: summary.firstDrift ?? null, valuesPrinted: false, }; } +function repositoryListView(total: number, shown: number, limit: number | null): Record { + return { + limit, + total, + shown, + omitted: Math.max(0, total - shown), + }; +} + function shortCommit(value: unknown): string | null { return typeof value === "string" && /^[0-9a-f]{40}$/u.test(value) ? value.slice(0, 12) : null; }