import { createHash } from "node:crypto"; import { readFileSync } from "node:fs"; import { rootPath } from "./config"; import { listAgentRunLaneTargetsForNode, type AgentRunLaneSpec, } from "./agentrun-lanes"; const controllerSourcePath = rootPath("scripts", "native", "agentrun", "managed-repository-reconciler.mjs"); const askPassSourcePath = rootPath("scripts", "native", "agentrun", "git-askpass.mjs"); const controllerMountPath = "/etc/agentrun-managed-repository"; const authMountPath = "/var/run/secrets/agentrun-managed-repository-auth"; 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 remoteAuth: { readonly configRef: string; readonly capabilitySha256: string; readonly authMode: "github-https-token"; readonly host: "github.com"; readonly tokenFile: string; readonly askPassFile: string; readonly proxy: { readonly host: string; readonly port: 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")), })), remoteAuth: { configRef: reconciler.remoteAuth.configRef, capabilitySha256: reconciler.remoteAuth.capabilitySha256, authMode: reconciler.remoteAuth.authMode, host: reconciler.remoteAuth.host, tokenFile: `${authMountPath}/token`, askPassFile: `${authMountPath}/git-askpass.mjs`, proxy: spec.gitMirror.githubProxy, }, }; return { ...desired, desiredHash: sha256(JSON.stringify(desired)) }; } export function agentRunManagedRepositoryRenderHash(spec: AgentRunLaneSpec): string { const config = agentRunManagedRepositoryDesiredConfig(spec); return sha256([ JSON.stringify(config), JSON.stringify(managedRepositoryManifestIdentity(spec)), readFileSync(controllerSourcePath, "utf8"), readFileSync(askPassSourcePath, "utf8"), ].join("\0")); } export function renderAgentRunManagedRepositoryReconcilerFragment(nodeId: string, env: NodeJS.ProcessEnv = process.env): string { const targets = listAgentRunLaneTargetsForNode(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(); const cacheRepositories = new Map(); 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[] { const reconciler = spec.gitMirror.repositoryReconciler; const desired = agentRunManagedRepositoryDesiredConfig(spec); const desiredJson = JSON.stringify(desired, null, 2); const controllerSource = readFileSync(controllerSourcePath, "utf8"); const askPassSource = readFileSync(askPassSourcePath, "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/git-fetch-credential-sha": reconciler.remoteAuth.capabilitySha256, "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" } }; 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, "git-askpass.mjs": askPassSource, }, }, { 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), hostNetwork: reconciler.hostNetwork, dnsPolicy: reconciler.dnsPolicy, 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-auth", mountPath: authMountPath, readOnly: true }, ], }], volumes: [ { name: "config", configMap: { name: reconciler.configMapName, defaultMode: 0o555 } }, cacheVolume, { name: "git-auth", projected: { sources: [ { configMap: { name: reconciler.configMapName, items: [{ key: "git-askpass.mjs", path: "git-askpass.mjs", mode: 0o555 }], }, }, { secret: { name: reconciler.remoteAuth.secretRef.name, items: [{ key: reconciler.remoteAuth.secretRef.key, path: "token", mode: 0o400 }], }, }, ], }, }, ], }, }, }, }, ]; } function managedRepositoryManifestIdentity(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, remoteAuth: { configRef: reconciler.remoteAuth.configRef, capabilitySha256: reconciler.remoteAuth.capabilitySha256, secretRef: reconciler.remoteAuth.secretRef, }, readiness: reconciler.readiness, resources: reconciler.resources, }; } function sha256(value: string): string { return createHash("sha256").update(value).digest("hex"); }