Skip to content

Commit 450049c

Browse files
feat(supervisor): parameterize worker pod annotations, securityContext, SA, envFrom
The Kubernetes workload manager now reads five new env vars from the supervisor process and applies them to every spawned worker pod: - KUBERNETES_WORKER_POD_ANNOTATIONS (JSON, Record<string,string>) - KUBERNETES_WORKER_POD_SECURITY_CONTEXT (JSON, V1PodSecurityContext) - KUBERNETES_WORKER_CONTAINER_SECURITY_CONTEXT (JSON, V1SecurityContext) - KUBERNETES_WORKER_SERVICE_ACCOUNT (string, SA name) - KUBERNETES_WORKER_ENV_FROM_SECRET (string, Secret name) All five are optional. When unset, behavior matches today. Lets compliance-sensitive deployments (Red Hat OpenShift, FedRAMP/IL5 environments, restricted PSA namespaces) configure worker pods through the Helm chart instead of patching the supervisor image. Also unblocks operators who need worker pods to: - Carry custom annotations (e.g. service mesh sidecar opt-out, audit tags) - Run under specific UIDs / capabilities / seccomp profiles - Use a non-default ServiceAccount (e.g. for IRSA / Workload Identity) - Inherit a batch of env vars from a Secret via envFrom Includes envUtil helper + tests for JSON parsing of the structured envs. The supervisor.yaml Helm template emits these env vars from values when set; schema added under supervisor.config.kubernetes.* in values.yaml. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent b87e5fc commit 450049c

6 files changed

Lines changed: 277 additions & 4 deletions

File tree

apps/supervisor/src/env.ts

Lines changed: 32 additions & 1 deletion
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, JsonAny, JsonObjectEnv } from "./envUtil.js";
55

66
const Env = z
77
.object({
@@ -92,6 +92,37 @@ const Env = z
9292
KUBERNETES_FORCE_ENABLED: BoolEnv.default(false),
9393
KUBERNETES_NAMESPACE: z.string().default("default"),
9494
KUBERNETES_WORKER_NODETYPE_LABEL: z.string().default("v4-worker"),
95+
KUBERNETES_WORKER_SERVICE_ACCOUNT: z.string().optional(), // Service account for worker pods
96+
KUBERNETES_WORKER_AUTOMOUNT_SERVICE_ACCOUNT_TOKEN: BoolEnv.default(false), // Whether to mount SA token
97+
// Extra annotations to apply to every worker pod (e.g. for service mesh
98+
// sidecar injection, certificate injection, scheduling hints).
99+
KUBERNETES_WORKER_POD_ANNOTATIONS: JsonObjectEnv("KUBERNETES_WORKER_POD_ANNOTATIONS"),
100+
// Pod-level securityContext applied to every worker pod (V1PodSecurityContext shape).
101+
// Default is empty `{}`, preserving the upstream behavior of not setting
102+
// a pod-level securityContext. Provide a JSON object to enforce e.g.
103+
// `{"runAsNonRoot": true, "runAsUser": 1000, "fsGroup": 1000}`.
104+
// OpenShift and other clusters with arbitrary-UID SCCs typically want
105+
// to leave this empty and let the SCC inject values.
106+
KUBERNETES_WORKER_POD_SECURITY_CONTEXT: JsonObjectEnv("KUBERNETES_WORKER_POD_SECURITY_CONTEXT", {
107+
valueValidator: JsonAny,
108+
}),
109+
// Container-level securityContext applied to the worker container of every
110+
// worker pod (V1SecurityContext shape). Default is empty `{}` (matches
111+
// upstream's previous behavior of not setting a container securityContext).
112+
// Provide a JSON object to enforce e.g.
113+
// `{"runAsNonRoot": true, "runAsUser": 1000, "allowPrivilegeEscalation": false,
114+
// "capabilities": {"drop": ["ALL"]}, "seccompProfile": {"type": "RuntimeDefault"}}`.
115+
KUBERNETES_WORKER_CONTAINER_SECURITY_CONTEXT: JsonObjectEnv(
116+
"KUBERNETES_WORKER_CONTAINER_SECURITY_CONTEXT",
117+
{ valueValidator: JsonAny }
118+
),
119+
// Name of a Kubernetes Secret to envFrom-mount into every worker pod's
120+
// container. Pulls every key/value pair in the secret as env vars on
121+
// the worker. Resolved by the kubelet at pod creation time; the
122+
// supervisor never reads the secret values, so this needs no extra
123+
// RBAC. Use case: keep task-time secrets (DB URLs, API keys) in
124+
// Kubernetes rather than syncing them through the trigger.dev webapp.
125+
KUBERNETES_WORKER_ENV_FROM_SECRET: z.string().optional(),
95126
KUBERNETES_IMAGE_PULL_SECRETS: z.string().optional(), // csv
96127
KUBERNETES_EPHEMERAL_STORAGE_SIZE_LIMIT: z.string().default("10Gi"),
97128
KUBERNETES_EPHEMERAL_STORAGE_SIZE_REQUEST: z.string().default("2Gi"),

apps/supervisor/src/envUtil.test.ts

Lines changed: 77 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, it, expect } from "vitest";
2-
import { BoolEnv, AdditionalEnvVars } from "./envUtil.js";
2+
import { z } from "zod";
3+
import { BoolEnv, AdditionalEnvVars, JsonObjectEnv, JsonAny } from "./envUtil.js";
34

45
describe("BoolEnv", () => {
56
it("should parse string 'true' as true", () => {
@@ -78,3 +79,78 @@ describe("AdditionalEnvVars", () => {
7879
});
7980
});
8081
});
82+
83+
describe("JsonObjectEnv (string-valued)", () => {
84+
const schema = JsonObjectEnv("TEST_ENV");
85+
86+
it("returns empty object for default (no value)", () => {
87+
expect(schema.parse(undefined)).toEqual({});
88+
});
89+
90+
it("parses a simple string-valued JSON object", () => {
91+
expect(schema.parse('{"a":"1","b":"2"}')).toEqual({ a: "1", b: "2" });
92+
});
93+
94+
it("parses an empty JSON object", () => {
95+
expect(schema.parse("{}")).toEqual({});
96+
});
97+
98+
it("rejects non-JSON input", () => {
99+
expect(() => schema.parse("not json")).toThrowError(/not valid JSON/);
100+
});
101+
102+
it("rejects JSON arrays", () => {
103+
expect(() => schema.parse("[]")).toThrowError(/must be a JSON object \(got array\)/);
104+
});
105+
106+
it("rejects JSON primitives", () => {
107+
expect(() => schema.parse('"foo"')).toThrowError(/must be a JSON object \(got string\)/);
108+
expect(() => schema.parse("42")).toThrowError(/must be a JSON object \(got number\)/);
109+
expect(() => schema.parse("null")).toThrowError(/must be a JSON object \(got object\)/);
110+
});
111+
112+
it("rejects values that are not strings (with default validator)", () => {
113+
expect(() => schema.parse('{"a": 1}')).toThrowError(/has invalid value/);
114+
expect(() => schema.parse('{"a": true}')).toThrowError(/has invalid value/);
115+
});
116+
});
117+
118+
describe("JsonObjectEnv (arbitrary-value)", () => {
119+
const schema = JsonObjectEnv("TEST_ANY", { valueValidator: JsonAny });
120+
121+
it("accepts nested objects", () => {
122+
expect(
123+
schema.parse(
124+
JSON.stringify({
125+
runAsNonRoot: true,
126+
runAsUser: 1000,
127+
capabilities: { drop: ["ALL"] },
128+
})
129+
)
130+
).toEqual({
131+
runAsNonRoot: true,
132+
runAsUser: 1000,
133+
capabilities: { drop: ["ALL"] },
134+
});
135+
});
136+
137+
it("accepts mixed value types", () => {
138+
expect(schema.parse('{"s":"x","n":1,"b":true,"a":[1,2],"o":{"k":"v"}}')).toEqual({
139+
s: "x",
140+
n: 1,
141+
b: true,
142+
a: [1, 2],
143+
o: { k: "v" },
144+
});
145+
});
146+
147+
it("still rejects non-object roots", () => {
148+
expect(() => schema.parse('"x"')).toThrowError(/must be a JSON object/);
149+
expect(() => schema.parse("[1,2,3]")).toThrowError(/must be a JSON object/);
150+
});
151+
152+
it("includes the env var name in error messages", () => {
153+
const named = JsonObjectEnv("KUBERNETES_WORKER_POD_SECURITY_CONTEXT");
154+
expect(() => named.parse("{notjson")).toThrowError(/KUBERNETES_WORKER_POD_SECURITY_CONTEXT/);
155+
});
156+
});

apps/supervisor/src/envUtil.ts

Lines changed: 80 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,3 +45,83 @@ export const AdditionalEnvVars = z.preprocess((val) => {
4545
return undefined;
4646
}
4747
}, z.record(z.string(), z.string()).optional());
48+
49+
/**
50+
* Factory for env vars that hold a JSON object. The default is the empty object,
51+
* so callers can spread the parsed result into Kubernetes manifests without
52+
* branching on undefined.
53+
*
54+
* `valueValidator` constrains the shape of the parsed values:
55+
* - `JsonStringMap` for `Record<string, string>` (e.g. annotations, labels)
56+
* - `JsonAny` for arbitrary nested objects (e.g. `securityContext`)
57+
*
58+
* @example
59+
* KUBERNETES_WORKER_POD_ANNOTATIONS: JsonObjectEnv("KUBERNETES_WORKER_POD_ANNOTATIONS", {
60+
* valueValidator: JsonStringMap,
61+
* }),
62+
*/
63+
export const JsonStringMap = z.record(z.string(), z.string());
64+
export const JsonAny: z.ZodTypeAny = z.lazy(() =>
65+
z.union([
66+
z.string(),
67+
z.number(),
68+
z.boolean(),
69+
z.null(),
70+
z.array(JsonAny),
71+
z.record(z.string(), JsonAny),
72+
])
73+
);
74+
75+
type JsonObjectEnvOpts<TSchema extends z.ZodTypeAny> = {
76+
/**
77+
* Schema applied to each *value* in the parsed object. Defaults to
78+
* `JsonStringMap` (string values).
79+
*/
80+
valueValidator?: TSchema;
81+
};
82+
83+
export const JsonObjectEnv = <TSchema extends z.ZodTypeAny = typeof JsonStringMap>(
84+
envName: string,
85+
opts: JsonObjectEnvOpts<TSchema> = {}
86+
) => {
87+
const valueValidator = (opts.valueValidator ?? JsonStringMap) as TSchema;
88+
89+
return z
90+
.string()
91+
.default("{}")
92+
.transform((raw, ctx) => {
93+
let parsed: unknown;
94+
try {
95+
parsed = JSON.parse(raw);
96+
} catch (e) {
97+
ctx.addIssue({
98+
code: z.ZodIssueCode.custom,
99+
message: `${envName} is not valid JSON: ${e instanceof Error ? e.message : String(e)}`,
100+
});
101+
return z.NEVER;
102+
}
103+
104+
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
105+
ctx.addIssue({
106+
code: z.ZodIssueCode.custom,
107+
message: `${envName} must be a JSON object (got ${
108+
Array.isArray(parsed) ? "array" : typeof parsed
109+
})`,
110+
});
111+
return z.NEVER;
112+
}
113+
114+
const validated = z.record(z.string(), valueValidator).safeParse(parsed);
115+
if (!validated.success) {
116+
ctx.addIssue({
117+
code: z.ZodIssueCode.custom,
118+
message: `${envName} has invalid value(s): ${validated.error.message}`,
119+
});
120+
return z.NEVER;
121+
}
122+
123+
return validated.data as z.infer<TSchema> extends z.ZodTypeAny
124+
? Record<string, z.infer<TSchema>>
125+
: Record<string, unknown>;
126+
});
127+
};

apps/supervisor/src/workloadManager/kubernetes.ts

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,13 @@ export class KubernetesWorkloadManager implements WorkloadManager {
117117
"app.kubernetes.io/part-of": "trigger-worker",
118118
"app.kubernetes.io/component": "create",
119119
},
120+
...(Object.keys(env.KUBERNETES_WORKER_POD_ANNOTATIONS).length > 0
121+
? {
122+
annotations: {
123+
...env.KUBERNETES_WORKER_POD_ANNOTATIONS,
124+
} as Record<string, string>,
125+
}
126+
: {}),
120127
},
121128
spec: {
122129
...this.addPlacementTags(this.#defaultPodSpec, opts.placementTags),
@@ -133,6 +140,16 @@ export class KubernetesWorkloadManager implements WorkloadManager {
133140
},
134141
],
135142
resources: this.#getResourcesForMachine(opts.machine),
143+
...(Object.keys(env.KUBERNETES_WORKER_CONTAINER_SECURITY_CONTEXT).length > 0
144+
? { securityContext: env.KUBERNETES_WORKER_CONTAINER_SECURITY_CONTEXT }
145+
: {}),
146+
...(env.KUBERNETES_WORKER_ENV_FROM_SECRET
147+
? {
148+
envFrom: [
149+
{ secretRef: { name: env.KUBERNETES_WORKER_ENV_FROM_SECRET } },
150+
],
151+
}
152+
: {}),
136153
env: [
137154
{
138155
name: "TRIGGER_DEQUEUED_AT_MS",
@@ -307,13 +324,21 @@ export class KubernetesWorkloadManager implements WorkloadManager {
307324
get #defaultPodSpec(): Omit<k8s.V1PodSpec, "containers"> {
308325
return {
309326
restartPolicy: "Never",
310-
automountServiceAccountToken: false,
327+
// Explicit control over service account token mounting (defaults to false for security)
328+
automountServiceAccountToken: env.KUBERNETES_WORKER_AUTOMOUNT_SERVICE_ACCOUNT_TOKEN,
311329
imagePullSecrets: this.getImagePullSecrets(),
312330
...(env.KUBERNETES_SCHEDULER_NAME
313331
? {
314332
schedulerName: env.KUBERNETES_SCHEDULER_NAME,
315333
}
316334
: {}),
335+
// Optionally specify a service account for the worker pods
336+
...(env.KUBERNETES_WORKER_SERVICE_ACCOUNT
337+
? { serviceAccountName: env.KUBERNETES_WORKER_SERVICE_ACCOUNT }
338+
: {}),
339+
...(Object.keys(env.KUBERNETES_WORKER_POD_SECURITY_CONTEXT).length > 0
340+
? { securityContext: env.KUBERNETES_WORKER_POD_SECURITY_CONTEXT }
341+
: {}),
317342
...(env.KUBERNETES_WORKER_NODETYPE_LABEL
318343
? {
319344
nodeSelector: {

hosting/k8s/helm/templates/supervisor.yaml

Lines changed: 23 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -170,6 +170,28 @@ spec:
170170
value: {{ .Values.supervisor.config.kubernetes.forceEnabled | quote }}
171171
- name: KUBERNETES_WORKER_NODETYPE_LABEL
172172
value: {{ .Values.supervisor.config.kubernetes.workerNodetypeLabel | quote }}
173+
{{- if .Values.supervisor.config.kubernetes.workerServiceAccount }}
174+
- name: KUBERNETES_WORKER_SERVICE_ACCOUNT
175+
value: {{ .Values.supervisor.config.kubernetes.workerServiceAccount | quote }}
176+
{{- end }}
177+
- name: KUBERNETES_WORKER_AUTOMOUNT_SERVICE_ACCOUNT_TOKEN
178+
value: {{ .Values.supervisor.config.kubernetes.workerAutomountServiceAccountToken | quote }}
179+
{{- if .Values.supervisor.config.kubernetes.workerPodAnnotations }}
180+
- name: KUBERNETES_WORKER_POD_ANNOTATIONS
181+
value: {{ .Values.supervisor.config.kubernetes.workerPodAnnotations | toJson | quote }}
182+
{{- end }}
183+
{{- if .Values.supervisor.config.kubernetes.workerPodSecurityContext }}
184+
- name: KUBERNETES_WORKER_POD_SECURITY_CONTEXT
185+
value: {{ .Values.supervisor.config.kubernetes.workerPodSecurityContext | toJson | quote }}
186+
{{- end }}
187+
{{- if .Values.supervisor.config.kubernetes.workerContainerSecurityContext }}
188+
- name: KUBERNETES_WORKER_CONTAINER_SECURITY_CONTEXT
189+
value: {{ .Values.supervisor.config.kubernetes.workerContainerSecurityContext | toJson | quote }}
190+
{{- end }}
191+
{{- if .Values.supervisor.config.kubernetes.workerEnvFromSecret }}
192+
- name: KUBERNETES_WORKER_ENV_FROM_SECRET
193+
value: {{ .Values.supervisor.config.kubernetes.workerEnvFromSecret | quote }}
194+
{{- end }}
173195
{{- $registryAuthEnabled := false }}
174196
{{- if .Values.registry.deploy }}
175197
{{- $registryAuthEnabled = .Values.registry.auth.enabled }}
@@ -292,4 +314,4 @@ spec:
292314
protocol: TCP
293315
name: metrics
294316
selector:
295-
{{- include "trigger-v4.componentSelectorLabels" (dict "Chart" .Chart "Release" .Release "Values" .Values "component" $component) | nindent 4 }}
317+
{{- include "trigger-v4.componentSelectorLabels" (dict "Chart" .Chart "Release" .Release "Values" .Values "component" $component) | nindent 4 }}

hosting/k8s/helm/values.yaml

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,45 @@ supervisor:
291291
forceEnabled: true
292292
namespace: "" # Default: uses release namespace
293293
workerNodetypeLabel: "" # When set, runs will only be scheduled on nodes with "nodetype=<label>"
294+
# Service account name for worker pods. Empty = use the namespace's
295+
# "default" SA. Set to e.g. "trigger-worker" to pin a dedicated SA
296+
# (useful for IRSA / Workload Identity, image-pull secrets, etc.).
297+
# Operators are expected to create the SA themselves; this chart
298+
# does not manage worker SAs.
299+
workerServiceAccount: ""
300+
# Whether to mount the SA token inside worker pods. Defaults to
301+
# false; only enable if your worker tasks need to call the
302+
# Kubernetes API.
303+
workerAutomountServiceAccountToken: false
304+
# Annotations applied to every worker pod (e.g. service-mesh
305+
# sidecar opt-out, audit tags). Empty by default.
306+
workerPodAnnotations: {}
307+
# Pod-level securityContext applied to every worker pod
308+
# (V1PodSecurityContext shape). Empty by default. Set this on
309+
# clusters that enforce pod-security admission "restricted",
310+
# OpenShift restricted SCCs, FedRAMP/IL5, etc. Example:
311+
# workerPodSecurityContext:
312+
# runAsNonRoot: true
313+
# runAsUser: 1000
314+
# fsGroup: 1000
315+
workerPodSecurityContext: {}
316+
# Container-level securityContext applied to the worker container
317+
# of every worker pod (V1SecurityContext shape). Example:
318+
# workerContainerSecurityContext:
319+
# runAsNonRoot: true
320+
# runAsUser: 1000
321+
# allowPrivilegeEscalation: false
322+
# capabilities:
323+
# drop: [ALL]
324+
# seccompProfile:
325+
# type: RuntimeDefault
326+
workerContainerSecurityContext: {}
327+
# Name of a Kubernetes Secret to envFrom-mount into every worker
328+
# pod's container. Pulls every key/value pair in the secret as env
329+
# vars on the worker. Empty disables. Resolved by the kubelet at
330+
# pod creation; the supervisor never reads the secret values, so
331+
# no extra RBAC is required.
332+
workerEnvFromSecret: ""
294333
ephemeralStorageSizeLimit: "" # Default: 10Gi
295334
ephemeralStorageSizeRequest: "" # Default: 2Gi´
296335
podCleaner:

0 commit comments

Comments
 (0)