Skip to content

Commit 82e6b4b

Browse files
fix(supervisor): KUBERNETES_WORKER_POD_ANNOTATIONS values must be strings
K8s annotations are 'map[string]string' per API spec. The default JsonStringMap validator on JsonObjectEnv treats each value as a nested Record<string,string>, which silently accepts object values like {} and then crashes at the K8s apiserver: Pod in version v1 cannot be handled as a Pod: json: cannot unmarshal object into Go struct field ObjectMeta.metadata.annotations of type string Real production failure on FedStart with the Rubix pod-cert annotation (`{"com.palantir.rubix.service/pod-cert": {}}`) — pre-fix the env var passed zod validation, then kubernetes.ts spread the object into the Pod spec, and the apiserver rejected with the above. Worker pods never got created → no task runs ever scheduled. Fix: override the valueValidator to z.string() for annotations. Schema now matches K8s annotation reality. Object values are rejected at zod parse time with a clear error before they ever reach the apiserver. Security context env vars stay on JsonAny (their values genuinely ARE nested objects). Tests: add a 'flat string values, K8s-annotation shape' suite covering: - the Rubix pod-cert string value passes - multiple flat string annotations pass - the exact pre-fix failure shape (object value) is now rejected - other non-string values (numeric/boolean/nested map) rejected Regression case is the literal FedStart input that caused the prod crash, so anyone changing this validator in the future has to face it. Co-authored-by: Cursor <cursoragent@cursor.com>
1 parent 0b2fa27 commit 82e6b4b

3 files changed

Lines changed: 80 additions & 3 deletions

File tree

apps/supervisor/src/env.ts

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -95,8 +95,23 @@ const Env = z
9595
KUBERNETES_WORKER_SERVICE_ACCOUNT: z.string().optional(), // Service account for worker pods
9696
KUBERNETES_WORKER_AUTOMOUNT_SERVICE_ACCOUNT_TOKEN: BoolEnv.default(false), // Whether to mount SA token
9797
// 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"),
98+
// sidecar injection, certificate injection, scheduling hints). K8s
99+
// annotation values are always strings (`map[string]string` per the API
100+
// spec) — and `kubernetes.ts` spreads this parsed object straight into
101+
// the pod's `metadata.annotations` without any conversion. So each
102+
// value here must be a string. The default validator on JsonObjectEnv
103+
// is JsonStringMap (nested string→string map), which is the right
104+
// shape for security contexts but the wrong shape for annotations:
105+
// accepting nested objects here would silently pass zod validation
106+
// and then get rejected by the K8s API with
107+
// "Pod in version v1 cannot be handled as a Pod: json: cannot
108+
// unmarshal object into Go struct field
109+
// ObjectMeta.metadata.annotations of type string"
110+
// (Real failure mode hit on FedStart with
111+
// `{"com.palantir.rubix.service/pod-cert": {}}`.)
112+
KUBERNETES_WORKER_POD_ANNOTATIONS: JsonObjectEnv("KUBERNETES_WORKER_POD_ANNOTATIONS", {
113+
valueValidator: z.string(),
114+
}),
100115
// Pod-level securityContext applied to every worker pod (V1PodSecurityContext shape).
101116
// Default is empty `{}`, preserving the upstream behavior of not setting
102117
// a pod-level securityContext. Provide a JSON object to enforce e.g.

apps/supervisor/src/envUtil.test.ts

Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -154,3 +154,65 @@ describe("JsonObjectEnv (arbitrary-value)", () => {
154154
expect(() => named.parse("{notjson")).toThrowError(/KUBERNETES_WORKER_POD_SECURITY_CONTEXT/);
155155
});
156156
});
157+
158+
describe("JsonObjectEnv (flat string values, K8s-annotation shape)", () => {
159+
// Regression test for a real production incident on FedStart: passing
160+
// KUBERNETES_WORKER_POD_ANNOTATIONS with a nested-map value silently
161+
// passed zod validation under the default JsonStringMap validator
162+
// (because `{}` is a valid empty `Record<string,string>`), but then
163+
// got spread directly into a K8s Pod's metadata.annotations and
164+
// rejected by the apiserver:
165+
//
166+
// "Pod in version v1 cannot be handled as a Pod: json: cannot
167+
// unmarshal object into Go struct field
168+
// ObjectMeta.metadata.annotations of type string"
169+
//
170+
// K8s annotations are flat `map[string]string` per the API spec, so
171+
// KUBERNETES_WORKER_POD_ANNOTATIONS must validate values as `z.string()`,
172+
// not as a nested string map.
173+
const schema = JsonObjectEnv("KUBERNETES_WORKER_POD_ANNOTATIONS", {
174+
valueValidator: z.string(),
175+
});
176+
177+
it("accepts the Rubix pod-cert annotation as a string value", () => {
178+
// Rubix's `com.palantir.rubix.service/pod-cert` annotation expects
179+
// its VALUE to be the 2-char string "{}" (which Rubix internally
180+
// parses as JSON to determine cert minting config). The env var
181+
// input is itself JSON-stringified, so the value here is the
182+
// 4-char string `"{}"` — two quotes around two braces.
183+
expect(schema.parse('{"com.palantir.rubix.service/pod-cert":"{}"}')).toEqual({
184+
"com.palantir.rubix.service/pod-cert": "{}",
185+
});
186+
});
187+
188+
it("accepts multiple flat string annotations", () => {
189+
expect(
190+
schema.parse(
191+
'{"com.palantir.rubix.service/pod-cert":"{}","trigger.dev/owner":"supervisor"}'
192+
)
193+
).toEqual({
194+
"com.palantir.rubix.service/pod-cert": "{}",
195+
"trigger.dev/owner": "supervisor",
196+
});
197+
});
198+
199+
it("rejects object values (annotations must be flat strings)", () => {
200+
// The pre-fix bug: this would have passed zod with the default
201+
// JsonStringMap validator, then crashed at K8s apiserver. Schema
202+
// must reject before the value ever reaches K8s.
203+
expect(() => schema.parse('{"com.palantir.rubix.service/pod-cert":{}}')).toThrowError(
204+
/has invalid value/
205+
);
206+
});
207+
208+
it("rejects nested string maps as values", () => {
209+
expect(() => schema.parse('{"some/annotation":{"nested":"value"}}')).toThrowError(
210+
/has invalid value/
211+
);
212+
});
213+
214+
it("rejects numeric and boolean values", () => {
215+
expect(() => schema.parse('{"some/annotation":42}')).toThrowError(/has invalid value/);
216+
expect(() => schema.parse('{"some/annotation":true}')).toThrowError(/has invalid value/);
217+
});
218+
});

hosting/k8s/helm/Chart.yaml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ apiVersion: v2
22
name: trigger
33
description: The official Trigger.dev Helm chart
44
type: application
5-
version: 4.5.0-rc.4-plt663.6
5+
version: 4.5.0-rc.4-plt663.7
66
appVersion: v4.5.0-rc.4
77
home: https://trigger.dev
88
sources:

0 commit comments

Comments
 (0)