@crewhaus/helm-chart 0.1.1 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@crewhaus/helm-chart",
3
- "version": "0.1.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.1.1",
16
- "@crewhaus/errors": "0.1.1"
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@studiomax.io",
22
- "url": "https://studiomax.io"
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": "restricted"
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 ─────────────────────────────────────────────