fix(cicd): 收紧 managed repository 配置与状态合同

This commit is contained in:
Codex
2026-07-12 00:35:27 +02:00
parent 49f13e404e
commit 2f8ccee0a5
5 changed files with 257 additions and 7 deletions
+4
View File
@@ -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
+19
View File
@@ -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<string, unknown>, 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<string, unknown>, 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"]),
@@ -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);
@@ -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<string, unknow
const cacheVolume = spec.gitMirror.cacheHostPath === null
? { name: "cache", persistentVolumeClaim: { claimName: spec.gitMirror.cachePvc } }
: { name: "cache", hostPath: { path: spec.gitMirror.cacheHostPath, type: "DirectoryOrCreate" } };
const loopbackProxy = spec.gitMirror.githubProxy.host === "127.0.0.1" || spec.gitMirror.githubProxy.host === "localhost";
return [
{
apiVersion: "v1",
@@ -208,7 +208,8 @@ function managedRepositoryObjects(spec: AgentRunLaneSpec): Record<string, unknow
serviceAccountName: reconciler.serviceAccountName,
automountServiceAccountToken: false,
terminationGracePeriodSeconds: Math.ceil(reconciler.shutdownGraceMs / 1000),
...(loopbackProxy ? { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet" } : {}),
hostNetwork: reconciler.hostNetwork,
dnsPolicy: reconciler.dnsPolicy,
containers: [{
name: "managed-repository-reconciler",
image: spec.gitMirror.toolsImage,
@@ -253,6 +254,25 @@ function managedRepositoryObjects(spec: AgentRunLaneSpec): Record<string, unknow
];
}
function managedRepositoryManifestIdentity(spec: AgentRunLaneSpec): Record<string, unknown> {
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");
}
+44 -5
View File
@@ -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<typeof resolveAgentRunLaneTarget>,
deliveryAuthority: ReturnType<typeof resolveCicdDeliveryAuthority>,
observation: Awaited<ReturnType<typeof readGitMirrorStatus>>,
options: GitMirrorStatusOptions,
): Record<string, unknown> {
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<typeof resolveCicdDelive
};
}
function compactManagedRepositoryStatus(summary: Record<string, unknown>): Record<string, unknown> {
function compactManagedRepositoryStatus(summary: Record<string, unknown>, limit: number): Record<string, unknown> {
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<string, unknown>): 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<string, unknown>): 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<string, number | null> {
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;
}