Files
pikasTech-agentrun/src/mgr/runner-retention.ts
T

365 lines
16 KiB
TypeScript

import { spawn } from "node:child_process";
import { AgentRunError } from "../common/errors.js";
import { redactJson, redactText } from "../common/redaction.js";
import type { CommandRecord, JsonRecord, JsonValue, RunRecord } from "../common/types.js";
import { stableHash } from "../common/validation.js";
import { isTerminalCommandState, isTerminalRunStatus } from "./store.js";
import type { AgentRunStore } from "./store.js";
export interface RunnerRetentionOptions {
namespace: string;
maxRunners: number;
cleanupOrder: "oldest-inactive-last-active-first";
activeHeartbeatMaxAgeMs: number;
matchLabels: Record<string, string>;
jobNamePrefixes: string[];
ageBasedCleanup: {
enabled: boolean;
maxAgeHours: number | null;
};
kubectlCommand?: string;
}
export interface RunnerRetentionSummary extends JsonRecord {
enabled: boolean;
namespace: string;
maxRunners: number;
incomingRunnerCount: number;
liveRunnerCountBefore: number;
liveRunnerCountAfter: number;
runnerJobCountBefore: number;
nonTerminalRunnerPodCountBefore: number;
orphanNonTerminalRunnerPodCountBefore: number;
inactiveCandidateCount: number;
protectedActiveRunnerCount: number;
selectedRunnerCount: number;
deletedRunnerJobCount: number;
deletedRunnerPodCount: number;
deletedAssociatedResourceCount: number;
activeRunRisk: boolean;
reason: string;
valuesPrinted: false;
}
type CandidateKind = "idle" | "terminal" | "inactive";
interface K8sObject {
kind?: string;
metadata?: {
name?: string;
namespace?: string;
creationTimestamp?: string;
labels?: Record<string, string>;
annotations?: Record<string, string>;
ownerReferences?: Array<{ kind?: string; name?: string }>;
};
status?: JsonRecord;
}
interface RunnerResourceEntry {
resourceKind: "job" | "pod";
name: string;
jobName: string;
runId: string | null;
commandId: string | null;
runnerId: string | null;
createdAtMs: number;
terminal: boolean;
podPhase: string | null;
protectedActive: boolean;
protectedReason: string | null;
candidateKind: CandidateKind;
lastActiveAtMs: number;
}
interface Snapshot {
jobs: RunnerResourceEntry[];
pods: RunnerResourceEntry[];
liveRunnerCount: number;
runnerJobCount: number;
nonTerminalRunnerPodCount: number;
orphanNonTerminalRunnerPodCount: number;
}
export async function enforceRunnerRetentionBeforeCreate(input: { store: AgentRunStore; options: RunnerRetentionOptions; incomingRunnerCount?: number }): Promise<RunnerRetentionSummary> {
const incomingRunnerCount = input.incomingRunnerCount ?? 1;
const before = await runnerSnapshot(input.store, input.options);
const targetBeforeCreate = Math.max(0, input.options.maxRunners - incomingRunnerCount);
const requiredDeleteCount = Math.max(0, before.liveRunnerCount - targetBeforeCreate);
const candidates = [...before.jobs, ...before.pods]
.filter((item) => !item.protectedActive)
.sort(compareCandidates);
const selected = candidates.slice(0, requiredDeleteCount);
const protectedActiveRunnerCount = before.jobs.filter((item) => item.protectedActive).length + before.pods.filter((item) => item.protectedActive).length;
if (requiredDeleteCount > 0 && selected.length < requiredDeleteCount) {
throw new AgentRunError("infra-failed", "runner retention cannot safely make room for a new runner", {
httpStatus: 409,
details: {
reason: "runner-retention-no-safe-candidate",
namespace: input.options.namespace,
maxRunners: input.options.maxRunners,
incomingRunnerCount,
liveRunnerCountBefore: before.liveRunnerCount,
requiredDeleteCount,
inactiveCandidateCount: candidates.length,
protectedActiveRunnerCount,
activeRunRisk: protectedActiveRunnerCount > 0,
valuesPrinted: false,
},
});
}
let deletedRunnerJobCount = 0;
let deletedRunnerPodCount = 0;
let deletedAssociatedResourceCount = 0;
for (const item of selected) {
if (item.resourceKind === "job") {
await kubectlRun(input.options.kubectlCommand ?? "kubectl", ["delete", "job", item.name, "-n", input.options.namespace, "--ignore-not-found=true"]);
deletedRunnerJobCount++;
deletedAssociatedResourceCount += await deleteAssociatedResources(input.options, item.jobName);
} else {
await kubectlRun(input.options.kubectlCommand ?? "kubectl", ["delete", "pod", item.name, "-n", input.options.namespace, "--ignore-not-found=true"]);
deletedRunnerPodCount++;
}
}
const after = selected.length > 0 ? await runnerSnapshot(input.store, input.options) : before;
return {
enabled: true,
namespace: input.options.namespace,
maxRunners: input.options.maxRunners,
incomingRunnerCount,
liveRunnerCountBefore: before.liveRunnerCount,
liveRunnerCountAfter: after.liveRunnerCount,
runnerJobCountBefore: before.runnerJobCount,
nonTerminalRunnerPodCountBefore: before.nonTerminalRunnerPodCount,
orphanNonTerminalRunnerPodCountBefore: before.orphanNonTerminalRunnerPodCount,
inactiveCandidateCount: candidates.length,
protectedActiveRunnerCount,
selectedRunnerCount: selected.length,
deletedRunnerJobCount,
deletedRunnerPodCount,
deletedAssociatedResourceCount,
activeRunRisk: protectedActiveRunnerCount > 0,
reason: requiredDeleteCount === 0 ? "within-limit" : "pre-create-retention",
selected: selected.map((item) => ({ resourceKind: item.resourceKind, name: item.name, jobName: item.jobName, candidateKind: item.candidateKind, protectedActive: false, lastActiveAt: new Date(item.lastActiveAtMs).toISOString(), valuesPrinted: false })),
valuesPrinted: false,
} satisfies RunnerRetentionSummary;
}
async function runnerSnapshot(store: AgentRunStore, options: RunnerRetentionOptions): Promise<Snapshot> {
const [jobObjects, podObjects] = await Promise.all([
kubectlGetList(options, "jobs", labelSelector(options.matchLabels)),
kubectlGetList(options, "pods", labelSelector(options.matchLabels)),
]);
const jobs: RunnerResourceEntry[] = [];
for (const item of jobObjects) {
const name = objectName(item);
if (!name || !matchesJobPrefix(name, options.jobNamePrefixes)) continue;
jobs.push(await jobEntry(store, item, options));
}
const jobNames = new Set(jobs.map((item) => item.jobName));
const pods: RunnerResourceEntry[] = [];
let nonTerminalRunnerPodCount = 0;
let orphanNonTerminalRunnerPodCount = 0;
for (const item of podObjects) {
const name = objectName(item);
if (!name) continue;
const jobName = podJobName(item) ?? name;
if (!matchesJobPrefix(jobName, options.jobNamePrefixes)) continue;
const terminal = podTerminal(item);
if (!terminal) nonTerminalRunnerPodCount++;
if (jobNames.has(jobName)) continue;
if (!terminal) orphanNonTerminalRunnerPodCount++;
pods.push(await podEntry(store, item, options, jobName));
}
return {
jobs,
pods,
liveRunnerCount: jobs.length + pods.filter((item) => !item.terminal).length,
runnerJobCount: jobs.length,
nonTerminalRunnerPodCount,
orphanNonTerminalRunnerPodCount,
};
}
async function jobEntry(store: AgentRunStore, item: K8sObject, options: RunnerRetentionOptions): Promise<RunnerResourceEntry> {
const name = objectName(item) ?? "unknown";
const annotations = item.metadata?.annotations ?? {};
const activity = await activityFor(store, {
runId: annotations["agentrun.pikastech.local/run-id"] ?? null,
commandId: annotations["agentrun.pikastech.local/command-id"] ?? null,
runnerId: annotations["agentrun.pikastech.local/runner-id"] ?? null,
terminal: jobTerminal(item),
createdAtMs: objectCreatedAtMs(item),
activeHeartbeatMaxAgeMs: options.activeHeartbeatMaxAgeMs,
});
return {
resourceKind: "job",
name,
jobName: name,
createdAtMs: objectCreatedAtMs(item),
terminal: jobTerminal(item),
podPhase: null,
...activity,
};
}
async function podEntry(store: AgentRunStore, item: K8sObject, options: RunnerRetentionOptions, jobName: string): Promise<RunnerResourceEntry> {
const name = objectName(item) ?? "unknown";
const annotations = item.metadata?.annotations ?? {};
const activity = await activityFor(store, {
runId: annotations["agentrun.pikastech.local/run-id"] ?? null,
commandId: annotations["agentrun.pikastech.local/command-id"] ?? null,
runnerId: annotations["agentrun.pikastech.local/runner-id"] ?? null,
terminal: podTerminal(item),
createdAtMs: objectCreatedAtMs(item),
activeHeartbeatMaxAgeMs: options.activeHeartbeatMaxAgeMs,
});
return {
resourceKind: "pod",
name,
jobName,
createdAtMs: objectCreatedAtMs(item),
terminal: podTerminal(item),
podPhase: stringPath(item, ["status", "phase"]),
...activity,
};
}
async function activityFor(store: AgentRunStore, input: { runId: string | null; commandId: string | null; runnerId: string | null; terminal: boolean; createdAtMs: number; activeHeartbeatMaxAgeMs: number }): Promise<Pick<RunnerResourceEntry, "runId" | "commandId" | "runnerId" | "protectedActive" | "protectedReason" | "candidateKind" | "lastActiveAtMs">> {
let run: RunRecord | null = null;
let command: CommandRecord | null = null;
if (input.runId) {
try { run = await store.getRun(input.runId); } catch { run = null; }
}
if (input.commandId) {
try { command = await store.getCommand(input.commandId); } catch { command = null; }
}
const lastActiveAtMs = Math.max(input.createdAtMs, isoMs(run?.updatedAt), isoMs(command?.updatedAt), isoMs(run?.leaseExpiresAt));
const active = run && !isTerminalRunStatus(run.status) && command && !isTerminalCommandState(command.state) && input.runnerId && run.claimedBy === input.runnerId && heartbeatFresh(run.leaseExpiresAt, input.activeHeartbeatMaxAgeMs);
if (active) {
return { runId: input.runId, commandId: input.commandId, runnerId: input.runnerId, protectedActive: true, protectedReason: "fresh-active-run", candidateKind: "inactive", lastActiveAtMs };
}
const candidateKind: CandidateKind = command && isTerminalCommandState(command.state) && !input.terminal ? "idle" : input.terminal || (run !== null && isTerminalRunStatus(run.status)) ? "terminal" : "inactive";
return { runId: input.runId, commandId: input.commandId, runnerId: input.runnerId, protectedActive: false, protectedReason: null, candidateKind, lastActiveAtMs };
}
async function kubectlGetList(options: RunnerRetentionOptions, resource: string, selector: string): Promise<K8sObject[]> {
const args = ["get", resource, "-n", options.namespace];
if (selector) args.push("-l", selector);
args.push("-o", "json");
const result = await kubectlRun(options.kubectlCommand ?? "kubectl", args);
if (result.code !== 0) throw new AgentRunError("infra-failed", `kubectl get ${resource} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)), valuesPrinted: false }) });
const parsed = parseJsonObject(result.stdout, `kubectl get ${resource}`);
const items = parsed.items;
return Array.isArray(items) ? items.filter((entry) => typeof entry === "object" && entry !== null && !Array.isArray(entry)).map((entry) => entry as unknown as K8sObject) : [];
}
async function deleteAssociatedResources(options: RunnerRetentionOptions, jobName: string): Promise<number> {
let deleted = 0;
const selector = `agentrun.pikastech.local/runner-job=${jobName}`;
for (const resource of ["secret", "pvc"] as const) {
const result = await kubectlRun(options.kubectlCommand ?? "kubectl", ["delete", resource, "-n", options.namespace, "-l", selector, "--ignore-not-found=true"]);
if (result.code !== 0) throw new AgentRunError("infra-failed", `kubectl delete associated ${resource} failed with code ${result.code}`, { httpStatus: 502, details: redactJson({ stderr: redactText(result.stderr.slice(-2000)), stdout: redactText(result.stdout.slice(-1000)), valuesPrinted: false }) });
deleted += countDeletedLines(result.stdout);
}
return deleted;
}
async function kubectlRun(kubectlCommand: string, args: string[]): Promise<{ code: number | null; signal: NodeJS.Signals | null; stdout: string; stderr: string }> {
const child = spawn(kubectlCommand, args, { stdio: ["ignore", "pipe", "pipe"] });
let stdout = "";
let stderr = "";
child.stdout.setEncoding("utf8");
child.stderr.setEncoding("utf8");
child.stdout.on("data", (chunk) => { stdout += String(chunk); });
child.stderr.on("data", (chunk) => { stderr += String(chunk); });
const result = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => {
child.on("error", reject);
child.on("close", (code, signal) => resolve({ code, signal }));
}).catch((error: unknown) => {
throw new AgentRunError("infra-failed", `failed to start kubectl: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 503 });
});
return { ...result, stdout, stderr };
}
function compareCandidates(left: RunnerResourceEntry, right: RunnerResourceEntry): number {
const priority = (kind: CandidateKind): number => kind === "idle" ? 0 : kind === "terminal" ? 1 : 2;
const byKind = priority(left.candidateKind) - priority(right.candidateKind);
if (byKind !== 0) return byKind;
const byActive = left.lastActiveAtMs - right.lastActiveAtMs;
if (byActive !== 0) return byActive;
return left.name.localeCompare(right.name);
}
function labelSelector(labels: Record<string, string>): string {
return Object.entries(labels).map(([key, value]) => `${key}=${value}`).join(",");
}
function matchesJobPrefix(name: string, prefixes: string[]): boolean {
return prefixes.length === 0 || prefixes.some((prefix) => name === prefix || name.startsWith(`${prefix}-`));
}
function podJobName(item: K8sObject): string | null {
const labels = item.metadata?.labels ?? {};
if (labels["job-name"]) return labels["job-name"];
return item.metadata?.ownerReferences?.find((owner) => owner.kind === "Job")?.name ?? null;
}
function jobTerminal(item: K8sObject): boolean {
const conditions = item.status?.conditions;
if (!Array.isArray(conditions)) return false;
return conditions.some((condition) => {
const record = condition as JsonRecord;
return (record.type === "Complete" || record.type === "Failed") && record.status === "True";
});
}
function podTerminal(item: K8sObject): boolean {
const phase = stringPath(item, ["status", "phase"]);
return phase === "Succeeded" || phase === "Failed";
}
function heartbeatFresh(leaseExpiresAt: string | null, activeHeartbeatMaxAgeMs: number): boolean {
const leaseMs = isoMs(leaseExpiresAt);
if (leaseMs <= 0) return false;
return leaseMs + activeHeartbeatMaxAgeMs >= Date.now();
}
function objectName(item: K8sObject): string | null {
return typeof item.metadata?.name === "string" && item.metadata.name.length > 0 ? item.metadata.name : null;
}
function objectCreatedAtMs(item: K8sObject): number {
return isoMs(item.metadata?.creationTimestamp) || 0;
}
function isoMs(value: string | null | undefined): number {
if (!value) return 0;
const parsed = Date.parse(value);
return Number.isFinite(parsed) ? parsed : 0;
}
function stringPath(value: unknown, path: string[]): string | null {
let current: unknown = value;
for (const key of path) {
if (typeof current !== "object" || current === null || Array.isArray(current)) return null;
current = (current as Record<string, unknown>)[key];
}
return typeof current === "string" ? current : null;
}
function parseJsonObject(text: string, label: string): JsonRecord {
try {
const parsed = JSON.parse(text) as JsonValue;
if (typeof parsed === "object" && parsed !== null && !Array.isArray(parsed)) return parsed;
} catch (error) {
throw new AgentRunError("infra-failed", `${label} returned invalid JSON: ${error instanceof Error ? error.message : String(error)}`, { httpStatus: 502, details: { stdoutHash: stableHash(text), valuesPrinted: false } });
}
throw new AgentRunError("infra-failed", `${label} returned non-object JSON`, { httpStatus: 502, details: { stdoutHash: stableHash(text), valuesPrinted: false } });
}
function countDeletedLines(stdout: string): number {
return stdout.split(/\r?\n/u).filter((line) => /\sdeleted$/u.test(line.trim())).length;
}