Files
pikasTech-unidesk/scripts/src/agentrun-lanes.ts
T

741 lines
32 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 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 apiKeySecretRef: { readonly name: string; readonly key: string };
readonly egressProxyUrl: string | null;
readonly noProxyExtra: readonly 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,
idleTimeoutMs: spec.deployment.runner.idleTimeoutMs,
apiKeySecretRef: spec.deployment.runner.apiKeySecretRef,
egressProxyUrl: spec.deployment.runner.egressProxyUrl,
noProxyExtra: spec.deployment.runner.noProxyExtra,
},
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`),
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`),
apiKeySecretRef: parseSecretRef(recordField(runner, "apiKeySecretRef", `${path}.runner`), `${path}.runner.apiKeySecretRef`),
egressProxyUrl: optionalStringField(runner, "egressProxyUrl", `${path}.runner`) ?? null,
noProxyExtra: optionalStringArrayField(runner, "noProxyExtra", `${path}.runner`),
},
localPostgres: parseLocalPostgres(localPostgres, `${path}.localPostgres`),
};
}
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 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 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 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`);
}