feat: 自动物化 AgentRun managed repositories
This commit is contained in:
@@ -20,10 +20,44 @@ export const AGENTRUN_CONFIG_PATH = "config/agentrun.yaml";
|
||||
export interface AgentRunGitMirrorRepositorySpec {
|
||||
readonly key: string;
|
||||
readonly repository: string;
|
||||
readonly remote: string;
|
||||
readonly sourceBranch: string;
|
||||
readonly gitopsBranch?: string;
|
||||
}
|
||||
|
||||
export interface AgentRunManagedRepositoryReconcilerSpec {
|
||||
readonly enabled: boolean;
|
||||
readonly deploymentName: string;
|
||||
readonly configMapName: string;
|
||||
readonly serviceAccountName: string;
|
||||
readonly imagePullPolicy: "Always" | "IfNotPresent" | "Never";
|
||||
readonly cacheMountPath: string;
|
||||
readonly stateDirectory: string;
|
||||
readonly reconcileIntervalMs: number;
|
||||
readonly fetchTimeoutMs: number;
|
||||
readonly shutdownGraceMs: number;
|
||||
readonly maxConcurrentRepositories: number;
|
||||
readonly retry: {
|
||||
readonly maxAttempts: number;
|
||||
readonly initialDelayMs: number;
|
||||
readonly maxDelayMs: number;
|
||||
};
|
||||
readonly freshness: {
|
||||
readonly maxAgeMs: number;
|
||||
};
|
||||
readonly readiness: {
|
||||
readonly initialDelaySeconds: number;
|
||||
readonly periodSeconds: number;
|
||||
readonly timeoutSeconds: number;
|
||||
readonly failureThreshold: number;
|
||||
};
|
||||
readonly lifecycle: {
|
||||
readonly undeclaredRepositoryPolicy: "retain";
|
||||
readonly managedRefs: "source-branch-only";
|
||||
};
|
||||
readonly resources: AgentRunContainerResources;
|
||||
}
|
||||
|
||||
export interface AgentRunSourceAuthoritySpec {
|
||||
readonly mode: "gitMirrorSnapshot";
|
||||
readonly resolver: "k8s-git-mirror";
|
||||
@@ -205,6 +239,7 @@ export interface AgentRunLaneSpec {
|
||||
readonly toolsImage: string;
|
||||
readonly syncJobPrefix: string;
|
||||
readonly flushJobPrefix: string;
|
||||
readonly repositoryReconciler: AgentRunManagedRepositoryReconcilerSpec;
|
||||
readonly repositories: readonly AgentRunGitMirrorRepositorySpec[];
|
||||
};
|
||||
readonly database: {
|
||||
@@ -327,6 +362,16 @@ export function resolveAgentRunLaneTarget(options: { node?: string | null; lane?
|
||||
return { configPath: config.sourcePath, spec };
|
||||
}
|
||||
|
||||
export function resolveAgentRunLaneTargetsForNode(nodeId: string, env: NodeJS.ProcessEnv = process.env): readonly AgentRunLaneTarget[] {
|
||||
const config = readAgentRunControlPlaneConfig(env);
|
||||
const targets = Object.values(config.lanes)
|
||||
.filter((lane) => lane.nodeId === nodeId)
|
||||
.sort((left, right) => left.lane.localeCompare(right.lane))
|
||||
.map((spec) => ({ configPath: config.sourcePath, spec }));
|
||||
if (targets.length === 0) throw new Error(`${config.sourcePath}: no AgentRun lane is declared for node ${nodeId}`);
|
||||
return targets;
|
||||
}
|
||||
|
||||
export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unknown> {
|
||||
return {
|
||||
node: {
|
||||
@@ -425,9 +470,11 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
|
||||
cacheHostPath: spec.gitMirror.cacheHostPath,
|
||||
sshSecretName: spec.gitMirror.sshSecretName,
|
||||
githubProxy: { host: spec.gitMirror.githubProxy.host, port: spec.gitMirror.githubProxy.port },
|
||||
repositoryReconciler: spec.gitMirror.repositoryReconciler,
|
||||
repositories: spec.gitMirror.repositories.map((repo) => ({
|
||||
key: repo.key,
|
||||
repository: repo.repository,
|
||||
remoteConfigured: repo.remote.length > 0,
|
||||
sourceBranch: repo.sourceBranch,
|
||||
gitopsBranch: repo.gitopsBranch ?? null,
|
||||
})),
|
||||
@@ -646,7 +693,8 @@ function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, u
|
||||
toolsImage: stringField(gitMirror, "toolsImage", `${path}.gitMirror`),
|
||||
syncJobPrefix: stringField(gitMirror, "syncJobPrefix", `${path}.gitMirror`),
|
||||
flushJobPrefix: stringField(gitMirror, "flushJobPrefix", `${path}.gitMirror`),
|
||||
repositories: arrayField(gitMirror, "repositories", `${path}.gitMirror`).map((repo, index) => parseGitMirrorRepository(repo, `${path}.gitMirror.repositories[${index}]`)),
|
||||
repositoryReconciler: parseManagedRepositoryReconciler(recordField(gitMirror, "repositoryReconciler", `${path}.gitMirror`), `${path}.gitMirror.repositoryReconciler`),
|
||||
repositories: parseGitMirrorRepositories(arrayField(gitMirror, "repositories", `${path}.gitMirror`), `${path}.gitMirror.repositories`),
|
||||
},
|
||||
database: parseDatabase(database, `${path}.database`),
|
||||
secrets: parseLaneSecrets(input, path),
|
||||
@@ -1030,16 +1078,118 @@ function parseProviderCredentialBinding(input: Record<string, unknown>, path: st
|
||||
return profile;
|
||||
}
|
||||
|
||||
function parseGitMirrorRepositories(input: readonly Record<string, unknown>[], path: string): readonly AgentRunGitMirrorRepositorySpec[] {
|
||||
if (input.length === 0) throw new Error(`${path} must declare at least one repository`);
|
||||
const repositories = input.map((item, index) => parseGitMirrorRepository(item, `${path}[${index}]`));
|
||||
const keys = new Set<string>();
|
||||
const names = new Set<string>();
|
||||
const remotes = new Set<string>();
|
||||
for (const item of repositories) {
|
||||
if (keys.has(item.key)) throw new Error(`${path} contains duplicate key ${item.key}`);
|
||||
if (names.has(item.repository)) throw new Error(`${path} contains duplicate repository ${item.repository}`);
|
||||
if (remotes.has(item.remote)) throw new Error(`${path} contains duplicate remote ownership for ${item.remote}`);
|
||||
keys.add(item.key);
|
||||
names.add(item.repository);
|
||||
remotes.add(item.remote);
|
||||
}
|
||||
return repositories;
|
||||
}
|
||||
|
||||
function parseGitMirrorRepository(input: Record<string, unknown>, path: string): AgentRunGitMirrorRepositorySpec {
|
||||
const key = stringField(input, "key", path);
|
||||
const repository = stringField(input, "repository", path);
|
||||
const remote = stringField(input, "remote", path);
|
||||
const sourceBranch = stringField(input, "sourceBranch", path);
|
||||
const gitopsBranch = optionalStringField(input, "gitopsBranch", path);
|
||||
validateSimpleId(key, `${path}.key`);
|
||||
validateRepositoryPath(repository, `${path}.repository`);
|
||||
validateRepositoryRemote(remote, repository, `${path}.remote`);
|
||||
validateGitRefName(sourceBranch, `${path}.sourceBranch`);
|
||||
if (gitopsBranch !== undefined) validateGitRefName(gitopsBranch, `${path}.gitopsBranch`);
|
||||
return {
|
||||
key: stringField(input, "key", path),
|
||||
repository: stringField(input, "repository", path),
|
||||
sourceBranch: stringField(input, "sourceBranch", path),
|
||||
key,
|
||||
repository,
|
||||
remote,
|
||||
sourceBranch,
|
||||
...(gitopsBranch === undefined ? {} : { gitopsBranch }),
|
||||
};
|
||||
}
|
||||
|
||||
function parseManagedRepositoryReconciler(input: Record<string, unknown>, path: string): AgentRunManagedRepositoryReconcilerSpec {
|
||||
const retry = recordField(input, "retry", path);
|
||||
const freshness = recordField(input, "freshness", path);
|
||||
const readiness = recordField(input, "readiness", path);
|
||||
const lifecycle = recordField(input, "lifecycle", path);
|
||||
const stateDirectory = relativePathField(input, "stateDirectory", path);
|
||||
if (stateDirectory === "." || stateDirectory.length === 0) throw new Error(`${path}.stateDirectory must name a dedicated relative directory`);
|
||||
return {
|
||||
enabled: booleanField(input, "enabled", path),
|
||||
deploymentName: kubernetesNameField(input, "deploymentName", path),
|
||||
configMapName: kubernetesNameField(input, "configMapName", path),
|
||||
serviceAccountName: kubernetesNameField(input, "serviceAccountName", path),
|
||||
imagePullPolicy: enumField(input, "imagePullPolicy", path, ["Always", "IfNotPresent", "Never"]),
|
||||
cacheMountPath: absolutePathField(input, "cacheMountPath", path),
|
||||
stateDirectory,
|
||||
reconcileIntervalMs: positiveIntegerField(input, "reconcileIntervalMs", path),
|
||||
fetchTimeoutMs: positiveIntegerField(input, "fetchTimeoutMs", path),
|
||||
shutdownGraceMs: positiveIntegerField(input, "shutdownGraceMs", path),
|
||||
maxConcurrentRepositories: positiveIntegerField(input, "maxConcurrentRepositories", path),
|
||||
retry: {
|
||||
maxAttempts: positiveIntegerField(retry, "maxAttempts", `${path}.retry`),
|
||||
initialDelayMs: positiveIntegerField(retry, "initialDelayMs", `${path}.retry`),
|
||||
maxDelayMs: positiveIntegerField(retry, "maxDelayMs", `${path}.retry`),
|
||||
},
|
||||
freshness: {
|
||||
maxAgeMs: positiveIntegerField(freshness, "maxAgeMs", `${path}.freshness`),
|
||||
},
|
||||
readiness: {
|
||||
initialDelaySeconds: positiveIntegerField(readiness, "initialDelaySeconds", `${path}.readiness`),
|
||||
periodSeconds: positiveIntegerField(readiness, "periodSeconds", `${path}.readiness`),
|
||||
timeoutSeconds: positiveIntegerField(readiness, "timeoutSeconds", `${path}.readiness`),
|
||||
failureThreshold: positiveIntegerField(readiness, "failureThreshold", `${path}.readiness`),
|
||||
},
|
||||
lifecycle: {
|
||||
undeclaredRepositoryPolicy: enumField(lifecycle, "undeclaredRepositoryPolicy", `${path}.lifecycle`, ["retain"]),
|
||||
managedRefs: enumField(lifecycle, "managedRefs", `${path}.lifecycle`, ["source-branch-only"]),
|
||||
},
|
||||
resources: parseContainerResources(recordField(input, "resources", path), `${path}.resources`),
|
||||
};
|
||||
}
|
||||
|
||||
function validateRepositoryPath(value: string, path: string): void {
|
||||
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u.test(value) || value.includes("..")) {
|
||||
throw new Error(`${path} must be an owner/name repository path without ..`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateRepositoryRemote(value: string, repository: string, path: string): void {
|
||||
try {
|
||||
const remote = new URL(value);
|
||||
const remoteRepository = decodeURIComponent(remote.pathname).replace(/^\/+|\.git$/gu, "");
|
||||
if (remote.protocol !== "ssh:" || remote.username.length === 0 || remote.password.length > 0 || remote.search.length > 0 || remote.hash.length > 0) {
|
||||
throw new Error("unsupported remote");
|
||||
}
|
||||
if (remoteRepository !== repository) throw new Error("repository mismatch");
|
||||
} catch {
|
||||
throw new Error(`${path} must be an ssh URL owned by the same repository entry (${repository})`);
|
||||
}
|
||||
}
|
||||
|
||||
function validateGitRefName(value: string, path: string): void {
|
||||
if (
|
||||
value.length === 0
|
||||
|| value.startsWith("-")
|
||||
|| value.startsWith("/")
|
||||
|| value.endsWith("/")
|
||||
|| value.endsWith(".")
|
||||
|| value.includes("..")
|
||||
|| value.includes("@{")
|
||||
|| /[\u0000-\u0020~^:?*[\\]/u.test(value)
|
||||
) {
|
||||
throw new Error(`${path} must be a safe Git branch name`);
|
||||
}
|
||||
}
|
||||
|
||||
function parseDatabase(input: Record<string, unknown>, path: string): AgentRunLaneSpec["database"] {
|
||||
const mode = enumField(input, "mode", path, ["local-postgres", "external-postgres"]);
|
||||
const sslmode = optionalStringField(input, "sslmode", path);
|
||||
@@ -1177,6 +1327,13 @@ function validateKubernetesNamePrefix(value: string, path: string): void {
|
||||
if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9])?$/u.test(value)) throw new Error(`${path} must be a Kubernetes name prefix`);
|
||||
}
|
||||
|
||||
function kubernetesNameField(obj: Record<string, unknown>, key: string, path: string): string {
|
||||
const value = stringField(obj, key, path);
|
||||
validateKubernetesNamePrefix(value, `${path}.${key}`);
|
||||
if (value.length > 63) throw new Error(`${path}.${key} must be at most 63 characters`);
|
||||
return value;
|
||||
}
|
||||
|
||||
function validateKubernetesLabelKey(value: string, path: string): void {
|
||||
const parts = value.split("/");
|
||||
const name = parts.length === 2 ? parts[1] : parts[0];
|
||||
|
||||
@@ -0,0 +1,204 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import {
|
||||
existsSync,
|
||||
mkdirSync,
|
||||
mkdtempSync,
|
||||
readFileSync,
|
||||
rmSync,
|
||||
writeFileSync,
|
||||
} from "node:fs";
|
||||
import { join } from "node:path";
|
||||
import { describe, expect, test } from "bun:test";
|
||||
import {
|
||||
agentRunManagedRepositoryDesiredConfig,
|
||||
renderAgentRunManagedRepositoryReconcilerFragment,
|
||||
} from "./agentrun-managed-repository-reconciler";
|
||||
import { resolveAgentRunLaneTarget } from "./agentrun-lanes";
|
||||
import { yamlLaneGitMirrorStatusScript } from "./agentrun/secrets";
|
||||
|
||||
const controllerPath = new URL("../native/agentrun/managed-repository-reconciler.mjs", import.meta.url).pathname;
|
||||
|
||||
describe("AgentRun YAML-owned managed repository reconciler", () => {
|
||||
test("renders one narrow controller from the lane repository list", () => {
|
||||
const spec = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }).spec;
|
||||
const desired = agentRunManagedRepositoryDesiredConfig(spec);
|
||||
expect(desired.repositories.map((item) => item.repository)).toEqual([
|
||||
"pikasTech/agentrun",
|
||||
"pikasTech/unidesk",
|
||||
"pikasTech/agent_skills",
|
||||
]);
|
||||
expect(desired.repositories.every((item) => item.remote.startsWith("ssh://"))).toBe(true);
|
||||
expect(desired.lifecycle).toEqual({
|
||||
undeclaredRepositoryPolicy: "retain",
|
||||
managedRefs: "source-branch-only",
|
||||
});
|
||||
|
||||
const documents = renderAgentRunManagedRepositoryReconcilerFragment("NC01")
|
||||
.split(/^---\s*$/mu)
|
||||
.map((item) => Bun.YAML.parse(item) as any);
|
||||
expect(documents.map((item) => item.kind)).toEqual(["ServiceAccount", "ConfigMap", "Deployment"]);
|
||||
expect(documents[0].automountServiceAccountToken).toBe(false);
|
||||
expect(documents[1].metadata.annotations["unidesk.ai/undeclared-repository-policy"]).toBe("retain");
|
||||
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.containers[0].readinessProbe.exec.command).toContain("--health");
|
||||
|
||||
const controllerSource = readFileSync(controllerPath, "utf8");
|
||||
expect(controllerSource).not.toContain("agent_skills");
|
||||
expect(controllerSource).not.toContain("pikasTech/");
|
||||
expect(controllerSource).not.toContain("github.com");
|
||||
});
|
||||
|
||||
test("requires every repository entry to own a matching remote", () => {
|
||||
const temporary = mkdtempSync("/tmp/unidesk-agentrun-repository-config-");
|
||||
try {
|
||||
const source = readFileSync(new URL("../../config/agentrun.yaml", import.meta.url), "utf8");
|
||||
const invalid = source.replace(
|
||||
"remote: ssh://git@ssh.github.com:443/pikasTech/agent_skills.git",
|
||||
"remote: ssh://git@ssh.github.com:443/pikasTech/unidesk.git",
|
||||
);
|
||||
const configPath = join(temporary, "agentrun.yaml");
|
||||
writeFileSync(configPath, invalid);
|
||||
expect(() => resolveAgentRunLaneTarget(
|
||||
{ node: "NC01", lane: "nc01-v02" },
|
||||
{ ...process.env, AGENTRUN_CONTROL_PLANE_CONFIG: configPath },
|
||||
)).toThrow("same repository entry");
|
||||
} 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 {
|
||||
const work = join(temporary, "work");
|
||||
const remote = join(temporary, "remote.git");
|
||||
const cache = join(temporary, "cache");
|
||||
mkdirSync(work, { recursive: true });
|
||||
mkdirSync(cache, { recursive: true });
|
||||
git(["init", work]);
|
||||
git(["-C", work, "config", "user.name", "UniDesk fixture"]);
|
||||
git(["-C", work, "config", "user.email", "fixture@unidesk.invalid"]);
|
||||
writeFileSync(join(work, "README.md"), "managed repository fixture\n");
|
||||
git(["-C", work, "add", "README.md"]);
|
||||
git(["-C", work, "commit", "-m", "fixture"]);
|
||||
git(["-C", work, "branch", "-M", "main"]);
|
||||
git(["clone", "--bare", work, remote]);
|
||||
|
||||
const retainedPath = join(cache, "undeclared", "retained.git");
|
||||
mkdirSync(retainedPath, { recursive: true });
|
||||
writeFileSync(join(retainedPath, "marker"), "retain\n");
|
||||
|
||||
const remoteUrl = `file://${remote}`;
|
||||
const repository = {
|
||||
key: "fixture",
|
||||
repository: "acme/example",
|
||||
remote: remoteUrl,
|
||||
sourceBranch: "main",
|
||||
desiredFingerprint: sha256(["fixture", "acme/example", remoteUrl, "main"].join("\0")),
|
||||
};
|
||||
const desired = {
|
||||
schemaVersion: 1,
|
||||
kind: "AgentRunManagedRepositoryReconciler",
|
||||
nodeId: "fixture-node",
|
||||
lane: "fixture-lane",
|
||||
cacheMountPath: cache,
|
||||
stateDirectory: ".managed-state",
|
||||
reconcileIntervalMs: 1000,
|
||||
fetchTimeoutMs: 10000,
|
||||
shutdownGraceMs: 1000,
|
||||
maxConcurrentRepositories: 1,
|
||||
retry: { maxAttempts: 1, initialDelayMs: 10, maxDelayMs: 10 },
|
||||
freshness: { maxAgeMs: 60000 },
|
||||
lifecycle: { undeclaredRepositoryPolicy: "retain", managedRefs: "source-branch-only" },
|
||||
repositories: [repository],
|
||||
};
|
||||
const config = { ...desired, desiredHash: sha256(JSON.stringify(desired)) };
|
||||
const configPath = join(temporary, "config.json");
|
||||
writeFileSync(configPath, `${JSON.stringify(config, null, 2)}\n`);
|
||||
|
||||
const once = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
expect(once.exitCode, once.stderr.toString()).toBe(0);
|
||||
const onceStatus = JSON.parse(once.stdout.toString());
|
||||
expect(onceStatus.ready).toBe(true);
|
||||
expect(onceStatus.repositories[0].remoteFingerprint).toBe(`sha256:${sha256(remoteUrl)}`);
|
||||
expect(existsSync(join(retainedPath, "marker"))).toBe(true);
|
||||
|
||||
const mirroredCommit = git([
|
||||
"--git-dir",
|
||||
join(cache, "acme", "example.git"),
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"refs/heads/main^{commit}",
|
||||
]).trim();
|
||||
expect(mirroredCommit).toMatch(/^[0-9a-f]{40}$/u);
|
||||
git([
|
||||
"--git-dir",
|
||||
join(cache, "acme", "example.git"),
|
||||
"update-ref",
|
||||
"refs/heads/retained-gitops",
|
||||
mirroredCommit,
|
||||
]);
|
||||
const secondOnce = Bun.spawnSync(["node", controllerPath, "--once", "--config", configPath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
expect(secondOnce.exitCode, secondOnce.stderr.toString()).toBe(0);
|
||||
expect(git([
|
||||
"--git-dir",
|
||||
join(cache, "acme", "example.git"),
|
||||
"rev-parse",
|
||||
"--verify",
|
||||
"refs/heads/retained-gitops^{commit}",
|
||||
])).toBe(mirroredCommit);
|
||||
expect(git([
|
||||
"--git-dir",
|
||||
join(cache, "acme", "example.git"),
|
||||
"for-each-ref",
|
||||
"--format=%(refname)",
|
||||
"refs/agentrun-managed",
|
||||
])).toBe("");
|
||||
|
||||
const health = Bun.spawnSync(["node", controllerPath, "--health", "--config", configPath], {
|
||||
stdout: "pipe",
|
||||
stderr: "pipe",
|
||||
});
|
||||
expect(health.exitCode, health.stderr.toString()).toBe(0);
|
||||
const healthStatus = JSON.parse(health.stdout.toString());
|
||||
expect(healthStatus.ready).toBe(true);
|
||||
expect(healthStatus.repositories[0]).toMatchObject({
|
||||
refPresent: true,
|
||||
fresh: true,
|
||||
commit: mirroredCommit,
|
||||
remoteFingerprintAligned: true,
|
||||
});
|
||||
} finally {
|
||||
rmSync(temporary, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("keeps legacy main-repository fields and bounds per-repository probes", () => {
|
||||
const spec = resolveAgentRunLaneTarget({ node: "NC01", lane: "nc01-v02" }).spec;
|
||||
const script = yamlLaneGitMirrorStatusScript(spec);
|
||||
expect(script).toContain("'repository': os.environ.get('REPOSITORY')");
|
||||
expect(script).toContain("'sourceCommit': legacy_ref(");
|
||||
expect(script).toContain("'gitopsCommit': legacy_ref(");
|
||||
expect(script.match(/timeout 20 kubectl/gu)?.length).toBe(spec.gitMirror.repositories.length);
|
||||
expect(script.match(/timeout 20 kubectl[^\n]+ &/gu)?.length).toBe(spec.gitMirror.repositories.length);
|
||||
expect(script).toContain("remoteFingerprintAligned");
|
||||
});
|
||||
});
|
||||
|
||||
function git(args: string[]): string {
|
||||
const result = Bun.spawnSync(["git", ...args], { stdout: "pipe", stderr: "pipe" });
|
||||
if (result.exitCode !== 0) throw new Error(`git ${args[0]} failed: ${result.stderr.toString()}`);
|
||||
return result.stdout.toString().trim();
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
import { createHash } from "node:crypto";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { rootPath } from "./config";
|
||||
import {
|
||||
resolveAgentRunLaneTargetsForNode,
|
||||
type AgentRunLaneSpec,
|
||||
} from "./agentrun-lanes";
|
||||
|
||||
const controllerSourcePath = rootPath("scripts", "native", "agentrun", "managed-repository-reconciler.mjs");
|
||||
const proxySourcePath = rootPath("scripts", "native", "agentrun", "git-mirror-proxy-connect.cjs");
|
||||
const controllerMountPath = "/etc/agentrun-managed-repository";
|
||||
const sshMountPath = "/var/run/secrets/agentrun-managed-repository";
|
||||
const knownHostsPath = "/tmp/agentrun-managed-repository/.ssh/known_hosts";
|
||||
|
||||
export interface AgentRunManagedRepositoryDesiredConfig {
|
||||
readonly schemaVersion: 1;
|
||||
readonly kind: "AgentRunManagedRepositoryReconciler";
|
||||
readonly nodeId: string;
|
||||
readonly lane: string;
|
||||
readonly cacheMountPath: string;
|
||||
readonly stateDirectory: string;
|
||||
readonly reconcileIntervalMs: number;
|
||||
readonly fetchTimeoutMs: number;
|
||||
readonly shutdownGraceMs: number;
|
||||
readonly maxConcurrentRepositories: number;
|
||||
readonly retry: {
|
||||
readonly maxAttempts: number;
|
||||
readonly initialDelayMs: number;
|
||||
readonly maxDelayMs: number;
|
||||
};
|
||||
readonly freshness: {
|
||||
readonly maxAgeMs: number;
|
||||
};
|
||||
readonly lifecycle: {
|
||||
readonly undeclaredRepositoryPolicy: "retain";
|
||||
readonly managedRefs: "source-branch-only";
|
||||
};
|
||||
readonly repositories: readonly {
|
||||
readonly key: string;
|
||||
readonly repository: string;
|
||||
readonly remote: string;
|
||||
readonly sourceBranch: string;
|
||||
readonly desiredFingerprint: string;
|
||||
}[];
|
||||
readonly ssh: {
|
||||
readonly identityFile: string;
|
||||
readonly knownHostsFile: string;
|
||||
readonly proxyConnectScript: string;
|
||||
readonly proxyHost: string;
|
||||
readonly proxyPort: number;
|
||||
};
|
||||
readonly desiredHash: string;
|
||||
}
|
||||
|
||||
export function agentRunManagedRepositoryDesiredConfig(spec: AgentRunLaneSpec): AgentRunManagedRepositoryDesiredConfig {
|
||||
const reconciler = spec.gitMirror.repositoryReconciler;
|
||||
const desired = {
|
||||
schemaVersion: 1 as const,
|
||||
kind: "AgentRunManagedRepositoryReconciler" as const,
|
||||
nodeId: spec.nodeId,
|
||||
lane: spec.lane,
|
||||
cacheMountPath: reconciler.cacheMountPath,
|
||||
stateDirectory: reconciler.stateDirectory,
|
||||
reconcileIntervalMs: reconciler.reconcileIntervalMs,
|
||||
fetchTimeoutMs: reconciler.fetchTimeoutMs,
|
||||
shutdownGraceMs: reconciler.shutdownGraceMs,
|
||||
maxConcurrentRepositories: reconciler.maxConcurrentRepositories,
|
||||
retry: reconciler.retry,
|
||||
freshness: reconciler.freshness,
|
||||
lifecycle: reconciler.lifecycle,
|
||||
repositories: spec.gitMirror.repositories.map((repository) => ({
|
||||
key: repository.key,
|
||||
repository: repository.repository,
|
||||
remote: repository.remote,
|
||||
sourceBranch: repository.sourceBranch,
|
||||
desiredFingerprint: sha256([repository.key, repository.repository, repository.remote, repository.sourceBranch].join("\0")),
|
||||
})),
|
||||
ssh: {
|
||||
identityFile: `${sshMountPath}/ssh-privatekey`,
|
||||
knownHostsFile: knownHostsPath,
|
||||
proxyConnectScript: `${controllerMountPath}/proxy-connect.cjs`,
|
||||
proxyHost: spec.gitMirror.githubProxy.host,
|
||||
proxyPort: spec.gitMirror.githubProxy.port,
|
||||
},
|
||||
};
|
||||
return { ...desired, desiredHash: sha256(JSON.stringify(desired)) };
|
||||
}
|
||||
|
||||
export function agentRunManagedRepositoryRenderHash(spec: AgentRunLaneSpec): string {
|
||||
const config = agentRunManagedRepositoryDesiredConfig(spec);
|
||||
return sha256([
|
||||
JSON.stringify(config),
|
||||
readFileSync(controllerSourcePath, "utf8"),
|
||||
readFileSync(proxySourcePath, "utf8"),
|
||||
].join("\0"));
|
||||
}
|
||||
|
||||
export function renderAgentRunManagedRepositoryReconcilerFragment(nodeId: string, env: NodeJS.ProcessEnv = process.env): string {
|
||||
const targets = resolveAgentRunLaneTargetsForNode(nodeId, env)
|
||||
.filter(({ spec }) => spec.gitMirror.repositoryReconciler.enabled);
|
||||
assertUniqueManagedRepositoryOwnership(targets.map(({ spec }) => spec));
|
||||
const objects = targets
|
||||
.flatMap(({ spec }) => managedRepositoryObjects(spec));
|
||||
if (objects.length === 0) return "";
|
||||
return `${objects.map((object) => Bun.YAML.stringify(object).trim()).join("\n---\n")}\n`;
|
||||
}
|
||||
|
||||
function assertUniqueManagedRepositoryOwnership(specs: readonly AgentRunLaneSpec[]): void {
|
||||
const objectNames = new Set<string>();
|
||||
const cacheRepositories = new Map<string, string>();
|
||||
for (const spec of specs) {
|
||||
const reconciler = spec.gitMirror.repositoryReconciler;
|
||||
for (const [kind, name] of [["Deployment", reconciler.deploymentName], ["ConfigMap", reconciler.configMapName], ["ServiceAccount", reconciler.serviceAccountName]]) {
|
||||
const identity = `${kind}:${spec.gitMirror.namespace}/${name}`;
|
||||
if (objectNames.has(identity)) throw new Error(`AgentRun managed repository renderer has duplicate object ownership: ${identity}`);
|
||||
objectNames.add(identity);
|
||||
}
|
||||
const cacheIdentity = spec.gitMirror.cacheHostPath === null
|
||||
? `pvc:${spec.gitMirror.namespace}/${spec.gitMirror.cachePvc}`
|
||||
: `hostPath:${spec.nodeId}/${spec.gitMirror.cacheHostPath}`;
|
||||
for (const repository of spec.gitMirror.repositories) {
|
||||
const identity = `${cacheIdentity}/${repository.repository}`;
|
||||
const owner = cacheRepositories.get(identity);
|
||||
if (owner !== undefined) throw new Error(`AgentRun managed repository ${identity} is owned by both ${owner} and ${spec.lane}`);
|
||||
cacheRepositories.set(identity, spec.lane);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function managedRepositoryObjects(spec: AgentRunLaneSpec): Record<string, unknown>[] {
|
||||
const reconciler = spec.gitMirror.repositoryReconciler;
|
||||
const desired = agentRunManagedRepositoryDesiredConfig(spec);
|
||||
const desiredJson = JSON.stringify(desired, null, 2);
|
||||
const controllerSource = readFileSync(controllerSourcePath, "utf8");
|
||||
const proxySource = readFileSync(proxySourcePath, "utf8");
|
||||
const renderHash = agentRunManagedRepositoryRenderHash(spec);
|
||||
const labels = {
|
||||
"app.kubernetes.io/name": reconciler.deploymentName,
|
||||
"app.kubernetes.io/component": "managed-repository-reconciler",
|
||||
"app.kubernetes.io/part-of": "agentrun",
|
||||
"app.kubernetes.io/managed-by": "unidesk",
|
||||
"agentrun.pikastech.local/lane": spec.lane,
|
||||
"agentrun.pikastech.local/node": spec.nodeId,
|
||||
};
|
||||
const annotations = {
|
||||
"unidesk.ai/managed-repository-desired-sha": desired.desiredHash,
|
||||
"unidesk.ai/managed-repository-render-sha": renderHash,
|
||||
"unidesk.ai/undeclared-repository-policy": reconciler.lifecycle.undeclaredRepositoryPolicy,
|
||||
"unidesk.ai/managed-refs": reconciler.lifecycle.managedRefs,
|
||||
};
|
||||
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",
|
||||
kind: "ServiceAccount",
|
||||
metadata: {
|
||||
name: reconciler.serviceAccountName,
|
||||
namespace: spec.gitMirror.namespace,
|
||||
labels,
|
||||
},
|
||||
automountServiceAccountToken: false,
|
||||
},
|
||||
{
|
||||
apiVersion: "v1",
|
||||
kind: "ConfigMap",
|
||||
metadata: {
|
||||
name: reconciler.configMapName,
|
||||
namespace: spec.gitMirror.namespace,
|
||||
labels,
|
||||
annotations,
|
||||
},
|
||||
data: {
|
||||
"config.json": `${desiredJson}\n`,
|
||||
"controller.mjs": controllerSource,
|
||||
"proxy-connect.cjs": proxySource,
|
||||
},
|
||||
},
|
||||
{
|
||||
apiVersion: "apps/v1",
|
||||
kind: "Deployment",
|
||||
metadata: {
|
||||
name: reconciler.deploymentName,
|
||||
namespace: spec.gitMirror.namespace,
|
||||
labels,
|
||||
annotations,
|
||||
},
|
||||
spec: {
|
||||
replicas: 1,
|
||||
strategy: { type: "Recreate" },
|
||||
selector: {
|
||||
matchLabels: {
|
||||
"app.kubernetes.io/name": reconciler.deploymentName,
|
||||
"app.kubernetes.io/component": "managed-repository-reconciler",
|
||||
},
|
||||
},
|
||||
template: {
|
||||
metadata: {
|
||||
labels,
|
||||
annotations: {
|
||||
"unidesk.ai/managed-repository-desired-sha": desired.desiredHash,
|
||||
"unidesk.ai/managed-repository-render-sha": renderHash,
|
||||
},
|
||||
},
|
||||
spec: {
|
||||
serviceAccountName: reconciler.serviceAccountName,
|
||||
automountServiceAccountToken: false,
|
||||
terminationGracePeriodSeconds: Math.ceil(reconciler.shutdownGraceMs / 1000),
|
||||
...(loopbackProxy ? { hostNetwork: true, dnsPolicy: "ClusterFirstWithHostNet" } : {}),
|
||||
containers: [{
|
||||
name: "managed-repository-reconciler",
|
||||
image: spec.gitMirror.toolsImage,
|
||||
imagePullPolicy: reconciler.imagePullPolicy,
|
||||
command: [
|
||||
"node",
|
||||
`${controllerMountPath}/controller.mjs`,
|
||||
"--config",
|
||||
`${controllerMountPath}/config.json`,
|
||||
],
|
||||
readinessProbe: {
|
||||
exec: {
|
||||
command: [
|
||||
"node",
|
||||
`${controllerMountPath}/controller.mjs`,
|
||||
"--health",
|
||||
"--config",
|
||||
`${controllerMountPath}/config.json`,
|
||||
],
|
||||
},
|
||||
initialDelaySeconds: reconciler.readiness.initialDelaySeconds,
|
||||
periodSeconds: reconciler.readiness.periodSeconds,
|
||||
timeoutSeconds: reconciler.readiness.timeoutSeconds,
|
||||
failureThreshold: reconciler.readiness.failureThreshold,
|
||||
},
|
||||
resources: reconciler.resources,
|
||||
volumeMounts: [
|
||||
{ name: "config", mountPath: controllerMountPath, readOnly: true },
|
||||
{ name: "cache", mountPath: reconciler.cacheMountPath },
|
||||
{ name: "git-ssh", mountPath: sshMountPath, readOnly: true },
|
||||
],
|
||||
}],
|
||||
volumes: [
|
||||
{ name: "config", configMap: { name: reconciler.configMapName, defaultMode: 0o555 } },
|
||||
cacheVolume,
|
||||
{ name: "git-ssh", secret: { secretName: spec.gitMirror.sshSecretName, defaultMode: 0o400 } },
|
||||
],
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
function sha256(value: string): string {
|
||||
return createHash("sha256").update(value).digest("hex");
|
||||
}
|
||||
@@ -15,7 +15,6 @@ import { runRemoteSshCommandCapture } from "../remote";
|
||||
import { startJob } from "../jobs";
|
||||
import {
|
||||
AGENTRUN_CONFIG_PATH,
|
||||
agentRunLaneSummary,
|
||||
agentRunPipelineRunName,
|
||||
agentRunProviderCredentialRefs,
|
||||
resolveAgentRunLaneTarget,
|
||||
@@ -32,11 +31,11 @@ import {
|
||||
type AgentRunArtifactService,
|
||||
} from "../agentrun-manifests";
|
||||
import { sha256Fingerprint } from "../platform-infra-ops-library";
|
||||
import { pacAutomaticDeliveryFix, pacReadOnlyNext, resolveCicdDeliveryAuthority } from "../cicd-delivery-authority";
|
||||
import { pacAutomaticDeliveryFix, resolveCicdDeliveryAuthority } from "../cicd-delivery-authority";
|
||||
|
||||
import type { GitMirrorStatusOptions } from "./options";
|
||||
import { readGitMirrorStatus } from "./rest-bridge";
|
||||
import { compactCapture, shQuote } from "./utils";
|
||||
import { compactCapture, record, shQuote } from "./utils";
|
||||
|
||||
export function cleanupRunnersFinalizeNodeScript(): string {
|
||||
return String.raw`
|
||||
@@ -484,19 +483,67 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS
|
||||
});
|
||||
const observation = await readGitMirrorStatus(config, target);
|
||||
const summary = observation.summary;
|
||||
const outputSummary = options.full || options.raw ? summary : compactManagedRepositoryStatus(summary);
|
||||
const expanded = options.full || options.raw;
|
||||
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 })),
|
||||
};
|
||||
const fullTarget = {
|
||||
...compactTarget,
|
||||
version: spec.version,
|
||||
sourceAuthority: spec.source.sourceAuthority,
|
||||
gitMirror: {
|
||||
readService: spec.gitMirror.readService,
|
||||
readDeployment: spec.gitMirror.readDeployment,
|
||||
writeService: spec.gitMirror.writeService,
|
||||
writeDeployment: spec.gitMirror.writeDeployment,
|
||||
resourceBundleBaseUrl: spec.gitMirror.resourceBundleBaseUrl,
|
||||
cachePvc: spec.gitMirror.cachePvc,
|
||||
cacheHostPath: spec.gitMirror.cacheHostPath,
|
||||
sshSecretName: spec.gitMirror.sshSecretName,
|
||||
githubProxy: spec.gitMirror.githubProxy,
|
||||
repositoryReconciler: spec.gitMirror.repositoryReconciler,
|
||||
repositories: spec.gitMirror.repositories.map((repository) => ({
|
||||
key: repository.key,
|
||||
repository: repository.repository,
|
||||
sourceBranch: repository.sourceBranch,
|
||||
gitopsBranch: repository.gitopsBranch ?? null,
|
||||
remoteConfigured: true,
|
||||
})),
|
||||
},
|
||||
};
|
||||
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}`,
|
||||
history: `bun scripts/cli.ts platform-infra pipelines-as-code history --target ${deliveryAuthority.consumer.node} --consumer ${deliveryAuthority.consumer.consumerId}`,
|
||||
valuesPrinted: false,
|
||||
}
|
||||
: deliveryAuthority.kind === "unknown"
|
||||
? {
|
||||
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
|
||||
...(expanded ? { fixAutomaticDelivery: pacAutomaticDeliveryFix(deliveryAuthority) } : {}),
|
||||
valuesPrinted: false,
|
||||
}
|
||||
: {
|
||||
sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
|
||||
flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null,
|
||||
};
|
||||
return {
|
||||
ok: observation.ok,
|
||||
command: "agentrun git-mirror status",
|
||||
mode: "yaml-declared-node-lane",
|
||||
configPath: target.configPath,
|
||||
deliveryAuthority,
|
||||
target: agentRunLaneSummary(spec),
|
||||
deliveryAuthority: compactDeliveryAuthority(deliveryAuthority),
|
||||
target: expanded ? fullTarget : compactTarget,
|
||||
namespace: spec.gitMirror.namespace,
|
||||
readUrl: spec.gitMirror.readUrl,
|
||||
writeUrl: spec.gitMirror.writeUrl,
|
||||
summary,
|
||||
...(expanded ? { readUrl: spec.gitMirror.readUrl, writeUrl: spec.gitMirror.writeUrl } : {}),
|
||||
summary: outputSummary,
|
||||
...(options.raw ? { raw: observation.raw } : {}),
|
||||
probe: compactCapture(observation.result, { full: options.full || options.raw, stdoutTailChars: 6000, stderrTailChars: 3000 }),
|
||||
probe: compactCapture(observation.result, { full: options.raw, stdoutTailChars: 6000, stderrTailChars: 3000 }),
|
||||
disclosure: {
|
||||
defaultView: "compact-low-noise",
|
||||
full: options.full,
|
||||
@@ -506,17 +553,59 @@ export async function gitMirrorStatus(config: UniDeskConfig, options: GitMirrorS
|
||||
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`,
|
||||
},
|
||||
next: deliveryAuthority.kind === "pac-pr-merge"
|
||||
? pacReadOnlyNext(deliveryAuthority.consumer.node, deliveryAuthority.consumer.consumerId)
|
||||
: deliveryAuthority.kind === "unknown"
|
||||
? {
|
||||
status: `bun scripts/cli.ts agentrun control-plane status --node ${spec.nodeId} --lane ${spec.lane}`,
|
||||
fixAutomaticDelivery: pacAutomaticDeliveryFix(deliveryAuthority),
|
||||
valuesPrinted: false,
|
||||
}
|
||||
: {
|
||||
sync: `bun scripts/cli.ts agentrun git-mirror sync --node ${spec.nodeId} --lane ${spec.lane} --confirm`,
|
||||
flush: summary.pendingFlush === true ? `bun scripts/cli.ts agentrun git-mirror flush --node ${spec.nodeId} --lane ${spec.lane} --confirm` : null,
|
||||
},
|
||||
next: outputNext,
|
||||
};
|
||||
}
|
||||
|
||||
function compactDeliveryAuthority(authority: ReturnType<typeof resolveCicdDeliveryAuthority>): Record<string, unknown> {
|
||||
return authority.kind === "pac-pr-merge"
|
||||
? {
|
||||
kind: authority.kind,
|
||||
migrated: authority.migrated,
|
||||
mutationHintsAllowed: authority.mutationHintsAllowed,
|
||||
consumer: {
|
||||
consumerId: authority.consumer.consumerId,
|
||||
node: authority.consumer.node,
|
||||
lane: authority.consumer.lane,
|
||||
},
|
||||
valuesPrinted: false,
|
||||
}
|
||||
: {
|
||||
kind: authority.kind,
|
||||
migrated: authority.migrated,
|
||||
mutationHintsAllowed: authority.mutationHintsAllowed,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function compactManagedRepositoryStatus(summary: Record<string, unknown>): Record<string, unknown> {
|
||||
const controller = record(summary.controller);
|
||||
const repositories = Array.isArray(summary.repositories)
|
||||
? summary.repositories.map((value) => record(value)).map((repository) => ({
|
||||
key: repository.key ?? null,
|
||||
sourceBranch: repository.sourceBranch ?? null,
|
||||
ref: shortCommit(repository.cacheCommit),
|
||||
refPresent: repository.cacheRefPresent === true,
|
||||
fresh: repository.fresh === true,
|
||||
served: shortCommit(repository.servedCommit),
|
||||
servedRefPresent: repository.servedRefPresent === true,
|
||||
aligned: repository.refAligned === true && repository.remoteFingerprintAligned === true,
|
||||
}))
|
||||
: [];
|
||||
return {
|
||||
ok: summary.ok === true,
|
||||
controller: {
|
||||
deploymentReady: controller.deploymentReady === true,
|
||||
desiredRendered: controller.configRendered === true && controller.workloadRendered === true,
|
||||
statusAligned: controller.statusAligned === true,
|
||||
heartbeatFresh: controller.heartbeatFresh === true,
|
||||
},
|
||||
repositories,
|
||||
firstDrift: summary.firstDrift ?? null,
|
||||
valuesPrinted: false,
|
||||
};
|
||||
}
|
||||
|
||||
function shortCommit(value: unknown): string | null {
|
||||
return typeof value === "string" && /^[0-9a-f]{40}$/u.test(value) ? value.slice(0, 12) : null;
|
||||
}
|
||||
|
||||
+196
-32
@@ -34,6 +34,10 @@ import {
|
||||
renderAgentRunGitopsFiles,
|
||||
type AgentRunArtifactService,
|
||||
} from "../agentrun-manifests";
|
||||
import {
|
||||
agentRunManagedRepositoryDesiredConfig,
|
||||
agentRunManagedRepositoryRenderHash,
|
||||
} from "../agentrun-managed-repository-reconciler";
|
||||
import { sha256Fingerprint } from "../platform-infra-ops-library";
|
||||
|
||||
import { capture, captureJsonPayload, compactCapture, progressEvent, record, shQuote, sleep, stringOrNull } from "./utils";
|
||||
@@ -629,6 +633,28 @@ export function yamlLaneGitMirrorFlushShell(spec: AgentRunLaneSpec): string {
|
||||
}
|
||||
|
||||
export function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string {
|
||||
const reconciler = spec.gitMirror.repositoryReconciler;
|
||||
const desired = agentRunManagedRepositoryDesiredConfig(spec);
|
||||
const expectedRepositories = desired.repositories.map((repository) => ({
|
||||
key: repository.key,
|
||||
repository: repository.repository,
|
||||
sourceBranch: repository.sourceBranch,
|
||||
desiredFingerprint: repository.desiredFingerprint,
|
||||
remoteFingerprint: sha256Fingerprint(repository.remote),
|
||||
}));
|
||||
const repositoryReadProbes = expectedRepositories.flatMap((repository) => {
|
||||
const url = `${spec.gitMirror.resourceBundleBaseUrl.replace(/\/+$/u, "")}/${repository.repository}.git`;
|
||||
const ref = `refs/heads/${repository.sourceBranch}`;
|
||||
const inner = [
|
||||
"url=\"$1\"",
|
||||
"ref=\"$2\"",
|
||||
"commit=$(timeout 15 git ls-remote \"$url\" \"$ref\" 2>/dev/null | awk 'NR == 1 { print $1 }')",
|
||||
"printf '%s\\t%s\\n' \"$3\" \"$commit\"",
|
||||
].join("; ");
|
||||
return [
|
||||
`( timeout 20 kubectl -n "$namespace" exec deploy/"$controller_deployment" -- sh -lc ${shQuote(inner)} sh ${shQuote(url)} ${shQuote(ref)} ${shQuote(repository.key)} 2>/dev/null || printf '%s\\t\\n' ${shQuote(repository.key)} ) >${shQuote(`/tmp/agentrun-managed-repository-read-refs/${repository.key}.tsv`)} &`,
|
||||
];
|
||||
});
|
||||
return [
|
||||
"set +e",
|
||||
`namespace=${shQuote(spec.gitMirror.namespace)}`,
|
||||
@@ -637,52 +663,176 @@ export function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string {
|
||||
`write_service=${shQuote(spec.gitMirror.writeService)}`,
|
||||
`cache_pvc=${shQuote(spec.gitMirror.cachePvc)}`,
|
||||
`cache_host_path=${spec.gitMirror.cacheHostPath === null ? "''" : shQuote(spec.gitMirror.cacheHostPath)}`,
|
||||
`controller_deployment=${shQuote(reconciler.deploymentName)}`,
|
||||
`controller_configmap=${shQuote(reconciler.configMapName)}`,
|
||||
`expected_desired_hash=${shQuote(desired.desiredHash)}`,
|
||||
`expected_render_hash=${shQuote(agentRunManagedRepositoryRenderHash(spec))}`,
|
||||
`repositories_json=${shQuote(JSON.stringify(expectedRepositories))}`,
|
||||
`repository=${shQuote(spec.source.repository)}`,
|
||||
`source_branch=${shQuote(spec.source.branch)}`,
|
||||
`gitops_branch=${shQuote(spec.gitops.branch)}`,
|
||||
`repositories_json=${shQuote(JSON.stringify(spec.gitMirror.repositories))}`,
|
||||
"kubectl -n \"$namespace\" get svc \"$read_service\" -o json >/tmp/agentrun-gitmirror-read.json 2>/dev/null",
|
||||
"read_exit=$?",
|
||||
"kubectl -n \"$namespace\" get svc \"$write_service\" -o json >/tmp/agentrun-gitmirror-write.json 2>/dev/null",
|
||||
"write_exit=$?",
|
||||
"kubectl -n \"$namespace\" get deploy \"$read_deployment\" -o json >/tmp/agentrun-gitmirror-read-deploy.json 2>/dev/null",
|
||||
"read_deploy_exit=$?",
|
||||
"kubectl -n \"$namespace\" get svc \"$read_service\" -o json >/tmp/agentrun-gitmirror-read-service.json 2>/dev/null",
|
||||
"read_service_exit=$?",
|
||||
"kubectl -n \"$namespace\" get endpoints \"$read_service\" -o json >/tmp/agentrun-gitmirror-read-endpoints.json 2>/dev/null",
|
||||
"read_endpoints_exit=$?",
|
||||
"kubectl -n \"$namespace\" get svc \"$write_service\" -o json >/tmp/agentrun-gitmirror-write-service.json 2>/dev/null",
|
||||
"write_service_exit=$?",
|
||||
"kubectl -n \"$namespace\" get endpoints \"$write_service\" -o json >/tmp/agentrun-gitmirror-write-endpoints.json 2>/dev/null",
|
||||
"write_endpoints_exit=$?",
|
||||
"kubectl -n \"$namespace\" get pvc \"$cache_pvc\" -o json >/tmp/agentrun-gitmirror-cache.json 2>/dev/null",
|
||||
"cache_exit=$?",
|
||||
"kubectl -n \"$namespace\" get deploy \"$controller_deployment\" -o json >/tmp/agentrun-managed-repository-controller.json 2>/dev/null",
|
||||
"controller_deploy_exit=$?",
|
||||
"kubectl -n \"$namespace\" get configmap \"$controller_configmap\" -o json >/tmp/agentrun-managed-repository-configmap.json 2>/dev/null",
|
||||
"controller_configmap_exit=$?",
|
||||
"cache_mode=pvc",
|
||||
"if [ -n \"$cache_host_path\" ]; then cache_mode=hostPath; fi",
|
||||
"kubectl -n \"$namespace\" get deploy,svc,pvc -o name > /tmp/agentrun-gitmirror-names.txt 2>/dev/null",
|
||||
"repo_path=\"/cache/${repository}.git\"",
|
||||
"rm -f /tmp/agentrun-gitmirror-refs.txt",
|
||||
"if [ \"$read_exit\" -eq 0 ]; then",
|
||||
" kubectl -n \"$namespace\" exec deploy/\"$read_deployment\" -- sh -lc 'repo_path=\"$1\"; source_branch=\"$2\"; gitops_branch=\"$3\"; source_commit=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$source_branch^{commit}\" 2>/dev/null || true); gitops_commit=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$gitops_branch^{commit}\" 2>/dev/null || true); printf \"sourceCommit=%s\\n\" \"$source_commit\"; printf \"gitopsCommit=%s\\n\" \"$gitops_commit\"' sh \"$repo_path\" \"$source_branch\" \"$gitops_branch\" > /tmp/agentrun-gitmirror-refs.txt 2>/dev/null",
|
||||
"fi",
|
||||
"NAMESPACE=\"$namespace\" READ_EXIT=\"$read_exit\" WRITE_EXIT=\"$write_exit\" CACHE_EXIT=\"$cache_exit\" CACHE_MODE=\"$cache_mode\" CACHE_HOST_PATH=\"$cache_host_path\" REPOSITORY=\"$repository\" SOURCE_BRANCH=\"$source_branch\" GITOPS_BRANCH=\"$gitops_branch\" REPOSITORIES_JSON=\"$repositories_json\" python3 - <<'PY'",
|
||||
"import json, os, re",
|
||||
"def read_text(path):",
|
||||
"timeout 10 kubectl -n \"$namespace\" exec deploy/\"$controller_deployment\" -- node /etc/agentrun-managed-repository/controller.mjs --status --config /etc/agentrun-managed-repository/config.json >/tmp/agentrun-managed-repository-status.json 2>/dev/null",
|
||||
"controller_status_exit=$?",
|
||||
"rm -f /tmp/agentrun-gitmirror-legacy-refs.txt",
|
||||
"timeout 10 kubectl -n \"$namespace\" exec deploy/\"$read_deployment\" -- sh -lc 'repo_path=\"$1\"; source_branch=\"$2\"; gitops_branch=\"$3\"; source_commit=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$source_branch^{commit}\" 2>/dev/null || true); gitops_commit=$(git --git-dir=\"$repo_path\" rev-parse --verify \"refs/heads/$gitops_branch^{commit}\" 2>/dev/null || true); printf \"sourceCommit=%s\\n\" \"$source_commit\"; printf \"gitopsCommit=%s\\n\" \"$gitops_commit\"' sh \"/cache/${repository}.git\" \"$source_branch\" \"$gitops_branch\" >/tmp/agentrun-gitmirror-legacy-refs.txt 2>/dev/null",
|
||||
"rm -rf /tmp/agentrun-managed-repository-read-refs",
|
||||
"mkdir -p /tmp/agentrun-managed-repository-read-refs",
|
||||
...repositoryReadProbes,
|
||||
"wait",
|
||||
"cat /tmp/agentrun-managed-repository-read-refs/*.tsv > /tmp/agentrun-managed-repository-read-refs.tsv 2>/dev/null || true",
|
||||
"NAMESPACE=\"$namespace\" READ_DEPLOY_EXIT=\"$read_deploy_exit\" READ_SERVICE_EXIT=\"$read_service_exit\" READ_ENDPOINTS_EXIT=\"$read_endpoints_exit\" WRITE_SERVICE_EXIT=\"$write_service_exit\" WRITE_ENDPOINTS_EXIT=\"$write_endpoints_exit\" CACHE_EXIT=\"$cache_exit\" CONTROLLER_DEPLOY_EXIT=\"$controller_deploy_exit\" CONTROLLER_CONFIGMAP_EXIT=\"$controller_configmap_exit\" CONTROLLER_STATUS_EXIT=\"$controller_status_exit\" CACHE_MODE=\"$cache_mode\" CACHE_HOST_PATH=\"$cache_host_path\" EXPECTED_DESIRED_HASH=\"$expected_desired_hash\" EXPECTED_RENDER_HASH=\"$expected_render_hash\" REPOSITORIES_JSON=\"$repositories_json\" REPOSITORY=\"$repository\" SOURCE_BRANCH=\"$source_branch\" GITOPS_BRANCH=\"$gitops_branch\" python3 - <<'PY'",
|
||||
"import json, os",
|
||||
"def read_json(path):",
|
||||
" try:",
|
||||
" with open(path, 'r', encoding='utf-8') as fh:",
|
||||
" return fh.read()",
|
||||
" value = json.load(fh)",
|
||||
" return value if isinstance(value, dict) else {}",
|
||||
" except Exception:",
|
||||
" return ''",
|
||||
"def ref_value(text, key):",
|
||||
" return {}",
|
||||
"def exit_ok(name):",
|
||||
" return os.environ.get(name) == '0'",
|
||||
"def deployment_ready(value):",
|
||||
" status = value.get('status') or {}",
|
||||
" spec = value.get('spec') or {}",
|
||||
" replicas = int(spec.get('replicas') or 0)",
|
||||
" available = int(status.get('availableReplicas') or 0)",
|
||||
" return replicas > 0 and available >= replicas and int(status.get('observedGeneration') or 0) >= int((value.get('metadata') or {}).get('generation') or 0)",
|
||||
"def endpoint_ready(value):",
|
||||
" for subset in value.get('subsets') or []:",
|
||||
" if len(subset.get('addresses') or []) > 0:",
|
||||
" return True",
|
||||
" return False",
|
||||
"def annotation(value, key):",
|
||||
" return ((value.get('metadata') or {}).get('annotations') or {}).get(key)",
|
||||
"def pod_annotation(value, key):",
|
||||
" return (((((value.get('spec') or {}).get('template') or {}).get('metadata') or {}).get('annotations') or {}).get(key))",
|
||||
"def read_refs(path):",
|
||||
" result = {}",
|
||||
" try:",
|
||||
" with open(path, 'r', encoding='utf-8') as fh:",
|
||||
" for line in fh:",
|
||||
" parts = line.rstrip('\\n').split('\\t', 1)",
|
||||
" if len(parts) == 2:",
|
||||
" result[parts[0]] = parts[1] or None",
|
||||
" except Exception:",
|
||||
" pass",
|
||||
" return result",
|
||||
"def legacy_ref(path, key):",
|
||||
" prefix = key + '='",
|
||||
" for line in re.split(r'\\r?\\n', text):",
|
||||
" if line.startswith(prefix):",
|
||||
" value = line[len(prefix):].strip()",
|
||||
" return value or None",
|
||||
" try:",
|
||||
" with open(path, 'r', encoding='utf-8') as fh:",
|
||||
" for line in fh:",
|
||||
" if line.startswith(prefix):",
|
||||
" return line[len(prefix):].strip() or None",
|
||||
" except Exception:",
|
||||
" pass",
|
||||
" return None",
|
||||
"try:",
|
||||
" repositories = json.loads(os.environ.get('REPOSITORIES_JSON') or '[]')",
|
||||
"except Exception:",
|
||||
" repositories = []",
|
||||
"names = read_text('/tmp/agentrun-gitmirror-names.txt')",
|
||||
"refs = read_text('/tmp/agentrun-gitmirror-refs.txt')",
|
||||
"read_ready = os.environ.get('READ_EXIT') == '0'",
|
||||
"write_ready = os.environ.get('WRITE_EXIT') == '0'",
|
||||
"cache_pvc_exists = os.environ.get('CACHE_EXIT') == '0'",
|
||||
"read_deploy = read_json('/tmp/agentrun-gitmirror-read-deploy.json')",
|
||||
"read_endpoints = read_json('/tmp/agentrun-gitmirror-read-endpoints.json')",
|
||||
"write_endpoints = read_json('/tmp/agentrun-gitmirror-write-endpoints.json')",
|
||||
"cache = read_json('/tmp/agentrun-gitmirror-cache.json')",
|
||||
"controller_deploy = read_json('/tmp/agentrun-managed-repository-controller.json')",
|
||||
"controller_configmap = read_json('/tmp/agentrun-managed-repository-configmap.json')",
|
||||
"controller_status = read_json('/tmp/agentrun-managed-repository-status.json')",
|
||||
"served_refs = read_refs('/tmp/agentrun-managed-repository-read-refs.tsv')",
|
||||
"expected_desired_hash = os.environ.get('EXPECTED_DESIRED_HASH')",
|
||||
"expected_render_hash = os.environ.get('EXPECTED_RENDER_HASH')",
|
||||
"read_ready = exit_ok('READ_DEPLOY_EXIT') and deployment_ready(read_deploy) and exit_ok('READ_SERVICE_EXIT') and exit_ok('READ_ENDPOINTS_EXIT') and endpoint_ready(read_endpoints)",
|
||||
"write_ready = exit_ok('WRITE_SERVICE_EXIT') and exit_ok('WRITE_ENDPOINTS_EXIT') and endpoint_ready(write_endpoints)",
|
||||
"cache_pvc_exists = exit_ok('CACHE_EXIT')",
|
||||
"cache_mode = os.environ.get('CACHE_MODE') or 'pvc'",
|
||||
"cache_ready = True if cache_mode == 'hostPath' else cache_pvc_exists",
|
||||
"cache_ready = True if cache_mode == 'hostPath' else cache_pvc_exists and (cache.get('status') or {}).get('phase') == 'Bound'",
|
||||
"controller_deployment_ready = exit_ok('CONTROLLER_DEPLOY_EXIT') and deployment_ready(controller_deploy)",
|
||||
"controller_config_rendered = exit_ok('CONTROLLER_CONFIGMAP_EXIT') and annotation(controller_configmap, 'unidesk.ai/managed-repository-desired-sha') == expected_desired_hash and annotation(controller_configmap, 'unidesk.ai/managed-repository-render-sha') == expected_render_hash",
|
||||
"controller_workload_rendered = annotation(controller_deploy, 'unidesk.ai/managed-repository-desired-sha') == expected_desired_hash and annotation(controller_deploy, 'unidesk.ai/managed-repository-render-sha') == expected_render_hash and pod_annotation(controller_deploy, 'unidesk.ai/managed-repository-desired-sha') == expected_desired_hash and pod_annotation(controller_deploy, 'unidesk.ai/managed-repository-render-sha') == expected_render_hash",
|
||||
"controller_status_aligned = exit_ok('CONTROLLER_STATUS_EXIT') and controller_status.get('desiredHash') == expected_desired_hash",
|
||||
"status_repositories = {item.get('desiredFingerprint'): item for item in controller_status.get('repositories') or [] if isinstance(item, dict)}",
|
||||
"repository_status = []",
|
||||
"for desired in repositories:",
|
||||
" observed = status_repositories.get(desired.get('desiredFingerprint')) or {}",
|
||||
" served_commit = served_refs.get(desired.get('key'))",
|
||||
" cache_commit = observed.get('commit')",
|
||||
" remote_fingerprint_aligned = observed.get('remoteFingerprint') == desired.get('remoteFingerprint')",
|
||||
" cache_ref_present = observed.get('refPresent') is True and isinstance(cache_commit, str) and len(cache_commit) == 40",
|
||||
" served_ref_present = isinstance(served_commit, str) and len(served_commit) == 40",
|
||||
" repository_status.append({",
|
||||
" 'key': desired.get('key'),",
|
||||
" 'repository': desired.get('repository'),",
|
||||
" 'sourceBranch': desired.get('sourceBranch'),",
|
||||
" 'desiredFingerprint': desired.get('desiredFingerprint'),",
|
||||
" 'remoteFingerprint': desired.get('remoteFingerprint'),",
|
||||
" 'observedRemoteFingerprint': observed.get('remoteFingerprint'),",
|
||||
" 'remoteFingerprintAligned': remote_fingerprint_aligned,",
|
||||
" 'cacheRefPresent': cache_ref_present,",
|
||||
" 'cacheCommit': cache_commit if cache_ref_present else None,",
|
||||
" 'servedRefPresent': served_ref_present,",
|
||||
" 'servedCommit': served_commit if served_ref_present else None,",
|
||||
" 'refAligned': cache_ref_present and served_ref_present and cache_commit == served_commit,",
|
||||
" 'fresh': observed.get('fresh') is True,",
|
||||
" 'lastSuccessAt': observed.get('lastSuccessAt'),",
|
||||
" 'lastSuccessAgeMs': observed.get('lastSuccessAgeMs'),",
|
||||
" 'lastAttemptOk': observed.get('lastAttemptOk') is True,",
|
||||
" 'errorCode': observed.get('errorCode'),",
|
||||
" 'errorFingerprint': observed.get('errorFingerprint'),",
|
||||
" })",
|
||||
"first_drift = None",
|
||||
"if not controller_config_rendered:",
|
||||
" first_drift = 'rendered-desired-not-applied'",
|
||||
"elif not controller_workload_rendered:",
|
||||
" first_drift = 'controller-workload-render-mismatch'",
|
||||
"elif not controller_deployment_ready:",
|
||||
" first_drift = 'controller-not-ready'",
|
||||
"elif not cache_ready:",
|
||||
" first_drift = 'cache-not-ready'",
|
||||
"elif not controller_status_aligned:",
|
||||
" first_drift = 'controller-status-not-aligned'",
|
||||
"elif not read_ready:",
|
||||
" first_drift = 'read-service-not-ready'",
|
||||
"elif not write_ready:",
|
||||
" first_drift = 'write-service-not-ready'",
|
||||
"else:",
|
||||
" for item in repository_status:",
|
||||
" if not item.get('cacheRefPresent'):",
|
||||
" first_drift = 'cache-ref-missing:' + str(item.get('key'))",
|
||||
" break",
|
||||
" if not item.get('remoteFingerprintAligned'):",
|
||||
" first_drift = 'remote-fingerprint-mismatch:' + str(item.get('key'))",
|
||||
" break",
|
||||
" if not item.get('fresh'):",
|
||||
" first_drift = 'repository-stale:' + str(item.get('key'))",
|
||||
" break",
|
||||
" if not item.get('servedRefPresent'):",
|
||||
" first_drift = 'served-ref-missing:' + str(item.get('key'))",
|
||||
" break",
|
||||
" if not item.get('refAligned'):",
|
||||
" first_drift = 'served-ref-mismatch:' + str(item.get('key'))",
|
||||
" break",
|
||||
"repositories_ready = len(repository_status) == len(repositories) and all(item.get('cacheRefPresent') and item.get('fresh') and item.get('refAligned') and item.get('remoteFingerprintAligned') for item in repository_status)",
|
||||
"ok = controller_config_rendered and controller_workload_rendered and controller_deployment_ready and controller_status_aligned and cache_ready and read_ready and write_ready and repositories_ready",
|
||||
"print(json.dumps({",
|
||||
" 'ok': read_ready and write_ready and cache_ready,",
|
||||
" 'ok': ok,",
|
||||
" 'namespace': os.environ.get('NAMESPACE'),",
|
||||
" 'readReady': read_ready,",
|
||||
" 'writeReady': write_ready,",
|
||||
@@ -690,13 +840,27 @@ export function yamlLaneGitMirrorStatusScript(spec: AgentRunLaneSpec): string {
|
||||
" 'cacheReady': cache_ready,",
|
||||
" 'cachePvcExists': cache_pvc_exists,",
|
||||
" 'cacheHostPath': os.environ.get('CACHE_HOST_PATH') or None,",
|
||||
" 'resources': [line for line in re.split(r'\\r?\\n', names) if line][:40],",
|
||||
" 'repository': os.environ.get('REPOSITORY'),",
|
||||
" 'sourceBranch': os.environ.get('SOURCE_BRANCH'),",
|
||||
" 'gitopsBranch': os.environ.get('GITOPS_BRANCH'),",
|
||||
" 'sourceCommit': ref_value(refs, 'sourceCommit'),",
|
||||
" 'gitopsCommit': ref_value(refs, 'gitopsCommit'),",
|
||||
" 'repositories': repositories,",
|
||||
" 'sourceCommit': legacy_ref('/tmp/agentrun-gitmirror-legacy-refs.txt', 'sourceCommit'),",
|
||||
" 'gitopsCommit': legacy_ref('/tmp/agentrun-gitmirror-legacy-refs.txt', 'gitopsCommit'),",
|
||||
" 'controller': {",
|
||||
" 'deploymentReady': controller_deployment_ready,",
|
||||
" 'configRendered': controller_config_rendered,",
|
||||
" 'workloadRendered': controller_workload_rendered,",
|
||||
" 'statusAligned': controller_status_aligned,",
|
||||
" 'desiredHash': expected_desired_hash,",
|
||||
" 'renderHash': expected_render_hash,",
|
||||
" 'observedDesiredHash': controller_status.get('desiredHash'),",
|
||||
" 'heartbeatAt': controller_status.get('heartbeatAt'),",
|
||||
" 'heartbeatAgeMs': controller_status.get('heartbeatAgeMs'),",
|
||||
" 'heartbeatFresh': controller_status.get('heartbeatFresh') is True,",
|
||||
" },",
|
||||
" 'repositoriesReady': repositories_ready,",
|
||||
" 'repositories': repository_status,",
|
||||
" 'firstDrift': first_drift,",
|
||||
" 'pendingFlush': None,",
|
||||
" 'valuesPrinted': False",
|
||||
"}, ensure_ascii=False))",
|
||||
"PY",
|
||||
|
||||
Reference in New Issue
Block a user