308 lines
12 KiB
TypeScript
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");
|
|
}
|