Skip to content

Commit 7e67700

Browse files
authored
feat(supervisor): per-org placement overrides for run pods (#4655)
The supervisor now supports routing an organization's runs to specific nodes. `KUBERNETES_ORG_PLACEMENT_OVERRIDES` takes JSON keyed by the internal org ID, adding node selector entries and tolerations to that org's run pods, e.g. to route an org onto a dedicated, tainted node pool: ```json {"<orgId>": {"nodeSelector": {"pool": "dedicated"}, "tolerations": "dedicated=runs:NoSchedule"}} ``` The node selector merges over the defaults (the override wins on key collision, with a warning logged). Tolerations append to the existing runner and scheduled-run sets. Overrides are validated at startup similar to `KUBERNETES_RUNNER_TOLERATIONS`. Exposed in the Helm chart as `supervisor.config.kubernetes.orgPlacementOverrides`, where tolerations can also be given as a list.
1 parent 53ca44d commit 7e67700

10 files changed

Lines changed: 370 additions & 9 deletions

File tree

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
area: supervisor
3+
type: feature
4+
---
5+
6+
Operators can now route an organization's runs to specific Kubernetes node pools.

apps/supervisor/src/env.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,13 @@
11
import { randomUUID } from "crypto";
22
import { env as stdEnv } from "std-env";
33
import { z } from "zod";
4-
import { AdditionalEnvVars, BoolEnv, NodeLabelValue, Tolerations } from "./envUtil.js";
4+
import {
5+
AdditionalEnvVars,
6+
BoolEnv,
7+
NodeLabelValue,
8+
OrgPlacementOverrides,
9+
Tolerations,
10+
} from "./envUtil.js";
511

612
export const Env = z
713
.object({
@@ -260,6 +266,11 @@ export const Env = z
260266
KUBERNETES_RUNNER_TOLERATIONS: Tolerations.optional(), // every run pod
261267
KUBERNETES_SCHEDULED_RUN_TOLERATIONS: Tolerations.optional(), // schedule-tree runs only
262268

269+
// Per-org placement overrides, JSON keyed by the internal org id
270+
// (the `org` label on run pods):
271+
// {"<orgId>": {"nodeSelector": {"<key>": "<value>"}, "tolerations": "<csv or array>"}}
272+
KUBERNETES_ORG_PLACEMENT_OVERRIDES: OrgPlacementOverrides,
273+
263274
// Placement tags settings
264275
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
265276
PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"),
@@ -305,6 +316,22 @@ export const Env = z
305316
path: ["TRIGGER_DEQUEUE_BACKPRESSURE_POD_COUNT_RELEASE"],
306317
});
307318
}
319+
if (data.KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED && data.KUBERNETES_ORG_PLACEMENT_OVERRIDES) {
320+
// Non-large presets carry a hard NotIn on the large-machine pool, so an org
321+
// pinned to that pool could never schedule its non-large runs.
322+
for (const [orgId, override] of Object.entries(data.KUBERNETES_ORG_PLACEMENT_OVERRIDES)) {
323+
const pinnedPool =
324+
override.nodeSelector?.[data.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_KEY];
325+
326+
if (pinnedPool === data.KUBERNETES_LARGE_MACHINE_AFFINITY_POOL_LABEL_VALUE) {
327+
ctx.addIssue({
328+
code: z.ZodIssueCode.custom,
329+
message: `Org "${orgId}" pins run pods to the large-machine pool, but non-large presets are required to stay off it, so those runs would never schedule. Use a different pool or disable KUBERNETES_LARGE_MACHINE_AFFINITY_ENABLED.`,
330+
path: ["KUBERNETES_ORG_PLACEMENT_OVERRIDES"],
331+
});
332+
}
333+
}
334+
}
308335
if (data.COMPUTE_SNAPSHOTS_ENABLED && !data.TRIGGER_METADATA_URL) {
309336
ctx.addIssue({
310337
code: z.ZodIssueCode.custom,

apps/supervisor/src/envUtil.test.ts

Lines changed: 129 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,11 @@
11
import { describe, it, expect } from "vitest";
2-
import { BoolEnv, AdditionalEnvVars, NodeLabelValue, Tolerations } from "./envUtil.js";
2+
import {
3+
BoolEnv,
4+
AdditionalEnvVars,
5+
NodeLabelValue,
6+
OrgPlacementOverrides,
7+
Tolerations,
8+
} from "./envUtil.js";
39

410
describe("BoolEnv", () => {
511
it("should parse string 'true' as true", () => {
@@ -203,3 +209,125 @@ describe("Tolerations", () => {
203209
expect(Tolerations.safeParse("dedicated=runs:NoSchedule:NoExecute").success).toBe(false);
204210
});
205211
});
212+
213+
describe("OrgPlacementOverrides", () => {
214+
it("should parse a full override with nodeSelector and tolerations", () => {
215+
expect(
216+
OrgPlacementOverrides.parse(
217+
JSON.stringify({
218+
org_123: {
219+
nodeSelector: { "node.cluster.x-k8s.io/machinepool": "dedicated-pool" },
220+
tolerations: "dedicated=pool:NoSchedule",
221+
},
222+
})
223+
)
224+
).toEqual({
225+
org_123: {
226+
nodeSelector: { "node.cluster.x-k8s.io/machinepool": "dedicated-pool" },
227+
tolerations: [{ key: "dedicated", operator: "Equal", value: "pool", effect: "NoSchedule" }],
228+
},
229+
});
230+
});
231+
232+
it("should allow either half to be omitted", () => {
233+
expect(
234+
OrgPlacementOverrides.parse(JSON.stringify({ org_123: { nodeSelector: { pool: "a" } } }))
235+
).toEqual({ org_123: { nodeSelector: { pool: "a" } } });
236+
237+
expect(
238+
OrgPlacementOverrides.parse(JSON.stringify({ org_123: { tolerations: "spot:NoExecute" } }))
239+
).toEqual({
240+
org_123: { tolerations: [{ key: "spot", operator: "Exists", effect: "NoExecute" }] },
241+
});
242+
243+
expect(OrgPlacementOverrides.parse(JSON.stringify({ org_123: {} }))).toEqual({ org_123: {} });
244+
});
245+
246+
it("should reject invalid JSON at startup rather than silently skipping the override", () => {
247+
for (const invalid of ["not json", "[]", '"org_123"', "{"]) {
248+
expect(OrgPlacementOverrides.safeParse(invalid).success).toBe(false);
249+
}
250+
});
251+
252+
it("should treat a blank or missing value as no overrides, like the sibling settings", () => {
253+
expect(OrgPlacementOverrides.parse(undefined)).toBeUndefined();
254+
expect(OrgPlacementOverrides.parse("")).toBeUndefined();
255+
expect(OrgPlacementOverrides.parse(" ")).toBeUndefined();
256+
});
257+
258+
it("should accept tolerations as an array of entries, matching the Helm list shape", () => {
259+
expect(
260+
OrgPlacementOverrides.parse(
261+
JSON.stringify({
262+
org_123: { tolerations: ["dedicated=pool:NoSchedule", "spot:NoExecute"] },
263+
})
264+
)
265+
).toEqual({
266+
org_123: {
267+
tolerations: [
268+
{ key: "dedicated", operator: "Equal", value: "pool", effect: "NoSchedule" },
269+
{ key: "spot", operator: "Exists", effect: "NoExecute" },
270+
],
271+
},
272+
});
273+
});
274+
275+
it("should coerce scalar node selector values to strings, as Kubernetes labels are", () => {
276+
expect(
277+
OrgPlacementOverrides.parse(
278+
JSON.stringify({ org_123: { nodeSelector: { paid: true, replicas: 3 } } })
279+
)
280+
).toEqual({ org_123: { nodeSelector: { paid: "true", replicas: "3" } } });
281+
});
282+
283+
it("should trim whitespace around node selector keys and values", () => {
284+
expect(
285+
OrgPlacementOverrides.parse(
286+
JSON.stringify({ org_123: { nodeSelector: { " pool ": " a " } } })
287+
)
288+
).toEqual({ org_123: { nodeSelector: { pool: "a" } } });
289+
});
290+
291+
it("should reject blank or padded org keys, since the lookup is exact", () => {
292+
for (const key of [" ", " org_123", "org_123 "]) {
293+
expect(OrgPlacementOverrides.safeParse(JSON.stringify({ [key]: {} })).success).toBe(false);
294+
}
295+
});
296+
297+
it("should reject an empty node selector value instead of pinning the org to nothing", () => {
298+
for (const value of ["", " "]) {
299+
expect(
300+
OrgPlacementOverrides.safeParse(
301+
JSON.stringify({ org_123: { nodeSelector: { pool: value } } })
302+
).success
303+
).toBe(false);
304+
}
305+
});
306+
307+
it("should reject an unknown field, so a typo cannot silently drop an override", () => {
308+
expect(
309+
OrgPlacementOverrides.safeParse(
310+
JSON.stringify({ org_123: { toleration: "dedicated=pool:NoSchedule" } })
311+
).success
312+
).toBe(false);
313+
});
314+
315+
it("should reject a node selector key or value Kubernetes would reject", () => {
316+
for (const invalid of [
317+
{ org_123: { nodeSelector: { "bad key": "a" } } },
318+
{ org_123: { nodeSelector: { pool: "bad value" } } },
319+
{ org_123: { nodeSelector: { "a/b/c": "a" } } },
320+
{ org_123: { nodeSelector: { pool: "v".repeat(64) } } },
321+
]) {
322+
expect(OrgPlacementOverrides.safeParse(JSON.stringify(invalid)).success).toBe(false);
323+
}
324+
});
325+
326+
it("should reject an invalid toleration inside an override", () => {
327+
expect(
328+
OrgPlacementOverrides.safeParse(
329+
JSON.stringify({ org_123: { tolerations: "dedicated=pool:Nope" } })
330+
).success
331+
).toBe(false);
332+
});
333+
});

apps/supervisor/src/envUtil.ts

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,6 +146,102 @@ export const Tolerations = z.string().transform((val, ctx) => {
146146
});
147147
});
148148

149+
/**
150+
* Scalar values are coerced: YAML/JSON easily produce `true` or `3` where a label
151+
* value is meant, and Kubernetes label values are always strings. An empty value
152+
* is rejected rather than passed through - as a selector it matches only nodes
153+
* carrying a literal empty-valued label, which pins the org to nothing.
154+
*/
155+
const NodeSelector = z
156+
.record(z.string(), z.union([z.string(), z.number(), z.boolean()]))
157+
.transform((selector, ctx) => {
158+
const result: Record<string, string> = {};
159+
160+
for (const [rawKey, rawValue] of Object.entries(selector)) {
161+
const key = rawKey.trim();
162+
const value = String(rawValue).trim();
163+
164+
if (!isQualifiedName(key)) {
165+
ctx.addIssue({
166+
code: z.ZodIssueCode.custom,
167+
message: `Invalid node selector key "${rawKey}". Must be a Kubernetes label key, optionally prefixed with a DNS subdomain.`,
168+
});
169+
continue;
170+
}
171+
172+
if (!value) {
173+
ctx.addIssue({
174+
code: z.ZodIssueCode.custom,
175+
message: `Empty node selector value for key "${key}". Remove the key instead of blanking the value.`,
176+
});
177+
continue;
178+
}
179+
180+
if (!isLabelValue(value)) {
181+
ctx.addIssue({
182+
code: z.ZodIssueCode.custom,
183+
message: `Invalid node selector value "${value}" for key "${key}". Must be a Kubernetes label value: alphanumeric, with dashes, underscores and dots inside, at most 63 characters.`,
184+
});
185+
continue;
186+
}
187+
188+
result[key] = value;
189+
}
190+
191+
return result;
192+
});
193+
194+
/**
195+
* Per-organization placement overrides for run pods, as JSON keyed by the
196+
* internal org id (the `org` label on run pods):
197+
* `{"<orgId>": {"nodeSelector": {"<key>": "<value>"}, "tolerations": "<csv>"}}`.
198+
* Tolerations use the same CSV format as `Tolerations`, or an array of such
199+
* entries. Everything is validated at startup for the same reason as
200+
* tolerations above: a typo would otherwise reject every pod create for that
201+
* org, with the cause buried in API errors. A blank value means no overrides.
202+
*/
203+
export const OrgPlacementOverrides = z
204+
.string()
205+
.optional()
206+
.transform((val, ctx) => {
207+
if (val === undefined || val.trim() === "") {
208+
return undefined;
209+
}
210+
211+
try {
212+
return JSON.parse(val) as unknown;
213+
} catch {
214+
ctx.addIssue({
215+
code: z.ZodIssueCode.custom,
216+
message: "Invalid org placement overrides: not valid JSON",
217+
});
218+
return z.NEVER;
219+
}
220+
})
221+
.pipe(
222+
z
223+
.record(
224+
z
225+
.string()
226+
.min(1)
227+
.refine((key) => key === key.trim() && key.trim().length > 0, {
228+
message:
229+
"Org override keys must not be blank or padded with whitespace; the lookup is exact",
230+
}),
231+
z
232+
.object({
233+
nodeSelector: NodeSelector.optional(),
234+
tolerations: z
235+
.union([z.string(), z.array(z.string())])
236+
.transform((val) => (Array.isArray(val) ? val.join(",") : val))
237+
.pipe(Tolerations)
238+
.optional(),
239+
})
240+
.strict()
241+
)
242+
.optional()
243+
);
244+
149245
export const AdditionalEnvVars = z.preprocess((val) => {
150246
if (typeof val !== "string") {
151247
return val;

apps/supervisor/src/workloadManager/kubernetes.test.ts

Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
nodetypeNodeSelector,
55
runPodTolerations,
66
withBlockIoUringSeccompProfile,
7+
withNodeSelector,
78
} from "./kubernetesPodSpec.js";
89

910
const basePodSpec = {
@@ -54,6 +55,49 @@ describe("runPodTolerations", () => {
5455
expect(runPodTolerations(worker, [], true)).toEqual(worker);
5556
expect(runPodTolerations(worker, scheduled, true)).toEqual([...worker, ...scheduled]);
5657
});
58+
59+
it("appends the org tolerations regardless of run type", () => {
60+
const org = [{ key: "dedicated", operator: "Equal", value: "org-pool", effect: "NoSchedule" }];
61+
62+
expect(runPodTolerations(undefined, undefined, false, org)).toEqual(org);
63+
expect(runPodTolerations(worker, undefined, false, org)).toEqual([...worker, ...org]);
64+
expect(runPodTolerations(worker, scheduled, true, org)).toEqual([
65+
...worker,
66+
...scheduled,
67+
...org,
68+
]);
69+
expect(runPodTolerations(undefined, undefined, false, [])).toBeUndefined();
70+
});
71+
});
72+
73+
describe("withNodeSelector", () => {
74+
const podSpec = { ...basePodSpec, nodeSelector: { nodetype: "v4-worker", paid: "true" } };
75+
76+
it("returns the pod spec untouched when there is nothing to merge", () => {
77+
expect(withNodeSelector(podSpec, undefined)).toBe(podSpec);
78+
expect(withNodeSelector(podSpec, {})).toBe(podSpec);
79+
});
80+
81+
it("merges extra entries with existing ones", () => {
82+
expect(withNodeSelector(podSpec, { machinepool: "dedicated-pool" })).toEqual({
83+
...podSpec,
84+
nodeSelector: { nodetype: "v4-worker", paid: "true", machinepool: "dedicated-pool" },
85+
});
86+
});
87+
88+
it("lets the extra entries win on key collision", () => {
89+
expect(withNodeSelector(podSpec, { nodetype: "other" }).nodeSelector).toEqual({
90+
nodetype: "other",
91+
paid: "true",
92+
});
93+
});
94+
95+
it("adds a nodeSelector to a spec that had none", () => {
96+
expect(withNodeSelector(basePodSpec, { machinepool: "dedicated-pool" })).toEqual({
97+
...basePodSpec,
98+
nodeSelector: { machinepool: "dedicated-pool" },
99+
});
100+
});
57101
});
58102

59103
describe("withBlockIoUringSeccompProfile", () => {

0 commit comments

Comments
 (0)