824 lines
36 KiB
TypeScript
824 lines
36 KiB
TypeScript
import { rootPath } from "./config";
|
|
import {
|
|
asRecord,
|
|
arrayField,
|
|
booleanField,
|
|
integerField,
|
|
optionalIntegerField,
|
|
optionalStringField,
|
|
readYamlRecord,
|
|
recordField,
|
|
stringArrayField,
|
|
stringField,
|
|
} from "./platform-infra-ops-library";
|
|
|
|
export const AGENTRUN_CONFIG_PATH = "config/agentrun.yaml";
|
|
|
|
export interface AgentRunGitMirrorRepositorySpec {
|
|
readonly key: string;
|
|
readonly repository: string;
|
|
readonly sourceBranch: string;
|
|
readonly gitopsBranch?: string;
|
|
}
|
|
|
|
export interface AgentRunSecretRef {
|
|
readonly namespace: string;
|
|
readonly name: string;
|
|
readonly key: string;
|
|
}
|
|
|
|
export interface AgentRunLaneSecretSpec {
|
|
readonly id: string;
|
|
readonly sourceRef: string;
|
|
readonly sourceMode: "env" | "file";
|
|
readonly sourceKey: string | null;
|
|
readonly targetRef: AgentRunSecretRef;
|
|
readonly providerCredentialProfile: string | null;
|
|
}
|
|
|
|
export interface AgentRunProviderCredentialRef {
|
|
readonly profile: string;
|
|
readonly secretRef: {
|
|
readonly namespace: string;
|
|
readonly name: string;
|
|
readonly keys: readonly string[];
|
|
};
|
|
}
|
|
|
|
export interface AgentRunLaneSpec {
|
|
readonly lane: string;
|
|
readonly nodeId: string;
|
|
readonly nodeRoute: string;
|
|
readonly nodeKubeRoute: string;
|
|
readonly version: string;
|
|
readonly source: {
|
|
readonly repository: string;
|
|
readonly branch: string;
|
|
readonly bootstrapFromBranch: string | null;
|
|
readonly bootstrapTimeoutSeconds: number;
|
|
readonly bootstrapPollSeconds: number;
|
|
readonly remote: string;
|
|
readonly workspace: string;
|
|
};
|
|
readonly runtime: {
|
|
readonly namespace: string;
|
|
readonly managerDeployment: string;
|
|
readonly managerService: string;
|
|
readonly managerPort: number;
|
|
readonly internalBaseUrl: string;
|
|
};
|
|
readonly ci: {
|
|
readonly namespace: string;
|
|
readonly pipeline: string;
|
|
readonly pipelineRunPrefix: string;
|
|
readonly serviceAccountName: string;
|
|
readonly registryPrefix: string;
|
|
readonly toolsImage: string;
|
|
};
|
|
readonly gitops: {
|
|
readonly branch: string;
|
|
readonly path: string;
|
|
readonly argoNamespace: string;
|
|
readonly argoApplication: string;
|
|
readonly repoURL: string;
|
|
};
|
|
readonly deployment: {
|
|
readonly format: "unidesk-yaml-only";
|
|
readonly gitopsRoot: string;
|
|
readonly runtimeRenderDir: string;
|
|
readonly artifactCatalogPath: string;
|
|
readonly argocd: {
|
|
readonly project: string;
|
|
readonly applicationFile: string;
|
|
};
|
|
readonly manager: {
|
|
readonly serviceAccount: string;
|
|
readonly apiKeySecretRef: { readonly name: string; readonly key: string };
|
|
readonly env: Readonly<Record<string, string>>;
|
|
readonly unideskSshEndpointEnv: { readonly name: string; readonly value: string } | null;
|
|
readonly bootRepoUrl: string;
|
|
readonly imageBuild: AgentRunImageBuildSpec;
|
|
readonly resources: AgentRunContainerResources;
|
|
};
|
|
readonly runner: {
|
|
readonly serviceAccount: string;
|
|
readonly jobNamePrefix: string;
|
|
readonly idleTimeoutMs: number;
|
|
readonly missingTerminalAfterToolTimeoutMs: number;
|
|
readonly apiKeySecretRef: { readonly name: string; readonly key: string };
|
|
readonly egressProxyUrl: string | null;
|
|
readonly noProxyExtra: readonly string[];
|
|
readonly retention: AgentRunRunnerRetentionSpec;
|
|
};
|
|
readonly localPostgres: {
|
|
readonly enabled: boolean;
|
|
readonly serviceName: string | null;
|
|
readonly image: string | null;
|
|
readonly storage: string | null;
|
|
readonly port: number | null;
|
|
};
|
|
};
|
|
readonly gitMirror: {
|
|
readonly namespace: string;
|
|
readonly readService: string;
|
|
readonly readDeployment: string;
|
|
readonly writeService: string;
|
|
readonly writeDeployment: string;
|
|
readonly readUrl: string;
|
|
readonly writeUrl: string;
|
|
readonly cachePvc: string;
|
|
readonly cacheHostPath: string | null;
|
|
readonly sshSecretName: string;
|
|
readonly githubProxy: {
|
|
readonly host: string;
|
|
readonly port: number;
|
|
};
|
|
readonly toolsImage: string;
|
|
readonly syncJobPrefix: string;
|
|
readonly flushJobPrefix: string;
|
|
readonly repositories: readonly AgentRunGitMirrorRepositorySpec[];
|
|
};
|
|
readonly database: {
|
|
readonly mode: "local-postgres" | "external-postgres";
|
|
readonly provider: string | null;
|
|
readonly configRef: string | null;
|
|
readonly database: string | null;
|
|
readonly user: string | null;
|
|
readonly sslmode: "require" | null;
|
|
readonly secretSourceRef: string | null;
|
|
readonly secretRef: { readonly name: string; readonly key: string };
|
|
readonly localPostgresExpectedAbsent: boolean;
|
|
};
|
|
readonly secrets: readonly AgentRunLaneSecretSpec[];
|
|
}
|
|
|
|
export interface AgentRunContainerResources {
|
|
readonly requests: {
|
|
readonly cpu: string;
|
|
readonly memory: string;
|
|
};
|
|
readonly limits: {
|
|
readonly cpu: string;
|
|
readonly memory: string;
|
|
};
|
|
}
|
|
|
|
export interface AgentRunImageBuildSpec {
|
|
readonly context: string;
|
|
readonly containerfile: string;
|
|
readonly repository: string;
|
|
readonly network: string;
|
|
readonly buildArgs: Readonly<Record<string, string>>;
|
|
readonly httpProxy: string | null;
|
|
readonly httpsProxy: string | null;
|
|
readonly noProxy: readonly string[];
|
|
readonly envIdentityFiles: readonly string[];
|
|
readonly timeoutSeconds: number;
|
|
readonly pollSeconds: number;
|
|
}
|
|
|
|
export interface AgentRunRunnerRetentionSpec {
|
|
readonly maxRunners: number;
|
|
readonly cleanupOrder: "oldest-inactive-last-active-first";
|
|
readonly activeHeartbeatMaxAgeMs: number;
|
|
readonly selectors: {
|
|
readonly matchLabels: Readonly<Record<string, string>>;
|
|
readonly jobNamePrefixes: readonly string[];
|
|
};
|
|
readonly ageBasedCleanup: {
|
|
readonly enabled: boolean;
|
|
readonly maxAgeHours: number | null;
|
|
};
|
|
}
|
|
|
|
export interface AgentRunLaneTarget {
|
|
readonly configPath: string;
|
|
readonly spec: AgentRunLaneSpec;
|
|
}
|
|
|
|
interface AgentRunNodeSpec {
|
|
readonly id: string;
|
|
readonly route: string;
|
|
readonly kubeRoute: string;
|
|
}
|
|
|
|
interface AgentRunControlPlaneConfig {
|
|
readonly sourcePath: string;
|
|
readonly defaultTarget: {
|
|
readonly node: string;
|
|
readonly lane: string;
|
|
};
|
|
readonly nodes: Record<string, AgentRunNodeSpec>;
|
|
readonly lanes: Record<string, AgentRunLaneSpec>;
|
|
}
|
|
|
|
export function resolveAgentRunLaneTarget(options: { node?: string | null; lane?: string | null }, env: NodeJS.ProcessEnv = process.env): AgentRunLaneTarget {
|
|
const config = readAgentRunControlPlaneConfig(env);
|
|
const requestedNode = options.node ?? null;
|
|
const requestedLane = options.lane ?? null;
|
|
if (requestedLane !== null) {
|
|
const spec = config.lanes[requestedLane];
|
|
if (spec === undefined) throw new Error(`${config.sourcePath}: controlPlane.lanes.${requestedLane} is not declared`);
|
|
if (requestedNode !== null && requestedNode !== spec.nodeId) throw new Error(`--node ${requestedNode} does not match controlPlane.lanes.${requestedLane}.node=${spec.nodeId}`);
|
|
return { configPath: config.sourcePath, spec };
|
|
}
|
|
if (requestedNode !== null) {
|
|
const candidates = Object.values(config.lanes).filter((lane) => lane.nodeId === requestedNode);
|
|
if (candidates.length === 0) throw new Error(`${config.sourcePath}: no AgentRun lane is declared for node ${requestedNode}`);
|
|
if (candidates.length > 1) throw new Error(`--lane is required because node ${requestedNode} has multiple AgentRun lanes: ${candidates.map((lane) => lane.lane).join(", ")}`);
|
|
return { configPath: config.sourcePath, spec: candidates[0] };
|
|
}
|
|
const spec = config.lanes[config.defaultTarget.lane];
|
|
if (spec === undefined) throw new Error(`${config.sourcePath}: default controlPlane lane ${config.defaultTarget.lane} is not declared`);
|
|
if (spec.nodeId !== config.defaultTarget.node) throw new Error(`${config.sourcePath}: controlPlane.default.node does not match default lane node`);
|
|
return { configPath: config.sourcePath, spec };
|
|
}
|
|
|
|
export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unknown> {
|
|
return {
|
|
node: {
|
|
id: spec.nodeId,
|
|
route: spec.nodeRoute,
|
|
kubeRoute: spec.nodeKubeRoute,
|
|
},
|
|
lane: spec.lane,
|
|
version: spec.version,
|
|
source: {
|
|
repository: spec.source.repository,
|
|
branch: spec.source.branch,
|
|
bootstrapFromBranch: spec.source.bootstrapFromBranch,
|
|
bootstrapTimeoutSeconds: spec.source.bootstrapTimeoutSeconds,
|
|
bootstrapPollSeconds: spec.source.bootstrapPollSeconds,
|
|
workspace: spec.source.workspace,
|
|
},
|
|
runtime: {
|
|
namespace: spec.runtime.namespace,
|
|
managerDeployment: spec.runtime.managerDeployment,
|
|
managerService: spec.runtime.managerService,
|
|
managerPort: spec.runtime.managerPort,
|
|
internalBaseUrl: spec.runtime.internalBaseUrl,
|
|
},
|
|
ci: {
|
|
namespace: spec.ci.namespace,
|
|
pipeline: spec.ci.pipeline,
|
|
pipelineRunPrefix: spec.ci.pipelineRunPrefix,
|
|
serviceAccountName: spec.ci.serviceAccountName,
|
|
registryPrefix: spec.ci.registryPrefix,
|
|
},
|
|
gitops: {
|
|
branch: spec.gitops.branch,
|
|
path: spec.gitops.path,
|
|
argoNamespace: spec.gitops.argoNamespace,
|
|
argoApplication: spec.gitops.argoApplication,
|
|
repoURL: spec.gitops.repoURL,
|
|
},
|
|
deployment: {
|
|
format: spec.deployment.format,
|
|
gitopsRoot: spec.deployment.gitopsRoot,
|
|
runtimeRenderDir: spec.deployment.runtimeRenderDir,
|
|
artifactCatalogPath: spec.deployment.artifactCatalogPath,
|
|
argocd: spec.deployment.argocd,
|
|
manager: {
|
|
serviceAccount: spec.deployment.manager.serviceAccount,
|
|
apiKeySecretRef: spec.deployment.manager.apiKeySecretRef,
|
|
envNames: Object.keys(spec.deployment.manager.env).sort(),
|
|
unideskSshEndpointEnv: spec.deployment.manager.unideskSshEndpointEnv === null
|
|
? null
|
|
: { name: spec.deployment.manager.unideskSshEndpointEnv.name, valuesPrinted: false },
|
|
bootRepoUrl: spec.deployment.manager.bootRepoUrl,
|
|
imageBuild: {
|
|
context: spec.deployment.manager.imageBuild.context,
|
|
containerfile: spec.deployment.manager.imageBuild.containerfile,
|
|
repository: spec.deployment.manager.imageBuild.repository,
|
|
network: spec.deployment.manager.imageBuild.network,
|
|
buildArgNames: Object.keys(spec.deployment.manager.imageBuild.buildArgs).sort(),
|
|
proxyConfigured: spec.deployment.manager.imageBuild.httpProxy !== null || spec.deployment.manager.imageBuild.httpsProxy !== null,
|
|
noProxyCount: spec.deployment.manager.imageBuild.noProxy.length,
|
|
envIdentityFileCount: spec.deployment.manager.imageBuild.envIdentityFiles.length,
|
|
timeoutSeconds: spec.deployment.manager.imageBuild.timeoutSeconds,
|
|
pollSeconds: spec.deployment.manager.imageBuild.pollSeconds,
|
|
},
|
|
resources: spec.deployment.manager.resources,
|
|
},
|
|
runner: {
|
|
serviceAccount: spec.deployment.runner.serviceAccount,
|
|
jobNamePrefix: spec.deployment.runner.jobNamePrefix,
|
|
idleTimeoutMs: spec.deployment.runner.idleTimeoutMs,
|
|
apiKeySecretRef: spec.deployment.runner.apiKeySecretRef,
|
|
egressProxyUrl: spec.deployment.runner.egressProxyUrl,
|
|
noProxyExtra: spec.deployment.runner.noProxyExtra,
|
|
retention: spec.deployment.runner.retention,
|
|
},
|
|
localPostgres: spec.deployment.localPostgres,
|
|
},
|
|
gitMirror: {
|
|
namespace: spec.gitMirror.namespace,
|
|
readService: spec.gitMirror.readService,
|
|
readDeployment: spec.gitMirror.readDeployment,
|
|
writeService: spec.gitMirror.writeService,
|
|
writeDeployment: spec.gitMirror.writeDeployment,
|
|
readUrl: spec.gitMirror.readUrl,
|
|
writeUrl: spec.gitMirror.writeUrl,
|
|
cachePvc: spec.gitMirror.cachePvc,
|
|
cacheHostPath: spec.gitMirror.cacheHostPath,
|
|
sshSecretName: spec.gitMirror.sshSecretName,
|
|
githubProxy: { host: spec.gitMirror.githubProxy.host, port: spec.gitMirror.githubProxy.port },
|
|
repositories: spec.gitMirror.repositories.map((repo) => ({
|
|
key: repo.key,
|
|
repository: repo.repository,
|
|
sourceBranch: repo.sourceBranch,
|
|
gitopsBranch: repo.gitopsBranch ?? null,
|
|
})),
|
|
},
|
|
database: {
|
|
mode: spec.database.mode,
|
|
provider: spec.database.provider,
|
|
configRef: spec.database.configRef,
|
|
database: spec.database.database,
|
|
user: spec.database.user,
|
|
sslmode: spec.database.sslmode,
|
|
secretSourceRef: spec.database.secretSourceRef,
|
|
secretRef: spec.database.secretRef,
|
|
localPostgresExpectedAbsent: spec.database.localPostgresExpectedAbsent,
|
|
valuesPrinted: false,
|
|
},
|
|
secrets: spec.secrets.map((secret) => ({
|
|
id: secret.id,
|
|
sourceRef: secret.sourceRef.startsWith("/") ? secret.sourceRef : `.state/secrets/${secret.sourceRef}`,
|
|
sourceMode: secret.sourceMode,
|
|
sourceKey: secret.sourceKey,
|
|
targetRef: secret.targetRef,
|
|
providerCredential: secret.providerCredentialProfile === null ? null : { profile: secret.providerCredentialProfile },
|
|
valuesPrinted: false,
|
|
})),
|
|
providerCredentials: agentRunProviderCredentialRefs(spec).map((credential) => ({
|
|
profile: credential.profile,
|
|
secretRef: credential.secretRef,
|
|
valuesPrinted: false,
|
|
})),
|
|
};
|
|
}
|
|
|
|
export function agentRunProviderCredentialRefs(spec: AgentRunLaneSpec, profile?: string | null): readonly AgentRunProviderCredentialRef[] {
|
|
const groups = new Map<string, { profile: string; namespace: string; name: string; keys: string[] }>();
|
|
for (const secret of spec.secrets) {
|
|
const credentialProfile = secret.providerCredentialProfile;
|
|
if (credentialProfile === null) continue;
|
|
if (profile !== undefined && profile !== null && credentialProfile !== profile) continue;
|
|
const groupKey = `${credentialProfile}\0${secret.targetRef.namespace}\0${secret.targetRef.name}`;
|
|
const group = groups.get(groupKey) ?? {
|
|
profile: credentialProfile,
|
|
namespace: secret.targetRef.namespace,
|
|
name: secret.targetRef.name,
|
|
keys: [],
|
|
};
|
|
if (!group.keys.includes(secret.targetRef.key)) group.keys.push(secret.targetRef.key);
|
|
groups.set(groupKey, group);
|
|
}
|
|
return Array.from(groups.values()).map((group) => ({
|
|
profile: group.profile,
|
|
secretRef: {
|
|
namespace: group.namespace,
|
|
name: group.name,
|
|
keys: group.keys,
|
|
},
|
|
}));
|
|
}
|
|
|
|
export function agentRunPipelineRunName(spec: AgentRunLaneSpec, sourceCommit: string): string {
|
|
return `${spec.ci.pipelineRunPrefix}-${sourceCommit.slice(0, 12)}`;
|
|
}
|
|
|
|
function readAgentRunControlPlaneConfig(env: NodeJS.ProcessEnv): AgentRunControlPlaneConfig {
|
|
const configPath = env.AGENTRUN_CONTROL_PLANE_CONFIG ?? rootPath(AGENTRUN_CONFIG_PATH);
|
|
const root = readYamlRecord<Record<string, unknown>>(configPath);
|
|
validateConfigEnvelope(root, configPath);
|
|
const controlPlane = recordField(root, "controlPlane", configPath);
|
|
const defaultTarget = recordField(controlPlane, "default", `${configPath}.controlPlane`);
|
|
const nodes = parseNodes(recordField(controlPlane, "nodes", `${configPath}.controlPlane`), configPath);
|
|
const lanes = parseLanes(recordField(controlPlane, "lanes", `${configPath}.controlPlane`), nodes, configPath);
|
|
return {
|
|
sourcePath: configPath,
|
|
defaultTarget: {
|
|
node: stringField(defaultTarget, "node", `${configPath}.controlPlane.default`),
|
|
lane: stringField(defaultTarget, "lane", `${configPath}.controlPlane.default`),
|
|
},
|
|
nodes,
|
|
lanes,
|
|
};
|
|
}
|
|
|
|
function validateConfigEnvelope(root: Record<string, unknown>, configPath: string): void {
|
|
if (root.version !== 1) throw new Error(`${configPath}.version must be 1`);
|
|
if (root.kind !== "AgentRunConfig") throw new Error(`${configPath}.kind must be AgentRunConfig`);
|
|
const metadata = recordField(root, "metadata", configPath);
|
|
if (stringField(metadata, "name", `${configPath}.metadata`) !== "agentrun") {
|
|
throw new Error(`${configPath}.metadata.name must be agentrun`);
|
|
}
|
|
}
|
|
|
|
function parseNodes(input: Record<string, unknown>, configPath: string): Record<string, AgentRunNodeSpec> {
|
|
const result: Record<string, AgentRunNodeSpec> = {};
|
|
for (const [id, raw] of Object.entries(input)) {
|
|
validateSimpleId(id, `${configPath}.controlPlane.nodes`);
|
|
const node = asRecord(raw, `${configPath}.controlPlane.nodes.${id}`);
|
|
result[id] = {
|
|
id,
|
|
route: stringField(node, "route", `${configPath}.controlPlane.nodes.${id}`),
|
|
kubeRoute: stringField(node, "kubeRoute", `${configPath}.controlPlane.nodes.${id}`),
|
|
};
|
|
}
|
|
if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.nodes must declare at least one node`);
|
|
return result;
|
|
}
|
|
|
|
function parseLanes(input: Record<string, unknown>, nodes: Record<string, AgentRunNodeSpec>, configPath: string): Record<string, AgentRunLaneSpec> {
|
|
const result: Record<string, AgentRunLaneSpec> = {};
|
|
for (const [lane, raw] of Object.entries(input)) {
|
|
validateSimpleId(lane, `${configPath}.controlPlane.lanes`);
|
|
const item = asRecord(raw, `${configPath}.controlPlane.lanes.${lane}`);
|
|
const nodeId = stringField(item, "node", `${configPath}.controlPlane.lanes.${lane}`);
|
|
const node = nodes[nodeId];
|
|
if (node === undefined) throw new Error(`${configPath}.controlPlane.lanes.${lane}.node references undeclared node ${nodeId}`);
|
|
result[lane] = parseLane(lane, node, item, configPath);
|
|
}
|
|
if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.lanes must declare at least one lane`);
|
|
return result;
|
|
}
|
|
|
|
function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, unknown>, configPath: string): AgentRunLaneSpec {
|
|
const path = `${configPath}.controlPlane.lanes.${lane}`;
|
|
const source = recordField(input, "source", path);
|
|
const runtime = recordField(input, "runtime", path);
|
|
const ci = recordField(input, "ci", path);
|
|
const gitops = recordField(input, "gitops", path);
|
|
const deployment = recordField(input, "deployment", path);
|
|
const gitMirror = recordField(input, "gitMirror", path);
|
|
const database = recordField(input, "database", path);
|
|
return {
|
|
lane,
|
|
nodeId: node.id,
|
|
nodeRoute: node.route,
|
|
nodeKubeRoute: node.kubeRoute,
|
|
version: stringField(input, "version", path),
|
|
source: {
|
|
repository: stringField(source, "repository", `${path}.source`),
|
|
branch: stringField(source, "branch", `${path}.source`),
|
|
bootstrapFromBranch: optionalStringField(source, "bootstrapFromBranch", `${path}.source`) ?? null,
|
|
bootstrapTimeoutSeconds: integerField(source, "bootstrapTimeoutSeconds", `${path}.source`),
|
|
bootstrapPollSeconds: integerField(source, "bootstrapPollSeconds", `${path}.source`),
|
|
remote: stringField(source, "remote", `${path}.source`),
|
|
workspace: absolutePathField(source, "workspace", `${path}.source`),
|
|
},
|
|
runtime: {
|
|
namespace: stringField(runtime, "namespace", `${path}.runtime`),
|
|
managerDeployment: stringField(runtime, "managerDeployment", `${path}.runtime`),
|
|
managerService: stringField(runtime, "managerService", `${path}.runtime`),
|
|
managerPort: integerField(runtime, "managerPort", `${path}.runtime`),
|
|
internalBaseUrl: urlField(runtime, "internalBaseUrl", `${path}.runtime`),
|
|
},
|
|
ci: {
|
|
namespace: stringField(ci, "namespace", `${path}.ci`),
|
|
pipeline: stringField(ci, "pipeline", `${path}.ci`),
|
|
pipelineRunPrefix: stringField(ci, "pipelineRunPrefix", `${path}.ci`),
|
|
serviceAccountName: stringField(ci, "serviceAccountName", `${path}.ci`),
|
|
registryPrefix: stringField(ci, "registryPrefix", `${path}.ci`),
|
|
toolsImage: stringField(ci, "toolsImage", `${path}.ci`),
|
|
},
|
|
gitops: {
|
|
branch: stringField(gitops, "branch", `${path}.gitops`),
|
|
path: relativePathField(gitops, "path", `${path}.gitops`),
|
|
argoNamespace: stringField(gitops, "argoNamespace", `${path}.gitops`),
|
|
argoApplication: stringField(gitops, "argoApplication", `${path}.gitops`),
|
|
repoURL: urlField(gitops, "repoURL", `${path}.gitops`),
|
|
},
|
|
deployment: parseDeployment(deployment, `${path}.deployment`),
|
|
gitMirror: {
|
|
namespace: stringField(gitMirror, "namespace", `${path}.gitMirror`),
|
|
readService: stringField(gitMirror, "readService", `${path}.gitMirror`),
|
|
readDeployment: stringField(gitMirror, "readDeployment", `${path}.gitMirror`),
|
|
writeService: stringField(gitMirror, "writeService", `${path}.gitMirror`),
|
|
writeDeployment: stringField(gitMirror, "writeDeployment", `${path}.gitMirror`),
|
|
readUrl: urlField(gitMirror, "readUrl", `${path}.gitMirror`),
|
|
writeUrl: urlField(gitMirror, "writeUrl", `${path}.gitMirror`),
|
|
cachePvc: stringField(gitMirror, "cachePvc", `${path}.gitMirror`),
|
|
cacheHostPath: optionalAbsolutePathField(gitMirror, "cacheHostPath", `${path}.gitMirror`) ?? null,
|
|
sshSecretName: stringField(gitMirror, "sshSecretName", `${path}.gitMirror`),
|
|
githubProxy: parseGithubProxy(recordField(gitMirror, "githubProxy", `${path}.gitMirror`), `${path}.gitMirror.githubProxy`),
|
|
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}]`)),
|
|
},
|
|
database: parseDatabase(database, `${path}.database`),
|
|
secrets: arrayField(input, "secrets", path).map((secret, index) => parseLaneSecret(secret, `${path}.secrets[${index}]`)),
|
|
};
|
|
}
|
|
|
|
function parseDeployment(input: Record<string, unknown>, path: string): AgentRunLaneSpec["deployment"] {
|
|
const argocd = recordField(input, "argocd", path);
|
|
const manager = recordField(input, "manager", path);
|
|
const runner = recordField(input, "runner", path);
|
|
const localPostgres = recordField(input, "localPostgres", path);
|
|
return {
|
|
format: enumField(input, "format", path, ["unidesk-yaml-only"]),
|
|
gitopsRoot: relativePathField(input, "gitopsRoot", path),
|
|
runtimeRenderDir: relativePathField(input, "runtimeRenderDir", path),
|
|
artifactCatalogPath: relativePathField(input, "artifactCatalogPath", path),
|
|
argocd: {
|
|
project: stringField(argocd, "project", `${path}.argocd`),
|
|
applicationFile: relativePathField(argocd, "applicationFile", `${path}.argocd`),
|
|
},
|
|
manager: {
|
|
serviceAccount: stringField(manager, "serviceAccount", `${path}.manager`),
|
|
apiKeySecretRef: parseSecretRef(recordField(manager, "apiKeySecretRef", `${path}.manager`), `${path}.manager.apiKeySecretRef`),
|
|
env: optionalEnvRecord(manager, "env", `${path}.manager`),
|
|
unideskSshEndpointEnv: optionalEnvPair(manager, "unideskSshEndpointEnv", `${path}.manager`),
|
|
bootRepoUrl: urlField(manager, "bootRepoUrl", `${path}.manager`),
|
|
imageBuild: parseImageBuild(recordField(manager, "imageBuild", `${path}.manager`), `${path}.manager.imageBuild`),
|
|
resources: parseContainerResources(recordField(manager, "resources", `${path}.manager`), `${path}.manager.resources`),
|
|
},
|
|
runner: {
|
|
serviceAccount: stringField(runner, "serviceAccount", `${path}.runner`),
|
|
jobNamePrefix: stringField(runner, "jobNamePrefix", `${path}.runner`),
|
|
idleTimeoutMs: positiveIntegerField(runner, "idleTimeoutMs", `${path}.runner`),
|
|
missingTerminalAfterToolTimeoutMs: positiveIntegerField(runner, "missingTerminalAfterToolTimeoutMs", `${path}.runner`),
|
|
apiKeySecretRef: parseSecretRef(recordField(runner, "apiKeySecretRef", `${path}.runner`), `${path}.runner.apiKeySecretRef`),
|
|
egressProxyUrl: optionalStringField(runner, "egressProxyUrl", `${path}.runner`) ?? null,
|
|
noProxyExtra: optionalStringArrayField(runner, "noProxyExtra", `${path}.runner`),
|
|
retention: parseRunnerRetention(recordField(runner, "retention", `${path}.runner`), `${path}.runner.retention`),
|
|
},
|
|
localPostgres: parseLocalPostgres(localPostgres, `${path}.localPostgres`),
|
|
};
|
|
}
|
|
|
|
function parseRunnerRetention(input: Record<string, unknown>, path: string): AgentRunRunnerRetentionSpec {
|
|
const selectors = recordField(input, "selectors", path);
|
|
const ageBasedCleanup = recordField(input, "ageBasedCleanup", path);
|
|
return {
|
|
maxRunners: positiveIntegerField(input, "maxRunners", path),
|
|
cleanupOrder: enumField(input, "cleanupOrder", path, ["oldest-inactive-last-active-first"]),
|
|
activeHeartbeatMaxAgeMs: positiveIntegerField(input, "activeHeartbeatMaxAgeMs", path),
|
|
selectors: {
|
|
matchLabels: kubernetesLabelRecordField(recordField(selectors, "matchLabels", `${path}.selectors`), `${path}.selectors.matchLabels`),
|
|
jobNamePrefixes: stringArrayField(selectors, "jobNamePrefixes", `${path}.selectors`).map((prefix, index) => {
|
|
validateKubernetesNamePrefix(prefix, `${path}.selectors.jobNamePrefixes[${index}]`);
|
|
return prefix;
|
|
}),
|
|
},
|
|
ageBasedCleanup: {
|
|
enabled: booleanField(ageBasedCleanup, "enabled", `${path}.ageBasedCleanup`),
|
|
maxAgeHours: optionalPositiveIntegerField(ageBasedCleanup, "maxAgeHours", `${path}.ageBasedCleanup`) ?? null,
|
|
},
|
|
};
|
|
}
|
|
|
|
function positiveIntegerField(input: Record<string, unknown>, key: string, path: string): number {
|
|
const value = integerField(input, key, path);
|
|
if (value <= 0) throw new Error(`${path}.${key} must be a positive integer`);
|
|
return value;
|
|
}
|
|
|
|
function optionalPositiveIntegerField(input: Record<string, unknown>, key: string, path: string): number | undefined {
|
|
const value = optionalIntegerField(input, key, path);
|
|
if (value === undefined) return undefined;
|
|
if (value <= 0) throw new Error(`${path}.${key} must be a positive integer when set`);
|
|
return value;
|
|
}
|
|
|
|
function parseLocalPostgres(input: Record<string, unknown>, path: string): AgentRunLaneSpec["deployment"]["localPostgres"] {
|
|
const enabled = booleanField(input, "enabled", path);
|
|
if (!enabled) {
|
|
return {
|
|
enabled: false,
|
|
serviceName: optionalStringField(input, "serviceName", path) ?? null,
|
|
image: optionalStringField(input, "image", path) ?? null,
|
|
storage: optionalStringField(input, "storage", path) ?? null,
|
|
port: optionalIntegerField(input, "port", path) ?? null,
|
|
};
|
|
}
|
|
return {
|
|
enabled: true,
|
|
serviceName: stringField(input, "serviceName", path),
|
|
image: stringField(input, "image", path),
|
|
storage: stringField(input, "storage", path),
|
|
port: integerField(input, "port", path),
|
|
};
|
|
}
|
|
|
|
function parseContainerResources(input: Record<string, unknown>, path: string): AgentRunContainerResources {
|
|
const requests = recordField(input, "requests", path);
|
|
const limits = recordField(input, "limits", path);
|
|
return {
|
|
requests: {
|
|
cpu: stringField(requests, "cpu", `${path}.requests`),
|
|
memory: stringField(requests, "memory", `${path}.requests`),
|
|
},
|
|
limits: {
|
|
cpu: stringField(limits, "cpu", `${path}.limits`),
|
|
memory: stringField(limits, "memory", `${path}.limits`),
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseImageBuild(input: Record<string, unknown>, path: string): AgentRunImageBuildSpec {
|
|
return {
|
|
context: relativePathField(input, "context", path),
|
|
containerfile: relativePathField(input, "containerfile", path),
|
|
repository: stringField(input, "repository", path),
|
|
network: stringField(input, "network", path),
|
|
buildArgs: stringRecordField(recordField(input, "buildArgs", path), `${path}.buildArgs`),
|
|
httpProxy: optionalStringField(input, "httpProxy", path) ?? null,
|
|
httpsProxy: optionalStringField(input, "httpsProxy", path) ?? null,
|
|
noProxy: stringArrayField(input, "noProxy", path),
|
|
envIdentityFiles: stringArrayField(input, "envIdentityFiles", path).map((item, index) => {
|
|
if (item.startsWith("/") || item.includes("..")) throw new Error(`${path}.envIdentityFiles[${index}] must be a relative path without ..`);
|
|
return item;
|
|
}),
|
|
timeoutSeconds: integerField(input, "timeoutSeconds", path),
|
|
pollSeconds: integerField(input, "pollSeconds", path),
|
|
};
|
|
}
|
|
|
|
function parseLaneSecret(input: Record<string, unknown>, path: string): AgentRunLaneSecretSpec {
|
|
const sourceMode = optionalStringField(input, "sourceMode", path) ?? "env";
|
|
if (sourceMode !== "env" && sourceMode !== "file") throw new Error(`${path}.sourceMode must be env or file`);
|
|
const sourceKey = optionalStringField(input, "sourceKey", path) ?? null;
|
|
if (sourceMode === "env" && sourceKey === null) throw new Error(`${path}.sourceKey is required when sourceMode is env`);
|
|
const providerCredential = parseProviderCredentialBinding(input, `${path}.providerCredential`);
|
|
return {
|
|
id: stringField(input, "id", path),
|
|
sourceRef: secretSourceRefField(input, "sourceRef", path),
|
|
sourceMode,
|
|
sourceKey,
|
|
targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`),
|
|
providerCredentialProfile: providerCredential,
|
|
};
|
|
}
|
|
|
|
function parseProviderCredentialBinding(input: Record<string, unknown>, path: string): string | null {
|
|
const value = input.providerCredential;
|
|
if (value === undefined || value === null) return null;
|
|
const record = asRecord(value, path);
|
|
const profile = stringField(record, "profile", path);
|
|
validateSimpleId(profile, path);
|
|
return profile;
|
|
}
|
|
|
|
function parseGitMirrorRepository(input: Record<string, unknown>, path: string): AgentRunGitMirrorRepositorySpec {
|
|
const gitopsBranch = optionalStringField(input, "gitopsBranch", path);
|
|
return {
|
|
key: stringField(input, "key", path),
|
|
repository: stringField(input, "repository", path),
|
|
sourceBranch: stringField(input, "sourceBranch", path),
|
|
...(gitopsBranch === undefined ? {} : { gitopsBranch }),
|
|
};
|
|
}
|
|
|
|
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);
|
|
if (sslmode !== undefined && sslmode !== "require") throw new Error(`${path}.sslmode must be require when set`);
|
|
return {
|
|
mode,
|
|
provider: optionalStringField(input, "provider", path) ?? null,
|
|
configRef: optionalStringField(input, "configRef", path) ?? null,
|
|
database: optionalStringField(input, "database", path) ?? null,
|
|
user: optionalStringField(input, "user", path) ?? null,
|
|
sslmode: sslmode === undefined ? null : "require",
|
|
secretSourceRef: optionalStringField(input, "secretSourceRef", path) ?? null,
|
|
secretRef: parseSecretRef(recordField(input, "secretRef", path), `${path}.secretRef`),
|
|
localPostgresExpectedAbsent: booleanField(input, "localPostgresExpectedAbsent", path),
|
|
};
|
|
}
|
|
|
|
function parseSecretRef(input: Record<string, unknown>, path: string): { name: string; key: string } {
|
|
return {
|
|
name: stringField(input, "name", path),
|
|
key: stringField(input, "key", path),
|
|
};
|
|
}
|
|
|
|
function parseNamespacedSecretRef(input: Record<string, unknown>, path: string): AgentRunSecretRef {
|
|
return {
|
|
namespace: stringField(input, "namespace", path),
|
|
name: stringField(input, "name", path),
|
|
key: stringField(input, "key", path),
|
|
};
|
|
}
|
|
|
|
function parseGithubProxy(input: Record<string, unknown>, path: string): { host: string; port: number } {
|
|
return {
|
|
host: stringField(input, "host", path),
|
|
port: integerField(input, "port", path),
|
|
};
|
|
}
|
|
|
|
function optionalEnvPair(obj: Record<string, unknown>, key: string, path: string): { name: string; value: string } | null {
|
|
const value = obj[key];
|
|
if (value === undefined || value === null) return null;
|
|
const record = asRecord(value, `${path}.${key}`);
|
|
return {
|
|
name: stringField(record, "name", `${path}.${key}`),
|
|
value: stringField(record, "value", `${path}.${key}`),
|
|
};
|
|
}
|
|
|
|
function optionalEnvRecord(obj: Record<string, unknown>, key: string, path: string): Readonly<Record<string, string>> {
|
|
const value = obj[key];
|
|
if (value === undefined || value === null) return {};
|
|
return stringRecordField(asRecord(value, `${path}.${key}`), `${path}.${key}`);
|
|
}
|
|
|
|
function optionalStringArrayField(obj: Record<string, unknown>, key: string, path: string): readonly string[] {
|
|
const value = obj[key];
|
|
if (value === undefined || value === null) return [];
|
|
return stringArrayField(obj, key, path);
|
|
}
|
|
|
|
function stringRecordField(obj: Record<string, unknown>, path: string): Readonly<Record<string, string>> {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
if (!/^[A-Za-z_][A-Za-z0-9_]*$/u.test(key)) throw new Error(`${path}.${key} must be a valid build arg name`);
|
|
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
result[key] = value.trim();
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function kubernetesLabelRecordField(obj: Record<string, unknown>, path: string): Readonly<Record<string, string>> {
|
|
const result: Record<string, string> = {};
|
|
for (const [key, value] of Object.entries(obj)) {
|
|
validateKubernetesLabelKey(key, `${path}.${key}`);
|
|
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
const trimmed = value.trim();
|
|
validateKubernetesLabelValue(trimmed, `${path}.${key}`);
|
|
result[key] = trimmed;
|
|
}
|
|
if (Object.keys(result).length === 0) throw new Error(`${path} must declare at least one label`);
|
|
return result;
|
|
}
|
|
|
|
function enumField<T extends string>(obj: Record<string, unknown>, key: string, path: string, values: readonly T[]): T {
|
|
const value = stringField(obj, key, path);
|
|
if (!values.includes(value as T)) throw new Error(`${path}.${key} must be one of ${values.join(", ")}`);
|
|
return value as T;
|
|
}
|
|
|
|
function absolutePathField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = stringField(obj, key, path);
|
|
if (!value.startsWith("/") || value.includes("..")) throw new Error(`${path}.${key} must be an absolute path without ..`);
|
|
return value;
|
|
}
|
|
|
|
function optionalAbsolutePathField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
|
|
const value = obj[key];
|
|
if (value === undefined || value === null) return undefined;
|
|
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string when set`);
|
|
const trimmed = value.trim();
|
|
if (!trimmed.startsWith("/") || trimmed.includes("..")) throw new Error(`${path}.${key} must be an absolute path without ..`);
|
|
return trimmed;
|
|
}
|
|
|
|
function relativePathField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = stringField(obj, key, path);
|
|
if (value.startsWith("/") || value.includes("..")) throw new Error(`${path}.${key} must be a relative path without ..`);
|
|
return value;
|
|
}
|
|
|
|
function secretSourceRefField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = stringField(obj, key, path);
|
|
if (value.includes("..")) throw new Error(`${path}.${key} must not contain ..`);
|
|
if (!value.startsWith("/") && value.startsWith(".")) throw new Error(`${path}.${key} must be absolute or relative without a leading dot`);
|
|
return value;
|
|
}
|
|
|
|
function urlField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = stringField(obj, key, path);
|
|
try {
|
|
const parsed = new URL(value);
|
|
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") throw new Error("unsupported protocol");
|
|
} catch {
|
|
throw new Error(`${path}.${key} must be an http(s) URL`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function validateSimpleId(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9._-]+$/u.test(value)) throw new Error(`${path}.${value} must use a simple id`);
|
|
}
|
|
|
|
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 validateKubernetesLabelKey(value: string, path: string): void {
|
|
const parts = value.split("/");
|
|
const name = parts.length === 2 ? parts[1] : parts[0];
|
|
if (parts.length > 2 || !name || !/^[A-Za-z0-9]([A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u.test(name)) throw new Error(`${path} must be a Kubernetes label key`);
|
|
}
|
|
|
|
function validateKubernetesLabelValue(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9]([A-Za-z0-9_.-]{0,61}[A-Za-z0-9])?$/u.test(value)) throw new Error(`${path} must be a Kubernetes label value`);
|
|
}
|