fix: add AgentRun runner retention cleanup

This commit is contained in:
Codex
2026-06-18 14:26:43 +00:00
parent dc19a07478
commit 424f7fe492
5 changed files with 607 additions and 2 deletions
+72
View File
@@ -106,6 +106,7 @@ export interface AgentRunLaneSpec {
readonly apiKeySecretRef: { readonly name: string; readonly key: string };
readonly egressProxyUrl: string | null;
readonly noProxyExtra: readonly string[];
readonly retention: AgentRunRunnerRetentionSpec;
};
readonly localPostgres: {
readonly enabled: boolean;
@@ -174,6 +175,20 @@ export interface AgentRunImageBuildSpec {
readonly pollSeconds: number;
}
export interface AgentRunRunnerRetentionSpec {
readonly maxRunners: number;
readonly cleanupOrder: "oldest-inactive-last-active-first";
readonly activeHeartbeatMaxAgeMs: number;
readonly selectors: {
readonly matchLabels: Readonly<Record<string, string>>;
readonly jobNamePrefixes: readonly string[];
};
readonly ageBasedCleanup: {
readonly enabled: boolean;
readonly maxAgeHours: number | null;
};
}
export interface AgentRunLaneTarget {
readonly configPath: string;
readonly spec: AgentRunLaneSpec;
@@ -289,6 +304,7 @@ export function agentRunLaneSummary(spec: AgentRunLaneSpec): Record<string, unkn
apiKeySecretRef: spec.deployment.runner.apiKeySecretRef,
egressProxyUrl: spec.deployment.runner.egressProxyUrl,
noProxyExtra: spec.deployment.runner.noProxyExtra,
retention: spec.deployment.runner.retention,
},
localPostgres: spec.deployment.localPostgres,
},
@@ -525,17 +541,46 @@ function parseDeployment(input: Record<string, unknown>, path: string): AgentRun
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`),
},
localPostgres: parseLocalPostgres(localPostgres, `${path}.localPostgres`),
};
}
function parseRunnerRetention(input: Record<string, unknown>, path: string): AgentRunRunnerRetentionSpec {
const selectors = recordField(input, "selectors", path);
const ageBasedCleanup = recordField(input, "ageBasedCleanup", path);
return {
maxRunners: positiveIntegerField(input, "maxRunners", path),
cleanupOrder: enumField(input, "cleanupOrder", path, ["oldest-inactive-last-active-first"]),
activeHeartbeatMaxAgeMs: positiveIntegerField(input, "activeHeartbeatMaxAgeMs", 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,
},
};
}
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) {
@@ -690,6 +735,19 @@ function stringRecordField(obj: Record<string, unknown>, path: string): Readonly
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(", ")}`);
@@ -738,3 +796,17 @@ function urlField(obj: Record<string, unknown>, key: string, path: string): stri
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 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`);
}