Skip to content

Commit 73d611b

Browse files
1stvampTrigger.dev RepoOps
authored andcommitted
feat(supervisor): optionally create a Runner k8s custom resource instead of building a pod
Adds an alternative Kubernetes workload manager behind a new `KUBERNETES_RUN_CRD_ENABLED` setting, off by default. Instead of building a run pod directly, it creates a `Runner` object in the `compute.trigger.dev/v1alpha1` API group and stops, leaving pod construction to the operator that reconciles that object. This keeps the reasoning about uid pinning, node selection and the label set a network policy selects on in one place instead of copying it a third time. The Helm chart exposes it as `supervisor.config.kubernetes.runCrdEnabled`, also off by default, which sets that variable and grants the supervisor's Role the two writes the backend needs: creating the `Runner`, and creating and patching the Secret that carries the deployment token. Turning it on requires the `Runner` CRD and a controller reconciling it to be installed, or the create fails. The existing pod-building path is unchanged and remains the default. The two are mutually exclusive by construction: whichever one runs is the only thing that creates a workload for a cold start. Mono-RevId: 0665f61da14cbc8ec45c7fb75b013cf45f93fdc8
1 parent d5f0d7d commit 73d611b

11 files changed

Lines changed: 711 additions & 5 deletions

File tree

‎apps/supervisor/src/clients/kubernetes.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@ export function createK8sApi() {
2323
core: kubeConfig.makeApiClient(k8s.CoreV1Api),
2424
batch: kubeConfig.makeApiClient(k8s.BatchV1Api),
2525
apps: kubeConfig.makeApiClient(k8s.AppsV1Api),
26+
custom: kubeConfig.makeApiClient(k8s.CustomObjectsApi),
27+
// Patching needs a content type the generated clients do not offer.
28+
objects: k8s.KubernetesObjectApi.makeApiClient(kubeConfig),
2629
makeInformer,
2730
};
2831

‎apps/supervisor/src/env.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,10 @@ export const Env = z
179179

180180
// Kubernetes settings
181181
KUBERNETES_FORCE_ENABLED: BoolEnv.default(false),
182+
// Create a Runner and let the operator build the pod, instead of building
183+
// the pod here. The two are mutually exclusive by construction: whichever
184+
// one runs is the only thing that creates a workload for a cold start.
185+
KUBERNETES_RUN_CRD_ENABLED: BoolEnv.default(false),
182186
KUBERNETES_NAMESPACE: z.string().default("default"),
183187
KUBERNETES_WORKER_NODETYPE_LABEL: NodeLabelValue.default("v4-worker"),
184188
KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv

‎apps/supervisor/src/index.ts‎

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
type ResourceMonitor,
1515
} from "./resourceMonitor.js";
1616
import { KubernetesWorkloadManager } from "./workloadManager/kubernetes.js";
17+
import { RunCrdWorkloadManager } from "./workloadManager/runCrd.js";
1718
import { DockerWorkloadManager } from "./workloadManager/docker.js";
1819
import { ComputeWorkloadManager } from "./workloadManager/compute.js";
1920
import {
@@ -80,7 +81,7 @@ class ManagedSupervisor {
8081
private readonly metricsServer?: HttpServer;
8182
private readonly workloadServer: WorkloadServer;
8283
private readonly workloadManager: WorkloadManager;
83-
private readonly workloadManagerBackend: "compute" | "kubernetes" | "docker";
84+
private readonly workloadManagerBackend: "compute" | "kubernetes" | "run-crd" | "docker";
8485
private readonly computeManager?: ComputeWorkloadManager;
8586
private readonly logger = new SimpleStructuredLogger("managed-supervisor");
8687
private readonly resourceMonitor: ResourceMonitor;
@@ -189,6 +190,12 @@ class ManagedSupervisor {
189190
this.computeManager = computeManager;
190191
this.workloadManager = computeManager;
191192
this.workloadManagerBackend = "compute";
193+
} else if (this.isKubernetes && env.KUBERNETES_RUN_CRD_ENABLED) {
194+
this.workloadManager = new RunCrdWorkloadManager({
195+
...workloadManagerOptions,
196+
namespace: env.KUBERNETES_NAMESPACE,
197+
});
198+
this.workloadManagerBackend = "run-crd";
192199
} else if (this.isKubernetes) {
193200
this.workloadManager = new KubernetesWorkloadManager(workloadManagerOptions);
194201
this.workloadManagerBackend = "kubernetes";
@@ -197,7 +204,8 @@ class ManagedSupervisor {
197204
this.workloadManagerBackend = "docker";
198205
}
199206

200-
if (this.isKubernetes) {
207+
// Not run-crd: the operator owns those pods and fails a Runner without one.
208+
if (this.isKubernetes && this.workloadManagerBackend !== "run-crd") {
201209
if (env.POD_CLEANER_ENABLED) {
202210
this.logger.log("🧹 Pod cleaner enabled", {
203211
namespace: env.KUBERNETES_NAMESPACE,
@@ -227,6 +235,8 @@ class ManagedSupervisor {
227235
} else {
228236
this.logger.warn("Failed pod handler disabled");
229237
}
238+
} else if (this.isKubernetes) {
239+
this.logger.log("🧹 Pod cleaner and failed pod handler disabled, the operator owns run pods");
230240
}
231241

232242
if (env.TRIGGER_DEQUEUE_INTERVAL_MS > env.TRIGGER_DEQUEUE_IDLE_INTERVAL_MS) {

‎apps/supervisor/src/services/failedPodHandler.test.ts‎

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -176,6 +176,43 @@ describe.skipIf(!process.env.K8S_INTEGRATION_TESTS)("FailedPodHandler Integratio
176176
}
177177
}, 30000);
178178

179+
it("should ignore operator-owned pods", async () => {
180+
const handler = new FailedPodHandler({ namespace, k8s, register });
181+
182+
try {
183+
// Operator-owned pods carry app=task-run as well, so only the runner
184+
// label separates them. This handler deletes the pod that carries the
185+
// termination reason, so processing one loses that reason too.
186+
const podNames = await createTestPods({
187+
k8sApi: k8s,
188+
namespace,
189+
count: 1,
190+
shouldFail: true,
191+
labels: { app: "task-run", "compute.trigger.dev/runner": "runner-abc123" },
192+
});
193+
194+
await waitForPodsPhase({
195+
k8sApi: k8s,
196+
namespace,
197+
podNames,
198+
phase: "Failed",
199+
});
200+
201+
await handler.start();
202+
203+
await setTimeout(5000);
204+
205+
const exists = await podExists({ k8sApi: k8s, namespace, podName: podNames[0]! });
206+
expect(exists).toBe(true);
207+
208+
const metrics = handler.getMetrics();
209+
const processedPods = await metrics.processedPodsTotal.get();
210+
expect(processedPods.values).toHaveLength(0);
211+
} finally {
212+
await handler.stop();
213+
}
214+
}, 30000);
215+
179216
it("should not process pods that are being deleted", async () => {
180217
const handler = new FailedPodHandler({ namespace, k8s, register });
181218

‎apps/supervisor/src/services/failedPodHandler.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,12 @@ import type { K8sApi } from "../clients/kubernetes.js";
77
import { createK8sApi } from "../clients/kubernetes.js";
88
import { register } from "../metrics.js";
99

10+
// Operator-owned pods carry app=task-run too, since the network policy selects
11+
// on it, so they have to be excluded by their own label instead. Unconditional:
12+
// a rolling update runs this against pods the next version created. One
13+
// constant because the list and the watch must select the same set.
14+
const RUN_POD_SELECTOR = "app=task-run,!compute.trigger.dev/runner";
15+
1016
type PodStatus = "Pending" | "Running" | "Succeeded" | "Failed" | "Unknown" | "GracefulShutdown";
1117

1218
export type FailedPodHandlerOptions = {
@@ -54,10 +60,10 @@ export class FailedPodHandler {
5460
() =>
5561
this.k8s.core.listNamespacedPod({
5662
namespace: this.namespace,
57-
labelSelector: "app=task-run",
63+
labelSelector: RUN_POD_SELECTOR,
5864
fieldSelector: "status.phase=Failed",
5965
}),
60-
"app=task-run",
66+
RUN_POD_SELECTOR,
6167
"status.phase=Failed"
6268
);
6369

‎apps/supervisor/src/services/podCleaner.test.ts‎

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -232,6 +232,42 @@ describe.skipIf(!process.env.K8S_INTEGRATION_TESTS)("PodCleaner Integration Test
232232
}
233233
}, 30000);
234234

235+
it("should not delete operator-owned pods", async () => {
236+
const podCleaner = new PodCleaner({ namespace, k8s, register });
237+
238+
try {
239+
// Operator-owned pods carry app=task-run as well, so only the runner
240+
// label separates them. Deleting one fails the run it was serving.
241+
const podNames = await createTestPods({
242+
k8sApi: k8s,
243+
namespace,
244+
count: 1,
245+
labels: { app: "task-run", "compute.trigger.dev/runner": "runner-abc123" },
246+
namePrefix: "operator-owned-pod",
247+
});
248+
249+
if (!podNames[0]) {
250+
throw new Error("Failed to create test pod");
251+
}
252+
const podName = podNames[0];
253+
254+
await waitForPodPhase({
255+
k8sApi: k8s,
256+
namespace,
257+
podName,
258+
phase: "Succeeded",
259+
});
260+
261+
await podCleaner.start();
262+
263+
await setTimeout(5000);
264+
265+
expect(await podExists({ k8sApi: k8s, namespace, podName })).toBe(true);
266+
} finally {
267+
await podCleaner.stop();
268+
}
269+
}, 30000);
270+
235271
it("should not delete pods that are still running", async () => {
236272
const podCleaner = new PodCleaner({ namespace, k8s, register });
237273

‎apps/supervisor/src/services/podCleaner.ts‎

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,11 @@ import type { K8sApi } from "../clients/kubernetes.js";
66
import { createK8sApi } from "../clients/kubernetes.js";
77
import { register } from "../metrics.js";
88

9+
// Operator-owned pods carry app=task-run too, since the network policy selects
10+
// on it, so they have to be excluded by their own label instead. Unconditional:
11+
// a rolling update runs this against pods the next version created.
12+
const RUN_POD_SELECTOR = "app=task-run,!compute.trigger.dev/runner";
13+
914
export type PodCleanerOptions = {
1015
namespace: string;
1116
k8s?: K8sApi;
@@ -72,7 +77,7 @@ export class PodCleaner {
7277
try {
7378
const result = await this.k8s.core.deleteCollectionNamespacedPod({
7479
namespace: this.namespace,
75-
labelSelector: "app=task-run",
80+
labelSelector: RUN_POD_SELECTOR,
7681
fieldSelector: "status.phase=Succeeded",
7782
limit: this.batchSize,
7883
_continue: continuationToken,

0 commit comments

Comments
 (0)