feat: add yaml-first AgentRun lane ops

This commit is contained in:
Codex
2026-06-13 04:21:57 +00:00
parent 58d4b6c76f
commit 687310d83c
4 changed files with 928 additions and 4 deletions
+393
View File
@@ -0,0 +1,393 @@
import { readFileSync } from "node:fs";
import { rootPath } from "./config";
export const AGENTRUN_CONFIG_PATH = "config/agentrun.yaml";
export interface AgentRunGitMirrorRepositorySpec {
readonly key: string;
readonly repository: string;
readonly sourceBranch: string;
readonly gitopsBranch?: string;
}
export interface 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 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 gitMirror: {
readonly namespace: string;
readonly readService: string;
readonly writeService: string;
readonly readUrl: string;
readonly writeUrl: string;
readonly cachePvc: string;
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;
};
}
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,
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,
},
gitMirror: {
namespace: spec.gitMirror.namespace,
readService: spec.gitMirror.readService,
writeService: spec.gitMirror.writeService,
readUrl: spec.gitMirror.readUrl,
writeUrl: spec.gitMirror.writeUrl,
cachePvc: spec.gitMirror.cachePvc,
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,
},
};
}
export function agentRunPipelineRunName(spec: AgentRunLaneSpec, sourceCommit: string): string {
return `${spec.ci.pipelineRunPrefix}-${sourceCommit.slice(0, 12)}`;
}
function readAgentRunControlPlaneConfig(env: NodeJS.ProcessEnv): AgentRunControlPlaneConfig {
const configPath = env.AGENTRUN_CONTROL_PLANE_CONFIG ?? rootPath(AGENTRUN_CONFIG_PATH);
const root = asRecord(Bun.YAML.parse(readFileSync(configPath, "utf8")) as unknown, configPath);
const controlPlane = recordField(root, "controlPlane", configPath);
const defaultTarget = recordField(controlPlane, "default", `${configPath}.controlPlane`);
const nodes = parseNodes(recordField(controlPlane, "nodes", `${configPath}.controlPlane`), configPath);
const lanes = parseLanes(recordField(controlPlane, "lanes", `${configPath}.controlPlane`), nodes, configPath);
return {
sourcePath: configPath,
defaultTarget: {
node: stringField(defaultTarget, "node", `${configPath}.controlPlane.default`),
lane: stringField(defaultTarget, "lane", `${configPath}.controlPlane.default`),
},
nodes,
lanes,
};
}
function parseNodes(input: Record<string, unknown>, configPath: string): Record<string, AgentRunNodeSpec> {
const result: Record<string, AgentRunNodeSpec> = {};
for (const [id, raw] of Object.entries(input)) {
validateSimpleId(id, `${configPath}.controlPlane.nodes`);
const node = asRecord(raw, `${configPath}.controlPlane.nodes.${id}`);
result[id] = {
id,
route: stringField(node, "route", `${configPath}.controlPlane.nodes.${id}`),
kubeRoute: stringField(node, "kubeRoute", `${configPath}.controlPlane.nodes.${id}`),
};
}
if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.nodes must declare at least one node`);
return result;
}
function parseLanes(input: Record<string, unknown>, nodes: Record<string, AgentRunNodeSpec>, configPath: string): Record<string, AgentRunLaneSpec> {
const result: Record<string, AgentRunLaneSpec> = {};
for (const [lane, raw] of Object.entries(input)) {
validateSimpleId(lane, `${configPath}.controlPlane.lanes`);
const item = asRecord(raw, `${configPath}.controlPlane.lanes.${lane}`);
const nodeId = stringField(item, "node", `${configPath}.controlPlane.lanes.${lane}`);
const node = nodes[nodeId];
if (node === undefined) throw new Error(`${configPath}.controlPlane.lanes.${lane}.node references undeclared node ${nodeId}`);
result[lane] = parseLane(lane, node, item, configPath);
}
if (Object.keys(result).length === 0) throw new Error(`${configPath}.controlPlane.lanes must declare at least one lane`);
return result;
}
function parseLane(lane: string, node: AgentRunNodeSpec, input: Record<string, unknown>, configPath: string): AgentRunLaneSpec {
const path = `${configPath}.controlPlane.lanes.${lane}`;
const source = recordField(input, "source", path);
const runtime = recordField(input, "runtime", path);
const ci = recordField(input, "ci", path);
const gitops = recordField(input, "gitops", path);
const 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`),
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`),
},
gitMirror: {
namespace: stringField(gitMirror, "namespace", `${path}.gitMirror`),
readService: stringField(gitMirror, "readService", `${path}.gitMirror`),
writeService: stringField(gitMirror, "writeService", `${path}.gitMirror`),
readUrl: urlField(gitMirror, "readUrl", `${path}.gitMirror`),
writeUrl: urlField(gitMirror, "writeUrl", `${path}.gitMirror`),
cachePvc: stringField(gitMirror, "cachePvc", `${path}.gitMirror`),
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`),
};
}
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 asRecord(value: unknown, path: string): Record<string, unknown> {
if (typeof value !== "object" || value === null || Array.isArray(value)) throw new Error(`${path} must be a YAML object`);
return value as Record<string, unknown>;
}
function recordField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown> {
return asRecord(obj[key], `${path}.${key}`);
}
function stringField(obj: Record<string, unknown>, key: string, path: string): string {
const value = obj[key];
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string`);
return value.trim();
}
function optionalStringField(obj: Record<string, unknown>, key: string, path: string): string | undefined {
const value = obj[key];
if (value === undefined || value === null) return undefined;
if (typeof value !== "string" || value.trim().length === 0) throw new Error(`${path}.${key} must be a non-empty string when set`);
return value.trim();
}
function booleanField(obj: Record<string, unknown>, key: string, path: string): boolean {
const value = obj[key];
if (typeof value !== "boolean") throw new Error(`${path}.${key} must be a boolean`);
return value;
}
function integerField(obj: Record<string, unknown>, key: string, path: string): number {
const value = obj[key];
if (!Number.isInteger(value)) throw new Error(`${path}.${key} must be an integer`);
return Number(value);
}
function arrayField(obj: Record<string, unknown>, key: string, path: string): Record<string, unknown>[] {
const value = obj[key];
if (!Array.isArray(value)) throw new Error(`${path}.${key} must be a YAML array`);
return value.map((item, index) => asRecord(item, `${path}.${key}[${index}]`));
}
function 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 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 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`);
}