@crewhaus/helm-chart 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +7 -12
- package/src/index.test.ts +243 -0
- package/src/index.ts +34 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@crewhaus/helm-chart",
|
|
3
|
-
"version": "0.1.
|
|
3
|
+
"version": "0.1.2",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "Kubernetes Helm chart (templates + values schema + ServiceMonitor + OTel collector sidecar) plus an in-process renderChart() for tests (Section 32)",
|
|
6
6
|
"main": "src/index.ts",
|
|
@@ -12,14 +12,14 @@
|
|
|
12
12
|
"test": "bun test src"
|
|
13
13
|
},
|
|
14
14
|
"dependencies": {
|
|
15
|
-
"@crewhaus/docker-images": "0.
|
|
16
|
-
"@crewhaus/errors": "0.
|
|
15
|
+
"@crewhaus/docker-images": "0.1.2",
|
|
16
|
+
"@crewhaus/errors": "0.1.2"
|
|
17
17
|
},
|
|
18
18
|
"license": "Apache-2.0",
|
|
19
19
|
"author": {
|
|
20
20
|
"name": "Max Meier",
|
|
21
|
-
"email": "max@
|
|
22
|
-
"url": "https://
|
|
21
|
+
"email": "max@crewhaus.ai",
|
|
22
|
+
"url": "https://crewhaus.ai"
|
|
23
23
|
},
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
@@ -31,12 +31,7 @@
|
|
|
31
31
|
"url": "https://github.com/crewhaus/factory/issues"
|
|
32
32
|
},
|
|
33
33
|
"publishConfig": {
|
|
34
|
-
"access": "
|
|
34
|
+
"access": "public"
|
|
35
35
|
},
|
|
36
|
-
"files": [
|
|
37
|
-
"src",
|
|
38
|
-
"README.md",
|
|
39
|
-
"LICENSE",
|
|
40
|
-
"NOTICE"
|
|
41
|
-
]
|
|
36
|
+
"files": ["src", "README.md", "LICENSE", "NOTICE"]
|
|
42
37
|
}
|
package/src/index.test.ts
CHANGED
|
@@ -124,6 +124,55 @@ describe("renderTemplate primitives (T1)", () => {
|
|
|
124
124
|
const ctx = renderContext({ ...defaultValues(), target: "channel" });
|
|
125
125
|
expect(renderTemplate("{{ .Values.target | quote }}", ctx)).toBe(`"channel"`);
|
|
126
126
|
});
|
|
127
|
+
|
|
128
|
+
// `and`/`or` operands are split at top-level spaces; the mini-evaluator
|
|
129
|
+
// resolves bare boolean dot-paths per operand. (Parenthesised operands are
|
|
130
|
+
// only special-cased for has/list, so we use plain boolean paths here.)
|
|
131
|
+
test("and predicate is true only when every operand is truthy", () => {
|
|
132
|
+
const bothOn = renderContext({
|
|
133
|
+
...defaultValues(),
|
|
134
|
+
ingress: { ...defaultValues().ingress, enabled: true },
|
|
135
|
+
otel: { enabled: true, endpoint: "", headers: "" },
|
|
136
|
+
});
|
|
137
|
+
expect(
|
|
138
|
+
renderTemplate("{{ if and .Values.ingress.enabled .Values.otel.enabled }}A{{ end }}", bothOn),
|
|
139
|
+
).toBe("A");
|
|
140
|
+
const oneOff = renderContext({
|
|
141
|
+
...defaultValues(),
|
|
142
|
+
ingress: { ...defaultValues().ingress, enabled: true },
|
|
143
|
+
otel: { enabled: false, endpoint: "", headers: "" },
|
|
144
|
+
});
|
|
145
|
+
expect(
|
|
146
|
+
renderTemplate(
|
|
147
|
+
"{{ if and .Values.ingress.enabled .Values.otel.enabled }}A{{ else }}N{{ end }}",
|
|
148
|
+
oneOff,
|
|
149
|
+
),
|
|
150
|
+
).toBe("N");
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
test("or predicate is true when any operand is truthy", () => {
|
|
154
|
+
// first operand false, second true → true
|
|
155
|
+
const oneOn = renderContext({
|
|
156
|
+
...defaultValues(),
|
|
157
|
+
ingress: { ...defaultValues().ingress, enabled: false },
|
|
158
|
+
otel: { enabled: true, endpoint: "", headers: "" },
|
|
159
|
+
});
|
|
160
|
+
expect(
|
|
161
|
+
renderTemplate("{{ if or .Values.ingress.enabled .Values.otel.enabled }}O{{ end }}", oneOn),
|
|
162
|
+
).toBe("O");
|
|
163
|
+
// every operand false → false (exercises the else arm)
|
|
164
|
+
const allOff = renderContext({
|
|
165
|
+
...defaultValues(),
|
|
166
|
+
ingress: { ...defaultValues().ingress, enabled: false },
|
|
167
|
+
otel: { enabled: false, endpoint: "", headers: "" },
|
|
168
|
+
});
|
|
169
|
+
expect(
|
|
170
|
+
renderTemplate(
|
|
171
|
+
"{{ if or .Values.ingress.enabled .Values.otel.enabled }}O{{ else }}N{{ end }}",
|
|
172
|
+
allOff,
|
|
173
|
+
),
|
|
174
|
+
).toBe("N");
|
|
175
|
+
});
|
|
127
176
|
});
|
|
128
177
|
|
|
129
178
|
describe("renderChart() — full render per shape (T1)", () => {
|
|
@@ -181,3 +230,197 @@ describe("renderChart() — full render per shape (T1)", () => {
|
|
|
181
230
|
expect(out["deployment.yaml"]).not.toContain("SLACK_SIGNING_SECRET");
|
|
182
231
|
});
|
|
183
232
|
});
|
|
233
|
+
|
|
234
|
+
describe("provider secrets + extraEnv (provider-agnostic deployments)", () => {
|
|
235
|
+
test("secrets.provider entries render as secretKeyRef env vars", () => {
|
|
236
|
+
const base = defaultValues();
|
|
237
|
+
const out = renderChart({
|
|
238
|
+
...base,
|
|
239
|
+
target: "cli",
|
|
240
|
+
secrets: {
|
|
241
|
+
...base.secrets,
|
|
242
|
+
provider: [
|
|
243
|
+
{ name: "OPENAI_API_KEY", secretName: "crewhaus-creds", key: "OPENAI_API_KEY" },
|
|
244
|
+
{ name: "GEMINI_API_KEY", secretName: "gemini-creds", key: "api-key" },
|
|
245
|
+
],
|
|
246
|
+
},
|
|
247
|
+
});
|
|
248
|
+
const d = out["deployment.yaml"] ?? "";
|
|
249
|
+
expect(d).toContain("- name: OPENAI_API_KEY");
|
|
250
|
+
expect(d).toContain("name: crewhaus-creds");
|
|
251
|
+
expect(d).toContain("- name: GEMINI_API_KEY");
|
|
252
|
+
expect(d).toContain("name: gemini-creds");
|
|
253
|
+
expect(d).toContain("key: api-key");
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
test("extraEnv entries render as plain name/value env vars (quoted)", () => {
|
|
257
|
+
const out = renderChart({
|
|
258
|
+
...defaultValues(),
|
|
259
|
+
target: "cli",
|
|
260
|
+
extraEnv: [
|
|
261
|
+
{ name: "AWS_REGION", value: "us-east-1" },
|
|
262
|
+
{ name: "OPENAI_BASE_URL", value: "http://vllm.internal:8000/v1" },
|
|
263
|
+
],
|
|
264
|
+
});
|
|
265
|
+
const d = out["deployment.yaml"] ?? "";
|
|
266
|
+
expect(d).toContain("- name: AWS_REGION");
|
|
267
|
+
expect(d).toContain('value: "us-east-1"');
|
|
268
|
+
expect(d).toContain("- name: OPENAI_BASE_URL");
|
|
269
|
+
expect(d).toContain('value: "http://vllm.internal:8000/v1"');
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
test("default (empty) provider/extraEnv lists render nothing extra", () => {
|
|
273
|
+
const out = renderChart({ ...defaultValues(), target: "cli" });
|
|
274
|
+
const d = out["deployment.yaml"] ?? "";
|
|
275
|
+
expect(d).not.toContain("OPENAI_API_KEY");
|
|
276
|
+
expect(d).not.toContain("AWS_REGION");
|
|
277
|
+
});
|
|
278
|
+
|
|
279
|
+
test("validateValues rejects malformed provider/extraEnv entries", () => {
|
|
280
|
+
const base = defaultValues();
|
|
281
|
+
expect(() =>
|
|
282
|
+
validateValues({
|
|
283
|
+
...base,
|
|
284
|
+
secrets: {
|
|
285
|
+
...base.secrets,
|
|
286
|
+
provider: [{ name: "OPENAI_API_KEY", secretName: "", key: "k" }],
|
|
287
|
+
},
|
|
288
|
+
}),
|
|
289
|
+
).toThrow(HelmChartError);
|
|
290
|
+
expect(() =>
|
|
291
|
+
validateValues({
|
|
292
|
+
...base,
|
|
293
|
+
extraEnv: [{ name: "", value: "x" }],
|
|
294
|
+
}),
|
|
295
|
+
).toThrow(HelmChartError);
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
test("non-daemon exec probes call doctor --liveness (plain doctor always failed in-pod)", () => {
|
|
299
|
+
const out = renderChart({ ...defaultValues(), target: "cli" });
|
|
300
|
+
const d = out["deployment.yaml"] ?? "";
|
|
301
|
+
expect(d).toContain('["bun", "/app/crewhaus.js", "doctor", "--liveness"]');
|
|
302
|
+
expect(d).not.toContain('"doctor"]');
|
|
303
|
+
// Daemon shapes keep their httpGet probes untouched.
|
|
304
|
+
const daemon = renderChart({ ...defaultValues(), target: "channel" });
|
|
305
|
+
expect(daemon["deployment.yaml"]).toContain("path: /healthz");
|
|
306
|
+
expect(daemon["deployment.yaml"]).not.toContain("--liveness");
|
|
307
|
+
});
|
|
308
|
+
});
|
|
309
|
+
|
|
310
|
+
describe("range / with bind dot (withDot)", () => {
|
|
311
|
+
// The `range` body re-binds `.` to each item; `.host` then resolves off
|
|
312
|
+
// the per-item dot rather than the outer context. This exercises withDot()
|
|
313
|
+
// and the resolveDotPath `_dot` branch.
|
|
314
|
+
test("range over a non-empty list rebinds `.` to each element", () => {
|
|
315
|
+
const ctx = renderContext({
|
|
316
|
+
...defaultValues(),
|
|
317
|
+
ingress: {
|
|
318
|
+
...defaultValues().ingress,
|
|
319
|
+
hosts: [
|
|
320
|
+
{ host: "a.example.com", paths: [] },
|
|
321
|
+
{ host: "b.example.com", paths: [] },
|
|
322
|
+
],
|
|
323
|
+
},
|
|
324
|
+
});
|
|
325
|
+
const out = renderTemplate("{{ range .Values.ingress.hosts }}host={{ .host }};{{ end }}", ctx);
|
|
326
|
+
expect(out).toBe("host=a.example.com;host=b.example.com;");
|
|
327
|
+
});
|
|
328
|
+
|
|
329
|
+
test("range over an empty list yields nothing (no withDot call)", () => {
|
|
330
|
+
const ctx = renderContext({ ...defaultValues() });
|
|
331
|
+
const out = renderTemplate("{{ range .Values.ingress.hosts }}X{{ end }}", ctx);
|
|
332
|
+
expect(out).toBe("");
|
|
333
|
+
});
|
|
334
|
+
|
|
335
|
+
test("with binds `.` to a non-empty object", () => {
|
|
336
|
+
const ctx = renderContext({
|
|
337
|
+
...defaultValues(),
|
|
338
|
+
ingress: {
|
|
339
|
+
...defaultValues().ingress,
|
|
340
|
+
annotations: { "kubernetes.io/ingress.class": "nginx" },
|
|
341
|
+
},
|
|
342
|
+
});
|
|
343
|
+
// Non-empty object → `with` body executes and dot is rebound via withDot().
|
|
344
|
+
expect(renderTemplate("{{ with .Values.ingress.annotations }}IN{{ end }}", ctx)).toBe("IN");
|
|
345
|
+
});
|
|
346
|
+
|
|
347
|
+
test("with skips an empty object (falsy guard, no withDot call)", () => {
|
|
348
|
+
const ctx = renderContext({ ...defaultValues() });
|
|
349
|
+
// The mini-renderer's `with` has no else arm — the whole body (incl. any
|
|
350
|
+
// stray `else`) is skipped when the value is an empty object, so we get "".
|
|
351
|
+
expect(renderTemplate("{{ with .Values.ingress.annotations }}IN{{ end }}", ctx)).toBe("");
|
|
352
|
+
});
|
|
353
|
+
});
|
|
354
|
+
|
|
355
|
+
describe("toYaml filter (simpleYaml)", () => {
|
|
356
|
+
// toYaml is only reachable as a *pipe* filter: `{{ X | toYaml }}`. (The
|
|
357
|
+
// chart templates' `{{- toYaml . | nindent N }}` head form is treated as a
|
|
358
|
+
// literal by this mini-renderer, which is why simpleYaml was uncovered.)
|
|
359
|
+
test("renders a nested object with multiline children", () => {
|
|
360
|
+
// .Values.resources is { requests: {...}, limits: {...} } — exercises the
|
|
361
|
+
// object branch, the nested-object (inner includes '\n') branch, strings,
|
|
362
|
+
// and the recursive descent.
|
|
363
|
+
const ctx = renderContext({ ...defaultValues() });
|
|
364
|
+
const out = renderTemplate("{{ .Values.resources | toYaml }}", ctx);
|
|
365
|
+
expect(out).toContain("requests:");
|
|
366
|
+
expect(out).toContain('cpu: "100m"');
|
|
367
|
+
expect(out).toContain('memory: "256Mi"');
|
|
368
|
+
expect(out).toContain("limits:");
|
|
369
|
+
expect(out).toContain('cpu: "1000m"');
|
|
370
|
+
});
|
|
371
|
+
|
|
372
|
+
test("renders booleans and numbers unquoted", () => {
|
|
373
|
+
const ctx = renderContext({
|
|
374
|
+
...defaultValues(),
|
|
375
|
+
podSecurityContext: { runAsNonRoot: true, runAsUser: 10001 },
|
|
376
|
+
});
|
|
377
|
+
const out = renderTemplate("{{ .Values.podSecurityContext | toYaml }}", ctx);
|
|
378
|
+
expect(out).toContain("runAsNonRoot: true");
|
|
379
|
+
expect(out).toContain("runAsUser: 10001");
|
|
380
|
+
});
|
|
381
|
+
|
|
382
|
+
test("renders a non-empty array as YAML list items", () => {
|
|
383
|
+
const ctx = renderContext({
|
|
384
|
+
...defaultValues(),
|
|
385
|
+
image: {
|
|
386
|
+
...defaultValues().image,
|
|
387
|
+
pullSecrets: [{ name: "regcred" }, { name: "ghcr" }],
|
|
388
|
+
},
|
|
389
|
+
});
|
|
390
|
+
const out = renderTemplate("{{ .Values.image.pullSecrets | toYaml }}", ctx);
|
|
391
|
+
expect(out).toContain('- name: "regcred"');
|
|
392
|
+
expect(out).toContain('- name: "ghcr"');
|
|
393
|
+
});
|
|
394
|
+
|
|
395
|
+
test("renders an empty array as []", () => {
|
|
396
|
+
const ctx = renderContext({ ...defaultValues() });
|
|
397
|
+
// defaultValues().image.pullSecrets is [].
|
|
398
|
+
expect(renderTemplate("{{ .Values.image.pullSecrets | toYaml }}", ctx)).toBe("[]");
|
|
399
|
+
});
|
|
400
|
+
|
|
401
|
+
test("renders an empty object as {}", () => {
|
|
402
|
+
const ctx = renderContext({ ...defaultValues() });
|
|
403
|
+
// defaultValues().serviceMonitor.labels is {}.
|
|
404
|
+
expect(renderTemplate("{{ .Values.serviceMonitor.labels | toYaml }}", ctx)).toBe("{}");
|
|
405
|
+
});
|
|
406
|
+
|
|
407
|
+
test("renders a missing path (undefined) as null", () => {
|
|
408
|
+
const ctx = renderContext({ ...defaultValues() });
|
|
409
|
+
expect(renderTemplate("{{ .Values.doesNotExist | toYaml }}", ctx)).toBe("null");
|
|
410
|
+
});
|
|
411
|
+
|
|
412
|
+
test("renders a nested array-of-objects (recursive list + object branch)", () => {
|
|
413
|
+
const ctx = renderContext({
|
|
414
|
+
...defaultValues(),
|
|
415
|
+
ingress: {
|
|
416
|
+
...defaultValues().ingress,
|
|
417
|
+
tls: [{ hosts: ["a.example.com", "b.example.com"], secretName: "tls-cert" }],
|
|
418
|
+
},
|
|
419
|
+
});
|
|
420
|
+
const out = renderTemplate("{{ .Values.ingress.tls | toYaml }}", ctx);
|
|
421
|
+
expect(out).toContain('secretName: "tls-cert"');
|
|
422
|
+
// hosts is a string array nested under an object inside a list item.
|
|
423
|
+
expect(out).toContain("a.example.com");
|
|
424
|
+
expect(out).toContain("b.example.com");
|
|
425
|
+
});
|
|
426
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -78,7 +78,25 @@ export type ChartValues = {
|
|
|
78
78
|
readonly signing: { readonly secretName: string; readonly key: string };
|
|
79
79
|
readonly bot: { readonly secretName: string; readonly key: string };
|
|
80
80
|
};
|
|
81
|
+
/**
|
|
82
|
+
* Generic provider secret list — one container env var per entry,
|
|
83
|
+
* each sourced from a Kubernetes Secret. The escape hatch for
|
|
84
|
+
* non-Anthropic models (OPENAI_API_KEY, GEMINI_API_KEY/GOOGLE_API_KEY,
|
|
85
|
+
* AWS_BEARER_TOKEN_BEDROCK/AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY, …).
|
|
86
|
+
* Presets are documented in values.yaml.
|
|
87
|
+
*/
|
|
88
|
+
readonly provider?: ReadonlyArray<{
|
|
89
|
+
readonly name: string;
|
|
90
|
+
readonly secretName: string;
|
|
91
|
+
readonly key: string;
|
|
92
|
+
}>;
|
|
81
93
|
};
|
|
94
|
+
/**
|
|
95
|
+
* Plain (non-secret) env vars appended to the container — provider
|
|
96
|
+
* region/endpoint selectors like AWS_REGION, OPENAI_BASE_URL,
|
|
97
|
+
* GOOGLE_CLOUD_PROJECT.
|
|
98
|
+
*/
|
|
99
|
+
readonly extraEnv?: ReadonlyArray<{ readonly name: string; readonly value: string }>;
|
|
82
100
|
readonly service: {
|
|
83
101
|
readonly type: "ClusterIP" | "LoadBalancer" | "NodePort";
|
|
84
102
|
readonly port: number;
|
|
@@ -117,7 +135,9 @@ export function defaultValues(): ChartValues {
|
|
|
117
135
|
signing: { secretName: "crewhaus-creds", key: "SLACK_SIGNING_SECRET" },
|
|
118
136
|
bot: { secretName: "crewhaus-creds", key: "SLACK_BOT_TOKEN" },
|
|
119
137
|
},
|
|
138
|
+
provider: [],
|
|
120
139
|
},
|
|
140
|
+
extraEnv: [],
|
|
121
141
|
service: { type: "ClusterIP", port: 80, targetPort: 3000 },
|
|
122
142
|
ingress: {
|
|
123
143
|
enabled: false,
|
|
@@ -160,6 +180,20 @@ export function validateValues(values: ChartValues): void {
|
|
|
160
180
|
if (!values.image.tag) {
|
|
161
181
|
throw new HelmChartError("image.tag must be non-empty");
|
|
162
182
|
}
|
|
183
|
+
for (const entry of values.secrets.provider ?? []) {
|
|
184
|
+
if (!entry.name || !entry.secretName || !entry.key) {
|
|
185
|
+
throw new HelmChartError(
|
|
186
|
+
`secrets.provider entries need name + secretName + key; got ${JSON.stringify(entry)}`,
|
|
187
|
+
);
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
for (const entry of values.extraEnv ?? []) {
|
|
191
|
+
if (!entry.name || typeof entry.value !== "string") {
|
|
192
|
+
throw new HelmChartError(
|
|
193
|
+
`extraEnv entries need name + string value; got ${JSON.stringify(entry)}`,
|
|
194
|
+
);
|
|
195
|
+
}
|
|
196
|
+
}
|
|
163
197
|
}
|
|
164
198
|
|
|
165
199
|
// ─── Minimal helm-shape renderer ─────────────────────────────────────────────
|