Skip to content

Commit dfb1e42

Browse files
committed
feat(supervisor): per-org placement overrides for run pods
KUBERNETES_ORG_PLACEMENT_OVERRIDES takes JSON keyed by org id, adding node selector entries and tolerations to that org's run pods so an operator can pin an org onto a dedicated node pool. Validated at startup so a typo fails fast instead of rejecting every pod create.
1 parent 99f0787 commit dfb1e42

6 files changed

Lines changed: 221 additions & 9 deletions

File tree

apps/supervisor/src/env.ts

Lines changed: 11 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,10 @@ 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 org id:
270+
// {"<orgId>": {"nodeSelector": {"<key>": "<value>"}, "tolerations": "<csv>"}}
271+
KUBERNETES_ORG_PLACEMENT_OVERRIDES: OrgPlacementOverrides.optional(),
272+
263273
// Placement tags settings
264274
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),
265275
PLACEMENT_TAGS_PREFIX: z.string().default("node.cluster.x-k8s.io"),

apps/supervisor/src/envUtil.test.ts

Lines changed: 76 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,72 @@ 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: [
228+
{ key: "dedicated", operator: "Equal", value: "pool", effect: "NoSchedule" },
229+
],
230+
},
231+
});
232+
});
233+
234+
it("should allow either half to be omitted", () => {
235+
expect(
236+
OrgPlacementOverrides.parse(JSON.stringify({ org_123: { nodeSelector: { pool: "a" } } }))
237+
).toEqual({ org_123: { nodeSelector: { pool: "a" } } });
238+
239+
expect(
240+
OrgPlacementOverrides.parse(JSON.stringify({ org_123: { tolerations: "spot:NoExecute" } }))
241+
).toEqual({
242+
org_123: { tolerations: [{ key: "spot", operator: "Exists", effect: "NoExecute" }] },
243+
});
244+
245+
expect(OrgPlacementOverrides.parse(JSON.stringify({ org_123: {} }))).toEqual({ org_123: {} });
246+
});
247+
248+
it("should reject invalid JSON at startup rather than silently skipping the override", () => {
249+
for (const invalid of ["not json", "[]", '"org_123"', "{"]) {
250+
expect(OrgPlacementOverrides.safeParse(invalid).success).toBe(false);
251+
}
252+
});
253+
254+
it("should reject an unknown field, so a typo cannot silently drop an override", () => {
255+
expect(
256+
OrgPlacementOverrides.safeParse(
257+
JSON.stringify({ org_123: { toleration: "dedicated=pool:NoSchedule" } })
258+
).success
259+
).toBe(false);
260+
});
261+
262+
it("should reject a node selector key or value Kubernetes would reject", () => {
263+
for (const invalid of [
264+
{ org_123: { nodeSelector: { "bad key": "a" } } },
265+
{ org_123: { nodeSelector: { pool: "bad value" } } },
266+
{ org_123: { nodeSelector: { "a/b/c": "a" } } },
267+
{ org_123: { nodeSelector: { pool: "v".repeat(64) } } },
268+
]) {
269+
expect(OrgPlacementOverrides.safeParse(JSON.stringify(invalid)).success).toBe(false);
270+
}
271+
});
272+
273+
it("should reject an invalid toleration inside an override", () => {
274+
expect(
275+
OrgPlacementOverrides.safeParse(
276+
JSON.stringify({ org_123: { tolerations: "dedicated=pool:Nope" } })
277+
).success
278+
).toBe(false);
279+
});
280+
});

apps/supervisor/src/envUtil.ts

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

149+
const NodeSelector = z.record(z.string(), z.string()).superRefine((selector, ctx) => {
150+
for (const [key, value] of Object.entries(selector)) {
151+
if (!isQualifiedName(key)) {
152+
ctx.addIssue({
153+
code: z.ZodIssueCode.custom,
154+
message: `Invalid node selector key "${key}". Must be a Kubernetes label key, optionally prefixed with a DNS subdomain.`,
155+
});
156+
}
157+
158+
if (!isLabelValue(value)) {
159+
ctx.addIssue({
160+
code: z.ZodIssueCode.custom,
161+
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.`,
162+
});
163+
}
164+
}
165+
});
166+
167+
/**
168+
* Per-organization placement overrides for run pods, as JSON keyed by org id:
169+
* `{"<orgId>": {"nodeSelector": {"<key>": "<value>"}, "tolerations": "<csv>"}}`.
170+
* Tolerations use the same CSV format as `Tolerations`. Everything is validated
171+
* at startup for the same reason as tolerations above: a typo would otherwise
172+
* reject every pod create for that org, with the cause buried in API errors.
173+
*/
174+
export const OrgPlacementOverrides = z
175+
.string()
176+
.transform((val, ctx) => {
177+
try {
178+
return JSON.parse(val) as unknown;
179+
} catch {
180+
ctx.addIssue({
181+
code: z.ZodIssueCode.custom,
182+
message: "Invalid org placement overrides: not valid JSON",
183+
});
184+
return z.NEVER;
185+
}
186+
})
187+
.pipe(
188+
z.record(
189+
z.string().min(1),
190+
z
191+
.object({
192+
nodeSelector: NodeSelector.optional(),
193+
tolerations: Tolerations.optional(),
194+
})
195+
.strict()
196+
)
197+
);
198+
149199
export const AdditionalEnvVars = z.preprocess((val) => {
150200
if (typeof val !== "string") {
151201
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", () => {

apps/supervisor/src/workloadManager/kubernetes.ts

Lines changed: 13 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -18,6 +18,7 @@ import {
1818
nodetypeNodeSelector,
1919
runPodTolerations,
2020
withBlockIoUringSeccompProfile,
21+
withNodeSelector,
2122
} from "./kubernetesPodSpec.js";
2223

2324
type ResourceQuantities = {
@@ -110,7 +111,11 @@ export class KubernetesWorkloadManager implements WorkloadManager {
110111
const runnerId = getRunnerId(opts.runFriendlyId, opts.nextAttemptNumber);
111112

112113
try {
113-
const basePodSpec = this.addPlacementTags(this.#defaultPodSpec, opts.placementTags);
114+
const orgOverride = env.KUBERNETES_ORG_PLACEMENT_OVERRIDES?.[opts.orgId];
115+
const basePodSpec = withNodeSelector(
116+
this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
117+
orgOverride?.nodeSelector
118+
);
114119
const podSpec = this.opts.checkpointsEnabled
115120
? withBlockIoUringSeccompProfile(basePodSpec, opts.runtime)
116121
: basePodSpec;
@@ -131,7 +136,7 @@ export class KubernetesWorkloadManager implements WorkloadManager {
131136
spec: {
132137
...podSpec,
133138
affinity: this.#getAffinity(opts),
134-
tolerations: this.#getTolerations(this.#isScheduledRun(opts)),
139+
tolerations: this.#getTolerations(this.#isScheduledRun(opts), orgOverride?.tolerations),
135140
terminationGracePeriodSeconds: 60 * 60,
136141
containers: [
137142
{
@@ -555,11 +560,15 @@ export class KubernetesWorkloadManager implements WorkloadManager {
555560
};
556561
}
557562

558-
#getTolerations(isScheduledRun: boolean): k8s.V1Toleration[] | undefined {
563+
#getTolerations(
564+
isScheduledRun: boolean,
565+
orgTolerations?: k8s.V1Toleration[]
566+
): k8s.V1Toleration[] | undefined {
559567
return runPodTolerations(
560568
env.KUBERNETES_RUNNER_TOLERATIONS,
561569
env.KUBERNETES_SCHEDULED_RUN_TOLERATIONS,
562-
isScheduledRun
570+
isScheduledRun,
571+
orgTolerations
563572
);
564573
}
565574

apps/supervisor/src/workloadManager/kubernetesPodSpec.ts

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -19,23 +19,47 @@ export function nodetypeNodeSelector(
1919

2020
/**
2121
* Tolerations for a run pod: the cluster-wide set, plus the scheduled-run set when the
22-
* run came from a schedule tree. Not reconciled - Kubernetes matches tolerations as an
23-
* any-match set, so a broad entry in one set can subsume a narrower one in the other.
22+
* run came from a schedule tree, plus the org's own set when a placement override
23+
* matches. Not reconciled - Kubernetes matches tolerations as an any-match set, so a
24+
* broad entry in one set can subsume a narrower one in another.
2425
* Returns undefined rather than an empty array to leave the field unset.
2526
*/
2627
export function runPodTolerations(
2728
runnerTolerations: k8s.V1Toleration[] | undefined,
2829
scheduledRunTolerations: k8s.V1Toleration[] | undefined,
29-
isScheduledRun: boolean
30+
isScheduledRun: boolean,
31+
orgTolerations?: k8s.V1Toleration[]
3032
): k8s.V1Toleration[] | undefined {
3133
const tolerations = [
3234
...(runnerTolerations ?? []),
3335
...(isScheduledRun ? (scheduledRunTolerations ?? []) : []),
36+
...(orgTolerations ?? []),
3437
];
3538

3639
return tolerations.length > 0 ? tolerations : undefined;
3740
}
3841

42+
/**
43+
* Merges extra node selector entries into a pod spec. Later entries win on key
44+
* collision, so an override can retarget a key set by an earlier stage.
45+
*/
46+
export function withNodeSelector(
47+
podSpec: Omit<k8s.V1PodSpec, "containers">,
48+
nodeSelector: Record<string, string> | undefined
49+
): Omit<k8s.V1PodSpec, "containers"> {
50+
if (!nodeSelector || Object.keys(nodeSelector).length === 0) {
51+
return podSpec;
52+
}
53+
54+
return {
55+
...podSpec,
56+
nodeSelector: {
57+
...podSpec.nodeSelector,
58+
...nodeSelector,
59+
},
60+
};
61+
}
62+
3963
/**
4064
* Node >= 24 always creates io_uring fds, which can't be checkpointed. Blocking
4165
* io_uring_setup makes libuv fall back to epoll. Other runtimes don't need this,

0 commit comments

Comments
 (0)