fix: add AgentRun runner retention cleanup
This commit is contained in:
@@ -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`);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user