Files
pikasTech-unidesk/scripts/src/agentrun-managed-repository-reconciler.ts
T

308 lines
12 KiB
TypeScript

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<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 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<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,
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");
}