1353 lines
63 KiB
TypeScript
1353 lines
63 KiB
TypeScript
// SPEC: PJ2026-01060305 AgentRun execution policy + PJ2026-01020108 cancel lifecycle draft-2026-06-25-p0.
|
|
// Parses AgentRun YAML lane policy, including cancel lifecycle values owned by config/agentrun.yaml.
|
|
import { rootPath } from "./config";
|
|
import {
|
|
asRecord,
|
|
arrayField,
|
|
booleanField,
|
|
integerField,
|
|
optionalIntegerField,
|
|
optionalStringField,
|
|
readYamlRecord,
|
|
recordField,
|
|
stringArrayField,
|
|
stringField,
|
|
} from "./platform-infra-ops-library";
|
|
import { materializeYamlComposition } from "./yaml-composition";
|
|
|
|
export const AGENTRUN_CONFIG_PATH = "config/agentrun.yaml";
|
|
|
|
export interface AgentRunGitMirrorRepositorySpec {
|
|
readonly key: string;
|
|
readonly repository: string;
|
|
readonly remote: string;
|
|
readonly sourceBranch: string;
|
|
readonly gitopsBranch?: string;
|
|
}
|
|
|
|
export interface AgentRunManagedRepositoryReconcilerSpec {
|
|
readonly enabled: boolean;
|
|
readonly deploymentName: string;
|
|
readonly configMapName: string;
|
|
readonly serviceAccountName: string;
|
|
readonly imagePullPolicy: "Always" | "IfNotPresent" | "Never";
|
|
readonly cacheMountPath: string;
|
|
readonly stateDirectory: string;
|
|
readonly reconcileIntervalMs: number;
|
|
readonly fetchTimeoutMs: number;
|
|
readonly shutdownGraceMs: number;
|
|
readonly maxConcurrentRepositories: number;
|
|
readonly retry: {
|
|
readonly maxAttempts: number;
|
|
readonly initialDelayMs: number;
|
|
readonly maxDelayMs: number;
|
|
};
|
|
readonly freshness: {
|
|
readonly maxAgeMs: number;
|
|
};
|
|
readonly readiness: {
|
|
readonly initialDelaySeconds: number;
|
|
readonly periodSeconds: number;
|
|
readonly timeoutSeconds: number;
|
|
readonly failureThreshold: number;
|
|
};
|
|
readonly lifecycle: {
|
|
readonly undeclaredRepositoryPolicy: "retain";
|
|
readonly managedRefs: "source-branch-only";
|
|
};
|
|
readonly resources: AgentRunContainerResources;
|
|
}
|
|
|
|
export interface AgentRunSourceAuthoritySpec {
|
|
readonly mode: "gitMirrorSnapshot";
|
|
readonly resolver: "k8s-git-mirror";
|
|
readonly allowHostGit: false;
|
|
readonly allowHostWorkspace: false;
|
|
readonly allowGithubDirectInPipeline: false;
|
|
}
|
|
|
|
export interface AgentRunSourceSnapshotSpec {
|
|
readonly stageRefPrefix: string;
|
|
readonly missingObjectPolicy: "fail-fast";
|
|
readonly refreshPolicy: "sync-before-snapshot";
|
|
}
|
|
|
|
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" | "codex-config" | "json";
|
|
readonly sourceKey: string | null;
|
|
readonly sourceFormat: "env" | "raw-token" | null;
|
|
readonly transform: "codex-auth-json-openai-api-key" | null;
|
|
readonly codexConfig: AgentRunCodexConfigSpec | null;
|
|
readonly jsonValue: unknown;
|
|
readonly targetRef: AgentRunSecretRef;
|
|
readonly providerCredentialProfile: string | null;
|
|
}
|
|
|
|
export interface AgentRunCodexConfigSpec {
|
|
readonly modelProvider: string;
|
|
readonly model: string;
|
|
readonly reviewModel: string | null;
|
|
readonly modelReasoningEffort: string | null;
|
|
readonly disableResponseStorage: boolean | null;
|
|
readonly networkAccess: string | null;
|
|
readonly windowsWslSetupAcknowledged: boolean | null;
|
|
readonly serviceTier: string | null;
|
|
readonly modelContextWindow: number | null;
|
|
readonly modelAutoCompactTokenLimit: number | null;
|
|
readonly modelCatalogJson: string | null;
|
|
readonly approvalsReviewer: string | null;
|
|
readonly noticeHideFullAccessWarning: boolean | null;
|
|
readonly modelProviders: readonly AgentRunCodexModelProviderSpec[];
|
|
readonly features: Readonly<Record<string, boolean>>;
|
|
readonly projects: readonly AgentRunCodexProjectSpec[];
|
|
readonly tuiModelAvailabilityNux: Readonly<Record<string, number>>;
|
|
}
|
|
|
|
export interface AgentRunCodexModelProviderSpec {
|
|
readonly id: string;
|
|
readonly name: string;
|
|
readonly baseUrl: string;
|
|
readonly wireApi: string;
|
|
readonly requiresOpenaiAuth: boolean | null;
|
|
}
|
|
|
|
export interface AgentRunCodexProjectSpec {
|
|
readonly path: string;
|
|
readonly trustLevel: string;
|
|
}
|
|
|
|
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 statusMode: "host-worktree" | "k3s-git-mirror";
|
|
readonly repository: string;
|
|
readonly worktreeRemote: string;
|
|
readonly branch: string;
|
|
readonly sourceAuthority: AgentRunSourceAuthoritySpec | null;
|
|
readonly sourceSnapshot: AgentRunSourceSnapshotSpec | null;
|
|
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 buildkitImage: string | null;
|
|
};
|
|
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 backendRetry: {
|
|
readonly maxAttempts: number;
|
|
readonly initialBackoffMs: number;
|
|
readonly maxBackoffMs: number;
|
|
};
|
|
readonly apiKeySecretRef: { readonly name: string; readonly key: string };
|
|
readonly egressProxyUrl: string | null;
|
|
readonly noProxyExtra: readonly string[];
|
|
readonly retention: AgentRunRunnerRetentionSpec;
|
|
readonly cancelLifecycle: AgentRunCancelLifecycleSpec;
|
|
};
|
|
readonly localPostgres: {
|
|
readonly enabled: boolean;
|
|
readonly serviceName: string | null;
|
|
readonly image: string | null;
|
|
readonly storage: string | null;
|
|
readonly port: number | null;
|
|
readonly database: string | null;
|
|
readonly user: string | null;
|
|
readonly passwordSourceRef: string | null;
|
|
readonly passwordSourceKey: string | null;
|
|
};
|
|
};
|
|
readonly gitMirror: {
|
|
readonly namespace: string;
|
|
readonly readService: string;
|
|
readonly readDeployment: string;
|
|
readonly writeService: string;
|
|
readonly writeDeployment: string;
|
|
readonly resourceBundleBaseUrl: 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 repositoryReconciler: AgentRunManagedRepositoryReconcilerSpec;
|
|
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 buildContainerProxy: {
|
|
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 stalePendingMaxAgeMs: number;
|
|
readonly selectors: {
|
|
readonly matchLabels: Readonly<Record<string, string>>;
|
|
readonly jobNamePrefixes: readonly string[];
|
|
};
|
|
readonly ageBasedCleanup: {
|
|
readonly enabled: boolean;
|
|
readonly maxAgeHours: number | null;
|
|
};
|
|
readonly sessionPvcRetention: {
|
|
readonly enabled: boolean;
|
|
readonly prefixes: readonly string[];
|
|
readonly maxDeletePerRun: number;
|
|
};
|
|
}
|
|
|
|
export type AgentRunCancelLifecycleStage = "accepted" | "persisted" | "delivered" | "aborting" | "terminalized" | "fenced" | "late-write-rejected";
|
|
|
|
export interface AgentRunCancelLifecycleSpec {
|
|
readonly deliveryMode: "manager-epoch";
|
|
readonly gracefulAbortMs: number;
|
|
readonly killEscalationMs: number;
|
|
readonly staleHeartbeatFencingMs: number;
|
|
readonly lateWriteFencing: {
|
|
readonly enabled: boolean;
|
|
};
|
|
readonly eventStages: readonly AgentRunCancelLifecycleStage[];
|
|
}
|
|
|
|
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 resolveAgentRunLaneTargetsForNode(nodeId: string, env: NodeJS.ProcessEnv = process.env): readonly AgentRunLaneTarget[] {
|
|
const targets = listAgentRunLaneTargetsForNode(nodeId, env);
|
|
if (targets.length === 0) {
|
|
const configPath = env.AGENTRUN_CONTROL_PLANE_CONFIG ?? rootPath(AGENTRUN_CONFIG_PATH);
|
|
throw new Error(`${configPath}: no AgentRun lane is declared for node ${nodeId}`);
|
|
}
|
|
return targets;
|
|
}
|
|
|
|
export function listAgentRunLaneTargetsForNode(nodeId: string, env: NodeJS.ProcessEnv = process.env): readonly AgentRunLaneTarget[] {
|
|
const config = readAgentRunControlPlaneConfig(env);
|
|
return Object.values(config.lanes)
|
|
.filter((lane) => lane.nodeId === nodeId)
|
|
.sort((left, right) => left.lane.localeCompare(right.lane))
|
|
.map((spec) => ({ 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,
|
|
statusMode: spec.source.statusMode,
|
|
sourceAuthority: spec.source.sourceAuthority,
|
|
sourceSnapshot: spec.source.sourceSnapshot,
|
|
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,
|
|
buildkitImage: spec.ci.buildkitImage,
|
|
},
|
|
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,
|
|
buildContainerProxyConfigured:
|
|
spec.deployment.manager.imageBuild.buildContainerProxy.httpProxy !== null || spec.deployment.manager.imageBuild.buildContainerProxy.httpsProxy !== null,
|
|
buildContainerNoProxyCount: spec.deployment.manager.imageBuild.buildContainerProxy.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,
|
|
cancelLifecycle: spec.deployment.runner.cancelLifecycle,
|
|
},
|
|
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,
|
|
resourceBundleBaseUrl: spec.gitMirror.resourceBundleBaseUrl,
|
|
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 },
|
|
repositoryReconciler: spec.gitMirror.repositoryReconciler,
|
|
repositories: spec.gitMirror.repositories.map((repo) => ({
|
|
key: repo.key,
|
|
repository: repo.repository,
|
|
remoteConfigured: repo.remote.length > 0,
|
|
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.sourceMode === "codex-config" || secret.sourceMode === "json" ? secret.sourceRef : secret.sourceRef.startsWith("/") ? secret.sourceRef : rootPath(".state", "secrets", secret.sourceRef),
|
|
sourceMode: secret.sourceMode,
|
|
sourceKey: secret.sourceKey,
|
|
sourceFormat: secret.sourceFormat,
|
|
transform: secret.transform,
|
|
targetRef: secret.targetRef,
|
|
providerCredential: secret.providerCredentialProfile === null ? null : { profile: secret.providerCredentialProfile },
|
|
codexConfig: secret.codexConfig === null ? null : {
|
|
modelProvider: secret.codexConfig.modelProvider,
|
|
model: secret.codexConfig.model,
|
|
reviewModel: secret.codexConfig.reviewModel,
|
|
modelReasoningEffort: secret.codexConfig.modelReasoningEffort,
|
|
networkAccess: secret.codexConfig.networkAccess,
|
|
serviceTier: secret.codexConfig.serviceTier,
|
|
modelContextWindow: secret.codexConfig.modelContextWindow,
|
|
modelAutoCompactTokenLimit: secret.codexConfig.modelAutoCompactTokenLimit,
|
|
modelCatalogJson: secret.codexConfig.modelCatalogJson,
|
|
approvalsReviewer: secret.codexConfig.approvalsReviewer,
|
|
noticeHideFullAccessWarning: secret.codexConfig.noticeHideFullAccessWarning,
|
|
modelProviderCount: secret.codexConfig.modelProviders.length,
|
|
projectCount: secret.codexConfig.projects.length,
|
|
},
|
|
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)}`;
|
|
}
|
|
|
|
export function agentRunSourceSnapshotStageRefPrefix(spec: AgentRunLaneSpec): string {
|
|
const snapshot = spec.source.sourceSnapshot;
|
|
if (snapshot === null) throw new Error(`config/agentrun.yaml controlPlane.lanes.${spec.lane}.source.sourceSnapshot is required for k8s git-mirror snapshot source authority`);
|
|
return snapshot.stageRefPrefix.replaceAll("{branch}", spec.source.branch);
|
|
}
|
|
|
|
export function agentRunSourceSnapshotRef(spec: AgentRunLaneSpec, sourceCommit: string): string {
|
|
if (!/^[0-9a-f]{40}$/iu.test(sourceCommit)) throw new Error(`sourceCommit must be a 40-hex git SHA for source snapshot ref: ${sourceCommit}`);
|
|
return `${agentRunSourceSnapshotStageRefPrefix(spec).replace(/\/+$/u, "")}/${sourceCommit.toLowerCase()}`;
|
|
}
|
|
|
|
function readAgentRunControlPlaneConfig(env: NodeJS.ProcessEnv): AgentRunControlPlaneConfig {
|
|
const configPath = env.AGENTRUN_CONTROL_PLANE_CONFIG ?? rootPath(AGENTRUN_CONFIG_PATH);
|
|
const root = materializeYamlComposition(readYamlRecord<Record<string, unknown>>(configPath), { label: configPath }).value;
|
|
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);
|
|
const version = stringField(input, "version", path);
|
|
const statusMode = sourceStatusModeField(optionalStringField(source, "statusMode", `${path}.source`) ?? (version === "v0.2" ? "k3s-git-mirror" : "host-worktree"), `${path}.source.statusMode`);
|
|
const spec: AgentRunLaneSpec = {
|
|
lane,
|
|
nodeId: node.id,
|
|
nodeRoute: node.route,
|
|
nodeKubeRoute: node.kubeRoute,
|
|
version,
|
|
source: {
|
|
statusMode,
|
|
repository: stringField(source, "repository", `${path}.source`),
|
|
worktreeRemote: stringField(source, "worktreeRemote", `${path}.source`),
|
|
branch: stringField(source, "branch", `${path}.source`),
|
|
sourceAuthority: sourceAuthorityConfig(source.sourceAuthority, `${path}.source.sourceAuthority`),
|
|
sourceSnapshot: sourceSnapshotConfig(source.sourceSnapshot, `${path}.source.sourceSnapshot`),
|
|
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`),
|
|
buildkitImage: optionalStringField(ci, "buildkitImage", `${path}.ci`) ?? null,
|
|
},
|
|
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`),
|
|
resourceBundleBaseUrl: urlField(gitMirror, "resourceBundleBaseUrl", `${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`),
|
|
repositoryReconciler: parseManagedRepositoryReconciler(recordField(gitMirror, "repositoryReconciler", `${path}.gitMirror`), `${path}.gitMirror.repositoryReconciler`),
|
|
repositories: parseGitMirrorRepositories(arrayField(gitMirror, "repositories", `${path}.gitMirror`), `${path}.gitMirror.repositories`),
|
|
},
|
|
database: parseDatabase(database, `${path}.database`),
|
|
secrets: parseLaneSecrets(input, path),
|
|
};
|
|
validateAgentRunLaneSourceAuthority(spec, path);
|
|
return spec;
|
|
}
|
|
|
|
function sourceStatusModeField(value: string, path: string): "host-worktree" | "k3s-git-mirror" {
|
|
if (value !== "host-worktree" && value !== "k3s-git-mirror") throw new Error(`${path} must be host-worktree or k3s-git-mirror`);
|
|
return value;
|
|
}
|
|
|
|
function sourceAuthorityConfig(value: unknown, path: string): AgentRunSourceAuthoritySpec | null {
|
|
if (value === undefined) return null;
|
|
const raw = asRecord(value, path);
|
|
return {
|
|
mode: enumField(raw, "mode", path, ["gitMirrorSnapshot"]),
|
|
resolver: enumField(raw, "resolver", path, ["k8s-git-mirror"]),
|
|
allowHostGit: falseBooleanField(raw, "allowHostGit", path),
|
|
allowHostWorkspace: falseBooleanField(raw, "allowHostWorkspace", path),
|
|
allowGithubDirectInPipeline: falseBooleanField(raw, "allowGithubDirectInPipeline", path),
|
|
};
|
|
}
|
|
|
|
function sourceSnapshotConfig(value: unknown, path: string): AgentRunSourceSnapshotSpec | null {
|
|
if (value === undefined) return null;
|
|
const raw = asRecord(value, path);
|
|
const stageRefPrefix = stringField(raw, "stageRefPrefix", path);
|
|
if (!stageRefPrefix.startsWith("refs/")) throw new Error(`${path}.stageRefPrefix must start with refs/`);
|
|
if (stageRefPrefix.includes("..") || /\s/u.test(stageRefPrefix)) throw new Error(`${path}.stageRefPrefix must not contain whitespace or ..`);
|
|
if (!stageRefPrefix.includes("{branch}")) throw new Error(`${path}.stageRefPrefix must include {branch}`);
|
|
return {
|
|
stageRefPrefix,
|
|
missingObjectPolicy: enumField(raw, "missingObjectPolicy", path, ["fail-fast"]),
|
|
refreshPolicy: enumField(raw, "refreshPolicy", path, ["sync-before-snapshot"]),
|
|
};
|
|
}
|
|
|
|
function falseBooleanField(obj: Record<string, unknown>, key: string, path: string): false {
|
|
const value = booleanField(obj, key, path);
|
|
if (value !== false) throw new Error(`${path}.${key} must be false`);
|
|
return false;
|
|
}
|
|
|
|
function validateAgentRunLaneSourceAuthority(spec: AgentRunLaneSpec, path: string): void {
|
|
if (spec.version !== "v0.2") return;
|
|
if (spec.source.statusMode !== "k3s-git-mirror") throw new Error(`${path}.source.statusMode must be k3s-git-mirror for AgentRun v0.2`);
|
|
if (spec.source.sourceAuthority === null) throw new Error(`${path}.source.sourceAuthority is required for AgentRun v0.2`);
|
|
if (spec.source.sourceSnapshot === null) throw new Error(`${path}.source.sourceSnapshot is required for AgentRun v0.2`);
|
|
}
|
|
|
|
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`),
|
|
backendRetry: parseRunnerBackendRetry(recordField(runner, "backendRetry", `${path}.runner`), `${path}.runner.backendRetry`),
|
|
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`),
|
|
cancelLifecycle: parseCancelLifecycle(recordField(runner, "cancelLifecycle", `${path}.runner`), `${path}.runner.cancelLifecycle`),
|
|
},
|
|
localPostgres: parseLocalPostgres(localPostgres, `${path}.localPostgres`),
|
|
};
|
|
}
|
|
|
|
function parseCancelLifecycle(input: Record<string, unknown>, path: string): AgentRunCancelLifecycleSpec {
|
|
const lateWriteFencing = recordField(input, "lateWriteFencing", path);
|
|
return {
|
|
deliveryMode: enumField(input, "deliveryMode", path, ["manager-epoch"]),
|
|
gracefulAbortMs: positiveIntegerField(input, "gracefulAbortMs", path),
|
|
killEscalationMs: positiveIntegerField(input, "killEscalationMs", path),
|
|
staleHeartbeatFencingMs: positiveIntegerField(input, "staleHeartbeatFencingMs", path),
|
|
lateWriteFencing: {
|
|
enabled: booleanField(lateWriteFencing, "enabled", `${path}.lateWriteFencing`),
|
|
},
|
|
eventStages: parseCancelLifecycleStages(input.eventStages, `${path}.eventStages`),
|
|
};
|
|
}
|
|
|
|
function parseCancelLifecycleStages(input: unknown, path: string): readonly AgentRunCancelLifecycleStage[] {
|
|
const values: readonly AgentRunCancelLifecycleStage[] = ["accepted", "persisted", "delivered", "aborting", "terminalized", "fenced", "late-write-rejected"];
|
|
if (!Array.isArray(input)) throw new Error(`${path} must be an array`);
|
|
if (input.length === 0) throw new Error(`${path} must declare at least one stage`);
|
|
const result = input.map((value, index) => {
|
|
if (typeof value !== "string" || !values.includes(value as AgentRunCancelLifecycleStage)) throw new Error(`${path}[${index}] must be one of ${values.join(", ")}`);
|
|
return value as AgentRunCancelLifecycleStage;
|
|
});
|
|
const duplicates = result.filter((value, index) => result.indexOf(value) !== index);
|
|
if (duplicates.length > 0) throw new Error(`${path} must not contain duplicate stages: ${[...new Set(duplicates)].join(", ")}`);
|
|
return result;
|
|
}
|
|
|
|
function parseRunnerRetention(input: Record<string, unknown>, path: string): AgentRunRunnerRetentionSpec {
|
|
const selectors = recordField(input, "selectors", path);
|
|
const ageBasedCleanup = recordField(input, "ageBasedCleanup", path);
|
|
const sessionPvcRetentionRaw = input.sessionPvcRetention;
|
|
const sessionPvcRetention = typeof sessionPvcRetentionRaw === "object" && sessionPvcRetentionRaw !== null && !Array.isArray(sessionPvcRetentionRaw)
|
|
? sessionPvcRetentionRaw as Record<string, unknown>
|
|
: {};
|
|
const sessionPvcPrefixes = sessionPvcRetention.prefixes === undefined ? [] : stringArrayField(sessionPvcRetention, "prefixes", `${path}.sessionPvcRetention`);
|
|
for (const [index, prefix] of sessionPvcPrefixes.entries()) {
|
|
if (!/^[a-z0-9]([-a-z0-9]*[a-z0-9-])?$/u.test(prefix)) throw new Error(`${path}.sessionPvcRetention.prefixes[${index}] must be a lowercase Kubernetes PVC name prefix`);
|
|
}
|
|
return {
|
|
maxRunners: positiveIntegerField(input, "maxRunners", path),
|
|
cleanupOrder: enumField(input, "cleanupOrder", path, ["oldest-inactive-last-active-first"]),
|
|
activeHeartbeatMaxAgeMs: positiveIntegerField(input, "activeHeartbeatMaxAgeMs", path),
|
|
stalePendingMaxAgeMs: positiveIntegerField(input, "stalePendingMaxAgeMs", 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,
|
|
},
|
|
sessionPvcRetention: {
|
|
enabled: sessionPvcRetention.enabled === undefined ? false : booleanField(sessionPvcRetention, "enabled", `${path}.sessionPvcRetention`),
|
|
prefixes: sessionPvcPrefixes,
|
|
maxDeletePerRun: optionalPositiveIntegerField(sessionPvcRetention, "maxDeletePerRun", `${path}.sessionPvcRetention`) ?? 100,
|
|
},
|
|
};
|
|
}
|
|
|
|
function parseRunnerBackendRetry(input: Record<string, unknown>, path: string): AgentRunLaneSpec["deployment"]["runner"]["backendRetry"] {
|
|
return {
|
|
maxAttempts: positiveIntegerField(input, "maxAttempts", path),
|
|
initialBackoffMs: positiveIntegerField(input, "initialBackoffMs", path),
|
|
maxBackoffMs: positiveIntegerField(input, "maxBackoffMs", path),
|
|
};
|
|
}
|
|
|
|
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,
|
|
database: optionalStringField(input, "database", path) ?? null,
|
|
user: optionalStringField(input, "user", path) ?? null,
|
|
passwordSourceRef: optionalStringField(input, "passwordSourceRef", path) ?? null,
|
|
passwordSourceKey: optionalStringField(input, "passwordSourceKey", 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),
|
|
database: stringField(input, "database", path),
|
|
user: stringField(input, "user", path),
|
|
passwordSourceRef: secretSourceRefField(input, "passwordSourceRef", path),
|
|
passwordSourceKey: stringField(input, "passwordSourceKey", 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 {
|
|
const buildContainerProxy = recordField(input, "buildContainerProxy", path);
|
|
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),
|
|
buildContainerProxy: {
|
|
httpProxy: optionalStringField(buildContainerProxy, "httpProxy", `${path}.buildContainerProxy`) ?? null,
|
|
httpsProxy: optionalStringField(buildContainerProxy, "httpsProxy", `${path}.buildContainerProxy`) ?? null,
|
|
noProxy: stringArrayField(buildContainerProxy, "noProxy", `${path}.buildContainerProxy`),
|
|
},
|
|
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" && sourceMode !== "codex-config" && sourceMode !== "json") throw new Error(`${path}.sourceMode must be env, file, codex-config, or json`);
|
|
const sourceKey = optionalStringField(input, "sourceKey", path) ?? null;
|
|
if (sourceMode === "env" && sourceKey === null) throw new Error(`${path}.sourceKey is required when sourceMode is env`);
|
|
if (sourceMode !== "env" && sourceKey !== null) throw new Error(`${path}.sourceKey is only supported when sourceMode is env`);
|
|
const sourceFormat = optionalStringField(input, "sourceFormat", path) ?? null;
|
|
if (sourceFormat !== null && sourceFormat !== "env" && sourceFormat !== "raw-token") throw new Error(`${path}.sourceFormat must be env or raw-token`);
|
|
if (sourceFormat === "raw-token" && sourceMode !== "env") throw new Error(`${path}.sourceFormat raw-token requires sourceMode env`);
|
|
if (sourceMode !== "env" && sourceFormat !== null) throw new Error(`${path}.sourceFormat is only supported when sourceMode is env`);
|
|
const transform = optionalStringField(input, "transform", path) ?? null;
|
|
if (transform !== null && transform !== "codex-auth-json-openai-api-key") throw new Error(`${path}.transform must be codex-auth-json-openai-api-key when set`);
|
|
if (transform !== null && sourceMode !== "env") throw new Error(`${path}.transform ${transform} requires sourceMode env`);
|
|
const jsonValue = sourceMode === "json" ? jsonValueField(input, "jsonValue", path) : null;
|
|
const providerCredential = parseProviderCredentialBinding(input, `${path}.providerCredential`);
|
|
return {
|
|
id: stringField(input, "id", path),
|
|
sourceRef: secretSourceRefField(input, "sourceRef", path),
|
|
sourceMode,
|
|
sourceKey,
|
|
sourceFormat,
|
|
transform,
|
|
codexConfig: sourceMode === "codex-config" ? parseCodexConfig(recordField(input, "codexConfig", path), `${path}.codexConfig`) : null,
|
|
jsonValue,
|
|
targetRef: parseNamespacedSecretRef(recordField(input, "targetRef", path), `${path}.targetRef`),
|
|
providerCredentialProfile: providerCredential,
|
|
};
|
|
}
|
|
|
|
function parseLaneSecrets(input: Record<string, unknown>, path: string): AgentRunLaneSecretSpec[] {
|
|
const secrets = arrayField(input, "secrets", path).map((secret, index) => parseLaneSecret(secret, `${path}.secrets[${index}]`));
|
|
const idPaths = new Map<string, string>();
|
|
const targetRefPaths = new Map<string, string>();
|
|
for (const [index, secret] of secrets.entries()) {
|
|
const secretPath = `${path}.secrets[${index}]`;
|
|
const previousIdPath = idPaths.get(secret.id);
|
|
if (previousIdPath !== undefined) {
|
|
throw new Error(`${secretPath}.id duplicates ${previousIdPath}.id (${secret.id})`);
|
|
}
|
|
idPaths.set(secret.id, secretPath);
|
|
const targetRef = `${secret.targetRef.namespace}/${secret.targetRef.name}/${secret.targetRef.key}`;
|
|
const previousTargetRefPath = targetRefPaths.get(targetRef);
|
|
if (previousTargetRefPath !== undefined) {
|
|
throw new Error(`${secretPath}.targetRef duplicates ${previousTargetRefPath}.targetRef (${targetRef})`);
|
|
}
|
|
targetRefPaths.set(targetRef, secretPath);
|
|
}
|
|
return secrets;
|
|
}
|
|
|
|
function parseCodexConfig(input: Record<string, unknown>, path: string): AgentRunCodexConfigSpec {
|
|
const providersRaw = recordField(input, "modelProviders", path);
|
|
const modelProviders = Object.entries(providersRaw).map(([id, raw]) => {
|
|
validateSimpleId(id, `${path}.modelProviders`);
|
|
const provider = asRecord(raw, `${path}.modelProviders.${id}`);
|
|
return {
|
|
id,
|
|
name: stringField(provider, "name", `${path}.modelProviders.${id}`),
|
|
baseUrl: stringField(provider, "baseUrl", `${path}.modelProviders.${id}`),
|
|
wireApi: stringField(provider, "wireApi", `${path}.modelProviders.${id}`),
|
|
requiresOpenaiAuth: optionalBooleanField(provider, "requiresOpenaiAuth", `${path}.modelProviders.${id}`),
|
|
};
|
|
});
|
|
if (modelProviders.length === 0) throw new Error(`${path}.modelProviders must not be empty`);
|
|
const features = booleanRecord(input.features, `${path}.features`);
|
|
const projectsRaw = input.projects === undefined || input.projects === null ? {} : asRecord(input.projects, `${path}.projects`);
|
|
const projects = Object.entries(projectsRaw).map(([projectPath, raw]) => {
|
|
const project = asRecord(raw, `${path}.projects.${projectPath}`);
|
|
return { path: projectPath, trustLevel: stringField(project, "trustLevel", `${path}.projects.${projectPath}`) };
|
|
});
|
|
const tuiModelAvailabilityNux = integerRecord(input.tuiModelAvailabilityNux, `${path}.tuiModelAvailabilityNux`);
|
|
return {
|
|
modelProvider: stringField(input, "modelProvider", path),
|
|
model: stringField(input, "model", path),
|
|
reviewModel: optionalStringField(input, "reviewModel", path) ?? null,
|
|
modelReasoningEffort: optionalStringField(input, "modelReasoningEffort", path) ?? null,
|
|
disableResponseStorage: optionalBooleanField(input, "disableResponseStorage", path),
|
|
networkAccess: optionalStringField(input, "networkAccess", path) ?? null,
|
|
windowsWslSetupAcknowledged: optionalBooleanField(input, "windowsWslSetupAcknowledged", path),
|
|
serviceTier: optionalStringField(input, "serviceTier", path) ?? null,
|
|
modelContextWindow: optionalIntegerField(input, "modelContextWindow", path) ?? null,
|
|
modelAutoCompactTokenLimit: optionalIntegerField(input, "modelAutoCompactTokenLimit", path) ?? null,
|
|
modelCatalogJson: optionalStringField(input, "modelCatalogJson", path) ?? null,
|
|
approvalsReviewer: optionalStringField(input, "approvalsReviewer", path) ?? null,
|
|
noticeHideFullAccessWarning: optionalBooleanField(input, "noticeHideFullAccessWarning", path),
|
|
modelProviders,
|
|
features,
|
|
projects,
|
|
tuiModelAvailabilityNux,
|
|
};
|
|
}
|
|
|
|
function jsonValueField(obj: Record<string, unknown>, key: string, path: string): unknown {
|
|
if (!(key in obj)) throw new Error(`${path}.${key} is required when sourceMode is json`);
|
|
const value = obj[key];
|
|
try {
|
|
if (JSON.stringify(value) === undefined) throw new Error("not-json");
|
|
} catch {
|
|
throw new Error(`${path}.${key} must be JSON-serializable`);
|
|
}
|
|
return value;
|
|
}
|
|
|
|
function optionalBooleanField(obj: Record<string, unknown>, key: string, path: string): boolean | null {
|
|
const value = obj[key];
|
|
if (value === undefined || value === null) return null;
|
|
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean when set`);
|
|
return value;
|
|
}
|
|
|
|
function booleanRecord(value: unknown, path: string): Readonly<Record<string, boolean>> {
|
|
if (value === undefined || value === null) return {};
|
|
const raw = asRecord(value, path);
|
|
const result: Record<string, boolean> = {};
|
|
for (const [key, item] of Object.entries(raw)) {
|
|
validateSimpleId(key, path);
|
|
if (typeof item !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
|
|
result[key] = item;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
function integerRecord(value: unknown, path: string): Readonly<Record<string, number>> {
|
|
if (value === undefined || value === null) return {};
|
|
const raw = asRecord(value, path);
|
|
const result: Record<string, number> = {};
|
|
for (const [key, item] of Object.entries(raw)) {
|
|
if (!Number.isInteger(item)) throw new Error(`${path}.${key} must be an integer`);
|
|
result[key] = item;
|
|
}
|
|
return result;
|
|
}
|
|
|
|
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 parseGitMirrorRepositories(input: readonly Record<string, unknown>[], path: string): readonly AgentRunGitMirrorRepositorySpec[] {
|
|
if (input.length === 0) throw new Error(`${path} must declare at least one repository`);
|
|
const repositories = input.map((item, index) => parseGitMirrorRepository(item, `${path}[${index}]`));
|
|
const keys = new Set<string>();
|
|
const names = new Set<string>();
|
|
const remotes = new Set<string>();
|
|
for (const item of repositories) {
|
|
if (keys.has(item.key)) throw new Error(`${path} contains duplicate key ${item.key}`);
|
|
if (names.has(item.repository)) throw new Error(`${path} contains duplicate repository ${item.repository}`);
|
|
if (remotes.has(item.remote)) throw new Error(`${path} contains duplicate remote ownership for ${item.remote}`);
|
|
keys.add(item.key);
|
|
names.add(item.repository);
|
|
remotes.add(item.remote);
|
|
}
|
|
return repositories;
|
|
}
|
|
|
|
function parseGitMirrorRepository(input: Record<string, unknown>, path: string): AgentRunGitMirrorRepositorySpec {
|
|
const key = stringField(input, "key", path);
|
|
const repository = stringField(input, "repository", path);
|
|
const remote = stringField(input, "remote", path);
|
|
const sourceBranch = stringField(input, "sourceBranch", path);
|
|
const gitopsBranch = optionalStringField(input, "gitopsBranch", path);
|
|
validateSimpleId(key, `${path}.key`);
|
|
validateRepositoryPath(repository, `${path}.repository`);
|
|
validateRepositoryRemote(remote, repository, `${path}.remote`);
|
|
validateGitRefName(sourceBranch, `${path}.sourceBranch`);
|
|
if (gitopsBranch !== undefined) validateGitRefName(gitopsBranch, `${path}.gitopsBranch`);
|
|
return {
|
|
key,
|
|
repository,
|
|
remote,
|
|
sourceBranch,
|
|
...(gitopsBranch === undefined ? {} : { gitopsBranch }),
|
|
};
|
|
}
|
|
|
|
function parseManagedRepositoryReconciler(input: Record<string, unknown>, path: string): AgentRunManagedRepositoryReconcilerSpec {
|
|
const retry = recordField(input, "retry", path);
|
|
const freshness = recordField(input, "freshness", path);
|
|
const readiness = recordField(input, "readiness", path);
|
|
const lifecycle = recordField(input, "lifecycle", path);
|
|
const stateDirectory = relativePathField(input, "stateDirectory", path);
|
|
if (stateDirectory === "." || stateDirectory.length === 0) throw new Error(`${path}.stateDirectory must name a dedicated relative directory`);
|
|
return {
|
|
enabled: booleanField(input, "enabled", path),
|
|
deploymentName: kubernetesNameField(input, "deploymentName", path),
|
|
configMapName: kubernetesNameField(input, "configMapName", path),
|
|
serviceAccountName: kubernetesNameField(input, "serviceAccountName", path),
|
|
imagePullPolicy: enumField(input, "imagePullPolicy", path, ["Always", "IfNotPresent", "Never"]),
|
|
cacheMountPath: absolutePathField(input, "cacheMountPath", path),
|
|
stateDirectory,
|
|
reconcileIntervalMs: positiveIntegerField(input, "reconcileIntervalMs", path),
|
|
fetchTimeoutMs: positiveIntegerField(input, "fetchTimeoutMs", path),
|
|
shutdownGraceMs: positiveIntegerField(input, "shutdownGraceMs", path),
|
|
maxConcurrentRepositories: positiveIntegerField(input, "maxConcurrentRepositories", path),
|
|
retry: {
|
|
maxAttempts: positiveIntegerField(retry, "maxAttempts", `${path}.retry`),
|
|
initialDelayMs: positiveIntegerField(retry, "initialDelayMs", `${path}.retry`),
|
|
maxDelayMs: positiveIntegerField(retry, "maxDelayMs", `${path}.retry`),
|
|
},
|
|
freshness: {
|
|
maxAgeMs: positiveIntegerField(freshness, "maxAgeMs", `${path}.freshness`),
|
|
},
|
|
readiness: {
|
|
initialDelaySeconds: positiveIntegerField(readiness, "initialDelaySeconds", `${path}.readiness`),
|
|
periodSeconds: positiveIntegerField(readiness, "periodSeconds", `${path}.readiness`),
|
|
timeoutSeconds: positiveIntegerField(readiness, "timeoutSeconds", `${path}.readiness`),
|
|
failureThreshold: positiveIntegerField(readiness, "failureThreshold", `${path}.readiness`),
|
|
},
|
|
lifecycle: {
|
|
undeclaredRepositoryPolicy: enumField(lifecycle, "undeclaredRepositoryPolicy", `${path}.lifecycle`, ["retain"]),
|
|
managedRefs: enumField(lifecycle, "managedRefs", `${path}.lifecycle`, ["source-branch-only"]),
|
|
},
|
|
resources: parseContainerResources(recordField(input, "resources", path), `${path}.resources`),
|
|
};
|
|
}
|
|
|
|
function validateRepositoryPath(value: string, path: string): void {
|
|
if (!/^[A-Za-z0-9._-]+\/[A-Za-z0-9._-]+$/u.test(value) || value.includes("..")) {
|
|
throw new Error(`${path} must be an owner/name repository path without ..`);
|
|
}
|
|
}
|
|
|
|
function validateRepositoryRemote(value: string, repository: string, path: string): void {
|
|
try {
|
|
const remote = new URL(value);
|
|
const remoteRepository = decodeURIComponent(remote.pathname).replace(/^\/+|\.git$/gu, "");
|
|
if (remote.protocol !== "ssh:" || remote.username.length === 0 || remote.password.length > 0 || remote.search.length > 0 || remote.hash.length > 0) {
|
|
throw new Error("unsupported remote");
|
|
}
|
|
if (remoteRepository !== repository) throw new Error("repository mismatch");
|
|
} catch {
|
|
throw new Error(`${path} must be an ssh URL owned by the same repository entry (${repository})`);
|
|
}
|
|
}
|
|
|
|
function validateGitRefName(value: string, path: string): void {
|
|
if (
|
|
value.length === 0
|
|
|| value.startsWith("-")
|
|
|| value.startsWith("/")
|
|
|| value.endsWith("/")
|
|
|| value.endsWith(".")
|
|
|| value.includes("..")
|
|
|| value.includes("@{")
|
|
|| /[\u0000-\u0020~^:?*[\\]/u.test(value)
|
|
) {
|
|
throw new Error(`${path} must be a safe Git branch name`);
|
|
}
|
|
}
|
|
|
|
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 kubernetesNameField(obj: Record<string, unknown>, key: string, path: string): string {
|
|
const value = stringField(obj, key, path);
|
|
validateKubernetesNamePrefix(value, `${path}.${key}`);
|
|
if (value.length > 63) throw new Error(`${path}.${key} must be at most 63 characters`);
|
|
return value;
|
|
}
|
|
|
|
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`);
|
|
}
|