702 lines
30 KiB
TypeScript
702 lines
30 KiB
TypeScript
import { readFileSync } from "node:fs";
|
|
import { rootPath } from "./config";
|
|
|
|
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;
|
|
}
|
|
|
|
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 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 apiKeySecretRef: { readonly name: string; readonly key: string };
|
|
};
|
|
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 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,
|
|
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,
|
|
apiKeySecretRef: spec.deployment.runner.apiKeySecretRef,
|
|
},
|
|
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,
|
|
valuesPrinted: false,
|
|
})),
|
|
};
|
|
}
|
|
|
|
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 = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, 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 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`),
|
|
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`),
|
|
apiKeySecretRef: parseSecretRef(recordField(runner, "apiKeySecretRef", `${path}.runner`), `${path}.runner.apiKeySecretRef`),
|
|
},
|
|
localPostgres: parseLocalPostgres(localPostgres, `${path}.localPostgres`),
|
|
};
|
|
}
|
|
|
|
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`);
|
|
return {
|
|
id: stringField(input, "id", path),
|
|
sourceRef: secretSourceRefField(input, "sourceRef", path),
|
|
sourceMode,
|
|
sourceKey,
|
|
targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`),
|
|
};
|
|
}
|
|
|
|
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 asRecord(value: unknown, path: string): Record<string, unknown> {
|
|
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`);
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function recordField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
|
|
return asRecord(obj[key], `${path}.${key}`);
|
|
}
|
|
|
|
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = obj[key];
|
|
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
|
|
return value.trim();
|
|
}
|
|
|
|
function optionalStringField(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`);
|
|
return value.trim();
|
|
}
|
|
|
|
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
|
|
const value = obj[key];
|
|
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
|
return value;
|
|
}
|
|
|
|
function integerField(obj: Record<string, unknown>, key: string, path: string): number {
|
|
const value = obj[key];
|
|
if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer`);
|
|
return Number(value);
|
|
}
|
|
|
|
function optionalIntegerField(obj: Record<string, unknown>, key: string, path: string): number | undefined {
|
|
const value = obj[key];
|
|
if (value === undefined || value === null) return undefined;
|
|
if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer when set`);
|
|
return Number(value);
|
|
}
|
|
|
|
function arrayField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown>[] {
|
|
const value = obj[key];
|
|
if (!Array.isArray(value)) throw new Error(`${path}.${key} must be a YAML array`);
|
|
return value.map((item, index) => asRecord(item, `${path}.${key}[${index}]`));
|
|
}
|
|
|
|
function stringArrayField(obj: Record<string, unknown>, key: string, path: string): string[] {
|
|
const value = obj[key];
|
|
if (!Array.isArray(value)) throw new Error(`${path}.${key} must be a YAML array`);
|
|
return value.map((item, index) => {
|
|
if (typeof item !== "string" || item.trim().length === 0) throw new Error(`${path}.${key}[${index}] must be a non-empty string`);
|
|
return item.trim();
|
|
});
|
|
}
|
|
|
|
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 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`);
|
|
}
|