Skip to content

Commit 8f9db53

Browse files
authored
feat(supervisor): configurable tolerations for run pods (#4491)
## Summary Self-hosted Kubernetes deployments can now add tolerations to run pods, so runs can schedule onto tainted nodes. Previously the only way to do this was to patch the supervisor. `KUBERNETES_RUNNER_TOLERATIONS` takes a comma separated list of `key=value:effect`, or `key:effect` to tolerate any value. It applies to every run pod, and for runs from a schedule tree it merges with the existing `KUBERNETES_SCHEDULED_RUN_TOLERATIONS`. Left unset, nothing changes: no tolerations are added and the pod spec leaves the field off entirely. The Helm chart takes it as a list: ```yaml supervisor: config: kubernetes: runnerTolerations: - dedicated=runs:NoSchedule - spot:NoExecute ``` ## Naming The issue proposed `KUBERNETES_WORKER_TOLERATIONS`. This ships as `KUBERNETES_RUNNER_TOLERATIONS` instead, because `RUNNER_*` is already the prefix for run pod settings (`RUNNER_HEARTBEAT_INTERVAL_SECONDS`, `RUNNER_ADDITIONAL_ENV_VARS`, and `DOCKER_RUNNER_NETWORKS` for the Docker equivalent), whereas "worker" refers to the supervisor itself throughout this app. ## Validation Keys and values are checked against the Kubernetes naming rules when the supervisor starts, so `dedicated=prod runs:NoSchedule` fails immediately with a message naming the offending entry. Without that check a bad value is accepted at startup and then rejected by the API server on every pod create, which stops all runs with the cause buried in an API error. `KUBERNETES_WORKER_NODETYPE_LABEL` is trimmed and validated for the same reason: surrounding whitespace is not valid in a label value, so a padded value fails every pod create today. ## Node selector off switch `KUBERNETES_WORKER_NODETYPE_LABEL` accepts an empty string to skip the node selector entirely, so runs schedule on any node. This already worked and the Helm chart has always shipped it empty, but it was not documented. It is now. The issue also asked for general node affinity configuration. That is not included: the node selector off switch plus tolerations covers the reported problem, and a free form affinity setting is a much larger config surface to commit to. Fixes #4458
1 parent 9d57aff commit 8f9db53

10 files changed

Lines changed: 359 additions & 79 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+
Self-hosted Kubernetes deployments can now add tolerations to run pods, so runs are allowed onto tainted nodes. An invalid toleration now stops the supervisor at startup instead of failing every run pod, so check existing values before upgrading.

apps/supervisor/src/env.ts

Lines changed: 4 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import { randomUUID } from "crypto";
22
import { env as stdEnv } from "std-env";
33
import { z } from "zod";
4-
import { AdditionalEnvVars, BoolEnv } from "./envUtil.js";
4+
import { AdditionalEnvVars, BoolEnv, NodeLabelValue, Tolerations } from "./envUtil.js";
55

66
export const Env = z
77
.object({
@@ -173,7 +173,7 @@ export const Env = z
173173
// Kubernetes settings
174174
KUBERNETES_FORCE_ENABLED: BoolEnv.default(false),
175175
KUBERNETES_NAMESPACE: z.string().default("default"),
176-
KUBERNETES_WORKER_NODETYPE_LABEL: z.string().default("v4-worker"),
176+
KUBERNETES_WORKER_NODETYPE_LABEL: NodeLabelValue.default("v4-worker"),
177177
KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv
178178
KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"),
179179
KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"),
@@ -256,65 +256,8 @@ export const Env = z
256256
.max(100)
257257
.default(20),
258258

259-
// Schedule toleration settings - scheduled runs tolerate taints on the dedicated pool
260-
// Comma-separated list of tolerations in the format: key=value:effect
261-
// For Exists operator (no value): key:effect
262-
KUBERNETES_SCHEDULED_RUN_TOLERATIONS: z
263-
.string()
264-
.transform((val, ctx) => {
265-
const tolerations = val
266-
.split(",")
267-
.map((entry) => entry.trim())
268-
.filter((entry) => entry.length > 0)
269-
.map((entry) => {
270-
const colonIdx = entry.lastIndexOf(":");
271-
if (colonIdx === -1) {
272-
ctx.addIssue({
273-
code: z.ZodIssueCode.custom,
274-
message: `Invalid toleration format (missing effect): "${entry}"`,
275-
});
276-
return z.NEVER;
277-
}
278-
279-
const effect = entry.slice(colonIdx + 1);
280-
const validEffects = ["NoSchedule", "NoExecute", "PreferNoSchedule"];
281-
if (!validEffects.includes(effect)) {
282-
ctx.addIssue({
283-
code: z.ZodIssueCode.custom,
284-
message: `Invalid toleration effect "${effect}" in "${entry}". Must be one of: ${validEffects.join(
285-
", "
286-
)}`,
287-
});
288-
return z.NEVER;
289-
}
290-
291-
const keyValue = entry.slice(0, colonIdx);
292-
const eqIdx = keyValue.indexOf("=");
293-
const key = eqIdx === -1 ? keyValue : keyValue.slice(0, eqIdx);
294-
295-
if (!key) {
296-
ctx.addIssue({
297-
code: z.ZodIssueCode.custom,
298-
message: `Invalid toleration format (empty key): "${entry}"`,
299-
});
300-
return z.NEVER;
301-
}
302-
303-
if (eqIdx === -1) {
304-
return { key, operator: "Exists" as const, effect };
305-
}
306-
307-
return {
308-
key,
309-
operator: "Equal" as const,
310-
value: keyValue.slice(eqIdx + 1),
311-
effect,
312-
};
313-
});
314-
315-
return tolerations;
316-
})
317-
.optional(),
259+
KUBERNETES_RUNNER_TOLERATIONS: Tolerations.optional(), // every run pod
260+
KUBERNETES_SCHEDULED_RUN_TOLERATIONS: Tolerations.optional(), // schedule-tree runs only
318261

319262
// Placement tags settings
320263
PLACEMENT_TAGS_ENABLED: BoolEnv.default(false),

apps/supervisor/src/envUtil.test.ts

Lines changed: 126 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { describe, it, expect } from "vitest";
2-
import { BoolEnv, AdditionalEnvVars } from "./envUtil.js";
2+
import { BoolEnv, AdditionalEnvVars, NodeLabelValue, Tolerations } from "./envUtil.js";
33

44
describe("BoolEnv", () => {
55
it("should parse string 'true' as true", () => {
@@ -78,3 +78,128 @@ describe("AdditionalEnvVars", () => {
7878
});
7979
});
8080
});
81+
82+
describe("NodeLabelValue", () => {
83+
it("should keep a clean value untouched", () => {
84+
expect(NodeLabelValue.parse("v4-worker")).toBe("v4-worker");
85+
});
86+
87+
it("should trim surrounding whitespace, which Kubernetes would reject", () => {
88+
expect(NodeLabelValue.parse(" v4-worker ")).toBe("v4-worker");
89+
expect(NodeLabelValue.parse("\tv4-worker\n")).toBe("v4-worker");
90+
});
91+
92+
it("should treat a whitespace-only value as the empty off-switch", () => {
93+
expect(NodeLabelValue.parse("")).toBe("");
94+
expect(NodeLabelValue.parse(" ")).toBe("");
95+
});
96+
97+
it("should still apply a default only when unset", () => {
98+
const withDefault = NodeLabelValue.default("v4-worker");
99+
expect(withDefault.parse(undefined)).toBe("v4-worker");
100+
expect(withDefault.parse("")).toBe("");
101+
});
102+
103+
it("should reject a value Kubernetes would reject, rather than 422 every pod create", () => {
104+
for (const invalid of ["my worker", "-bad-", "bad.", "a".repeat(64)]) {
105+
expect(NodeLabelValue.safeParse(invalid).success).toBe(false);
106+
}
107+
});
108+
});
109+
110+
describe("Tolerations", () => {
111+
it("should parse key=value entries as Equal", () => {
112+
expect(Tolerations.parse("dedicated=runs:NoSchedule")).toEqual([
113+
{ key: "dedicated", operator: "Equal", value: "runs", effect: "NoSchedule" },
114+
]);
115+
});
116+
117+
it("should parse entries without a value as Exists", () => {
118+
expect(Tolerations.parse("scheduled-runs:NoExecute")).toEqual([
119+
{ key: "scheduled-runs", operator: "Exists", effect: "NoExecute" },
120+
]);
121+
});
122+
123+
it("should keep an empty value as an exact match for a valueless taint", () => {
124+
expect(Tolerations.parse("dedicated=:NoSchedule")).toEqual([
125+
{ key: "dedicated", operator: "Equal", value: "", effect: "NoSchedule" },
126+
]);
127+
128+
expect(Tolerations.parse("dedicated:NoSchedule")).toEqual([
129+
{ key: "dedicated", operator: "Exists", effect: "NoSchedule" },
130+
]);
131+
});
132+
133+
it("should parse an empty string as no tolerations", () => {
134+
expect(Tolerations.parse("")).toEqual([]);
135+
expect(Tolerations.parse(" ")).toEqual([]);
136+
});
137+
138+
it("should skip blank entries and trim whitespace", () => {
139+
expect(Tolerations.parse(" a=b:NoSchedule , ,")).toEqual([
140+
{ key: "a", operator: "Equal", value: "b", effect: "NoSchedule" },
141+
]);
142+
});
143+
144+
it("should reject a missing effect, an unknown effect, and an empty key", () => {
145+
for (const invalid of ["dedicated=runs", "dedicated=runs:Nope", "=runs:NoSchedule"]) {
146+
expect(Tolerations.safeParse(invalid).success).toBe(false);
147+
}
148+
});
149+
150+
it("should accept a hyphenated key, a digit-suffixed key, and every effect", () => {
151+
expect(
152+
Tolerations.parse("capacity-1=true:PreferNoSchedule,spot:NoExecute,gpu=a10:NoSchedule")
153+
).toEqual([
154+
{ key: "capacity-1", operator: "Equal", value: "true", effect: "PreferNoSchedule" },
155+
{ key: "spot", operator: "Exists", effect: "NoExecute" },
156+
{ key: "gpu", operator: "Equal", value: "a10", effect: "NoSchedule" },
157+
]);
158+
});
159+
160+
it("should accept a DNS-subdomain prefixed key", () => {
161+
expect(
162+
Tolerations.parse("node.cluster.x-k8s.io/machinepool=scheduled-runs:NoSchedule")
163+
).toEqual([
164+
{
165+
key: "node.cluster.x-k8s.io/machinepool",
166+
operator: "Equal",
167+
value: "scheduled-runs",
168+
effect: "NoSchedule",
169+
},
170+
]);
171+
});
172+
173+
it("should reject a key or value that Kubernetes would reject at pod create", () => {
174+
for (const invalid of [
175+
"dedicated=prod runs:NoSchedule",
176+
"ded icated=runs:NoSchedule",
177+
"dedicated=-runs:NoSchedule",
178+
`dedicated=${"r".repeat(64)}:NoSchedule`,
179+
`${"a".repeat(64)}=runs:NoSchedule`,
180+
`example.com/${"a".repeat(64)}=runs:NoSchedule`,
181+
"a/b/c=runs:NoSchedule",
182+
"Example.com/pool=runs:NoSchedule",
183+
]) {
184+
expect(Tolerations.safeParse(invalid).success).toBe(false);
185+
}
186+
});
187+
188+
it("should bound the prefix and the name separately, as Kubernetes does", () => {
189+
const longestPrefix = `${"a".repeat(63)}.${"b".repeat(63)}.${"c".repeat(63)}.${"d".repeat(61)}`;
190+
expect(longestPrefix.length).toBe(253);
191+
192+
expect(Tolerations.parse(`${longestPrefix}/${"n".repeat(63)}=runs:NoSchedule`)).toHaveLength(1);
193+
expect(Tolerations.safeParse(`${longestPrefix}a/pool=runs:NoSchedule`).success).toBe(false);
194+
});
195+
196+
it("should tolerate whitespace around the separators", () => {
197+
expect(Tolerations.parse("dedicated = runs : NoSchedule")).toEqual([
198+
{ key: "dedicated", operator: "Equal", value: "runs", effect: "NoSchedule" },
199+
]);
200+
});
201+
202+
it("should reject a stray extra effect instead of folding it into the value", () => {
203+
expect(Tolerations.safeParse("dedicated=runs:NoSchedule:NoExecute").success).toBe(false);
204+
});
205+
});

apps/supervisor/src/envUtil.ts

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,136 @@ export const BoolEnv = baseBoolEnv as Omit<typeof baseBoolEnv, "default"> & {
1616
default: (value: boolean) => z.ZodDefault<typeof baseBoolEnv>;
1717
};
1818

19+
const QUALIFIED_NAME = /^[A-Za-z0-9]([-A-Za-z0-9_.]*[A-Za-z0-9])?$/;
20+
const DNS_SUBDOMAIN = /^[a-z0-9]([-a-z0-9]*[a-z0-9])?(\.[a-z0-9]([-a-z0-9]*[a-z0-9])?)*$/;
21+
const LABEL_VALUE = /^(([A-Za-z0-9][-A-Za-z0-9_.]*)?[A-Za-z0-9])?$/;
22+
const QUALIFIED_NAME_MAX = 63;
23+
const DNS_SUBDOMAIN_MAX = 253;
24+
const LABEL_VALUE_MAX = 63;
25+
26+
/**
27+
* isLabelValue mirrors the Kubernetes label value rules. Empty is valid upstream.
28+
*/
29+
function isLabelValue(value: string): boolean {
30+
return value.length <= LABEL_VALUE_MAX && LABEL_VALUE.test(value);
31+
}
32+
33+
/**
34+
* isQualifiedName mirrors the Kubernetes qualified name rules used for taint and
35+
* label keys: an optional DNS subdomain prefix before the slash, then the name.
36+
* The two halves have different length limits and different case rules, so a
37+
* single pattern with one overall bound gets both ends wrong.
38+
*/
39+
function isQualifiedName(key: string): boolean {
40+
const slashIdx = key.indexOf("/");
41+
42+
if (slashIdx === -1) {
43+
return key.length <= QUALIFIED_NAME_MAX && QUALIFIED_NAME.test(key);
44+
}
45+
46+
const prefix = key.slice(0, slashIdx);
47+
const name = key.slice(slashIdx + 1);
48+
49+
return (
50+
prefix.length <= DNS_SUBDOMAIN_MAX &&
51+
DNS_SUBDOMAIN.test(prefix) &&
52+
name.length <= QUALIFIED_NAME_MAX &&
53+
QUALIFIED_NAME.test(name)
54+
);
55+
}
56+
57+
/**
58+
* A node label value. Trimmed because Kubernetes rejects surrounding whitespace
59+
* outright, so a padded value fails every pod create. Deliberately no `min(1)`:
60+
* empty is the off-switch, and the Helm chart ships empty by default.
61+
*/
62+
export const NodeLabelValue = z.string().trim().refine(isLabelValue, {
63+
message:
64+
"Must be a Kubernetes label value: alphanumeric, with dashes, underscores and dots inside, at most 63 characters",
65+
});
66+
67+
/**
68+
* Comma-separated pod tolerations in the format `key=value:effect`, or `key:effect`
69+
* for the Exists operator. Keys and values are checked against the Kubernetes
70+
* naming rules here so a typo fails at startup, rather than 422ing every single
71+
* pod create with the cause buried in an API server message.
72+
*/
73+
export const Tolerations = z.string().transform((val, ctx) => {
74+
return val
75+
.split(",")
76+
.map((entry) => entry.trim())
77+
.filter((entry) => entry.length > 0)
78+
.map((entry) => {
79+
const colonIdx = entry.lastIndexOf(":");
80+
if (colonIdx === -1) {
81+
ctx.addIssue({
82+
code: z.ZodIssueCode.custom,
83+
message: `Invalid toleration format (missing effect): "${entry}"`,
84+
});
85+
return z.NEVER;
86+
}
87+
88+
const effect = entry.slice(colonIdx + 1).trim();
89+
const validEffects = ["NoSchedule", "NoExecute", "PreferNoSchedule"];
90+
if (!validEffects.includes(effect)) {
91+
ctx.addIssue({
92+
code: z.ZodIssueCode.custom,
93+
message: `Invalid toleration effect "${effect}" in "${entry}". Must be one of: ${validEffects.join(
94+
", "
95+
)}`,
96+
});
97+
return z.NEVER;
98+
}
99+
100+
const keyValue = entry.slice(0, colonIdx);
101+
const eqIdx = keyValue.indexOf("=");
102+
const key = (eqIdx === -1 ? keyValue : keyValue.slice(0, eqIdx)).trim();
103+
104+
if (!key) {
105+
ctx.addIssue({
106+
code: z.ZodIssueCode.custom,
107+
message: `Invalid toleration format (empty key): "${entry}"`,
108+
});
109+
return z.NEVER;
110+
}
111+
112+
if (!isQualifiedName(key)) {
113+
ctx.addIssue({
114+
code: z.ZodIssueCode.custom,
115+
message: `Invalid toleration key "${key}" in "${entry}". Must be a Kubernetes taint key, optionally prefixed with a DNS subdomain.`,
116+
});
117+
return z.NEVER;
118+
}
119+
120+
if (eqIdx === -1) {
121+
return { key, operator: "Exists" as const, effect };
122+
}
123+
124+
const value = keyValue.slice(eqIdx + 1).trim();
125+
if (!value) {
126+
logger.warn(
127+
'Toleration has an empty value, so it matches only a taint whose value is also empty. Drop the "=" to tolerate any value of this key.',
128+
{ entry, key }
129+
);
130+
}
131+
132+
if (!isLabelValue(value)) {
133+
ctx.addIssue({
134+
code: z.ZodIssueCode.custom,
135+
message: `Invalid toleration value "${value}" in "${entry}". Must be a Kubernetes label value: alphanumeric, with dashes, underscores and dots inside.`,
136+
});
137+
return z.NEVER;
138+
}
139+
140+
return {
141+
key,
142+
operator: "Equal" as const,
143+
value,
144+
effect,
145+
};
146+
});
147+
});
148+
19149
export const AdditionalEnvVars = z.preprocess((val) => {
20150
if (typeof val !== "string") {
21151
return val;

0 commit comments

Comments
 (0)