@crewhaus/helm-chart 0.1.4 → 0.1.6

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/src/index.test.ts DELETED
@@ -1,426 +0,0 @@
1
- import { describe, expect, test } from "bun:test";
2
- import { TARGET_SHAPES } from "@crewhaus/docker-images";
3
-
4
- import {
5
- HelmChartError,
6
- chartRoot,
7
- defaultValues,
8
- isDaemonShape,
9
- readTemplate,
10
- renderChart,
11
- renderContext,
12
- renderTemplate,
13
- templateFiles,
14
- validateValues,
15
- } from "./index";
16
-
17
- describe("chart on disk", () => {
18
- test("Chart.yaml + values.yaml + templates/ exist", () => {
19
- expect(chartRoot()).toMatch(/helm-chart\/helm\/crewhaus$/);
20
- const files = templateFiles();
21
- expect(files).toContain("deployment.yaml");
22
- expect(files).toContain("service.yaml");
23
- expect(files).toContain("ingress.yaml");
24
- expect(files).toContain("servicemonitor.yaml");
25
- expect(files).toContain("otel-collector.yaml");
26
- expect(files).toContain("_helpers.tpl");
27
- });
28
-
29
- test("readTemplate refuses traversal", () => {
30
- expect(() => readTemplate("../../../etc/passwd")).toThrow(HelmChartError);
31
- expect(() => readTemplate("nope.yaml")).toThrow(/template not found/);
32
- });
33
- });
34
-
35
- describe("defaultValues + validateValues", () => {
36
- test("default values pass validation", () => {
37
- expect(() => validateValues(defaultValues())).not.toThrow();
38
- });
39
-
40
- test("rejects unknown target", () => {
41
- const v = { ...defaultValues(), target: "bogus" } as never;
42
- expect(() => validateValues(v)).toThrow(/unknown target/);
43
- });
44
-
45
- test("rejects negative replicas + invalid port + empty tag", () => {
46
- expect(() => validateValues({ ...defaultValues(), replicas: -1 })).toThrow();
47
- expect(() =>
48
- validateValues({ ...defaultValues(), service: { ...defaultValues().service, port: 0 } }),
49
- ).toThrow();
50
- expect(() =>
51
- validateValues({ ...defaultValues(), image: { ...defaultValues().image, tag: "" } }),
52
- ).toThrow();
53
- });
54
- });
55
-
56
- describe("isDaemonShape", () => {
57
- test("daemon shapes are channel/managed/crew/voice/browser", () => {
58
- expect(isDaemonShape("channel")).toBe(true);
59
- expect(isDaemonShape("managed")).toBe(true);
60
- expect(isDaemonShape("crew")).toBe(true);
61
- expect(isDaemonShape("voice")).toBe(true);
62
- expect(isDaemonShape("browser")).toBe(true);
63
- });
64
-
65
- test("non-daemon shapes do not get a Service rendered", () => {
66
- expect(isDaemonShape("cli")).toBe(false);
67
- expect(isDaemonShape("workflow")).toBe(false);
68
- expect(isDaemonShape("graph")).toBe(false);
69
- expect(isDaemonShape("pipeline")).toBe(false);
70
- expect(isDaemonShape("research")).toBe(false);
71
- expect(isDaemonShape("batch")).toBe(false);
72
- expect(isDaemonShape("eval")).toBe(false);
73
- });
74
- });
75
-
76
- describe("renderTemplate primitives (T1)", () => {
77
- test("variable interpolation", () => {
78
- const ctx = renderContext(defaultValues(), "smoke");
79
- expect(renderTemplate("name: {{ .Release.Name }}", ctx)).toBe("name: smoke");
80
- });
81
-
82
- test("if true / if false branches", () => {
83
- const ctx = renderContext({
84
- ...defaultValues(),
85
- serviceMonitor: { enabled: true, interval: "30s", labels: {} },
86
- });
87
- expect(
88
- renderTemplate("{{ if .Values.serviceMonitor.enabled }}YES{{ else }}NO{{ end }}", ctx),
89
- ).toContain("YES");
90
- const ctxOff = renderContext({
91
- ...defaultValues(),
92
- serviceMonitor: { enabled: false, interval: "30s", labels: {} },
93
- });
94
- expect(
95
- renderTemplate("{{ if .Values.serviceMonitor.enabled }}YES{{ else }}NO{{ end }}", ctxOff),
96
- ).toContain("NO");
97
- });
98
-
99
- test("has X (list ...) predicate", () => {
100
- const ctxChannel = renderContext({ ...defaultValues(), target: "channel" });
101
- expect(
102
- renderTemplate(
103
- '{{ if has .Values.target (list "channel" "managed") }}DAEMON{{ end }}',
104
- ctxChannel,
105
- ),
106
- ).toContain("DAEMON");
107
- const ctxCli = renderContext({ ...defaultValues(), target: "cli" });
108
- expect(
109
- renderTemplate(
110
- '{{ if has .Values.target (list "channel" "managed") }}DAEMON{{ end }}',
111
- ctxCli,
112
- ),
113
- ).not.toContain("DAEMON");
114
- });
115
-
116
- test("eq predicate", () => {
117
- const ctx = renderContext({ ...defaultValues(), target: "channel" });
118
- expect(renderTemplate('{{ if eq .Values.target "channel" }}YES{{ end }}', ctx)).toContain(
119
- "YES",
120
- );
121
- });
122
-
123
- test("quote filter wraps strings in double quotes", () => {
124
- const ctx = renderContext({ ...defaultValues(), target: "channel" });
125
- expect(renderTemplate("{{ .Values.target | quote }}", ctx)).toBe(`"channel"`);
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
- });
176
- });
177
-
178
- describe("renderChart() — full render per shape (T1)", () => {
179
- for (const target of TARGET_SHAPES) {
180
- test(`${target}: deployment.yaml is a Deployment with the right image + replicas`, () => {
181
- const out = renderChart({ ...defaultValues(), target, replicas: 2 });
182
- expect(out["deployment.yaml"]).toContain("kind: Deployment");
183
- expect(out["deployment.yaml"]).toContain(`crewhaus/${target}:`);
184
- expect(out["deployment.yaml"]).toContain("replicas: 2");
185
- expect(out["deployment.yaml"]).toContain("livenessProbe");
186
- });
187
- }
188
-
189
- test("daemon shape (channel) renders Service", () => {
190
- const out = renderChart({ ...defaultValues(), target: "channel" });
191
- expect(out["service.yaml"]).toContain("kind: Service");
192
- expect(out["service.yaml"]).toContain("port: 80");
193
- });
194
-
195
- test("non-daemon shape (cli) does NOT render Service", () => {
196
- const out = renderChart({ ...defaultValues(), target: "cli" });
197
- expect(out["service.yaml"]?.includes("kind: Service")).toBeFalsy();
198
- });
199
-
200
- test("ServiceMonitor render gates on serviceMonitor.enabled", () => {
201
- const enabled = renderChart({ ...defaultValues(), target: "channel" });
202
- expect(enabled["servicemonitor.yaml"]).toContain("kind: ServiceMonitor");
203
- const disabled = renderChart({
204
- ...defaultValues(),
205
- target: "channel",
206
- serviceMonitor: { enabled: false, interval: "30s", labels: {} },
207
- });
208
- expect(disabled["servicemonitor.yaml"]?.includes("kind: ServiceMonitor")).toBeFalsy();
209
- });
210
-
211
- test("OTel ConfigMap renders only when otel.enabled", () => {
212
- const enabled = renderChart({ ...defaultValues(), target: "channel" });
213
- expect(enabled["otel-collector.yaml"]).toContain("kind: ConfigMap");
214
- const disabled = renderChart({
215
- ...defaultValues(),
216
- target: "channel",
217
- otel: { enabled: false, endpoint: "", headers: "" },
218
- });
219
- expect(disabled["otel-collector.yaml"]?.includes("kind: ConfigMap")).toBeFalsy();
220
- });
221
-
222
- test("channel deployment wires Slack secret env vars", () => {
223
- const out = renderChart({ ...defaultValues(), target: "channel" });
224
- expect(out["deployment.yaml"]).toContain("SLACK_SIGNING_SECRET");
225
- expect(out["deployment.yaml"]).toContain("SLACK_BOT_TOKEN");
226
- });
227
-
228
- test("cli deployment does NOT wire Slack env vars", () => {
229
- const out = renderChart({ ...defaultValues(), target: "cli" });
230
- expect(out["deployment.yaml"]).not.toContain("SLACK_SIGNING_SECRET");
231
- });
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
- });