feat: add yaml-first AgentRun lane ops
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
Reference in New Issue
Block a user