@intentius/chant-lexicon-k8s 0.0.12

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.
Files changed (123) hide show
  1. package/dist/integrity.json +32 -0
  2. package/dist/manifest.json +8 -0
  3. package/dist/meta.json +1413 -0
  4. package/dist/rules/hardcoded-namespace.ts +56 -0
  5. package/dist/rules/k8s-helpers.ts +149 -0
  6. package/dist/rules/wk8005.ts +59 -0
  7. package/dist/rules/wk8006.ts +68 -0
  8. package/dist/rules/wk8041.ts +73 -0
  9. package/dist/rules/wk8042.ts +48 -0
  10. package/dist/rules/wk8101.ts +65 -0
  11. package/dist/rules/wk8102.ts +42 -0
  12. package/dist/rules/wk8103.ts +45 -0
  13. package/dist/rules/wk8104.ts +69 -0
  14. package/dist/rules/wk8105.ts +45 -0
  15. package/dist/rules/wk8201.ts +55 -0
  16. package/dist/rules/wk8202.ts +46 -0
  17. package/dist/rules/wk8203.ts +46 -0
  18. package/dist/rules/wk8204.ts +54 -0
  19. package/dist/rules/wk8205.ts +56 -0
  20. package/dist/rules/wk8207.ts +45 -0
  21. package/dist/rules/wk8208.ts +45 -0
  22. package/dist/rules/wk8209.ts +45 -0
  23. package/dist/rules/wk8301.ts +51 -0
  24. package/dist/rules/wk8302.ts +46 -0
  25. package/dist/rules/wk8303.ts +96 -0
  26. package/dist/skills/chant-k8s.md +433 -0
  27. package/dist/types/index.d.ts +2934 -0
  28. package/package.json +30 -0
  29. package/src/actions/actions.test.ts +83 -0
  30. package/src/actions/apps.ts +23 -0
  31. package/src/actions/batch.ts +9 -0
  32. package/src/actions/core.ts +62 -0
  33. package/src/actions/index.ts +50 -0
  34. package/src/actions/networking.ts +15 -0
  35. package/src/actions/rbac.ts +13 -0
  36. package/src/codegen/docs-cli.ts +3 -0
  37. package/src/codegen/docs.ts +1147 -0
  38. package/src/codegen/generate-cli.ts +41 -0
  39. package/src/codegen/generate-lexicon.ts +69 -0
  40. package/src/codegen/generate-typescript.ts +97 -0
  41. package/src/codegen/generate.ts +144 -0
  42. package/src/codegen/naming.test.ts +63 -0
  43. package/src/codegen/naming.ts +187 -0
  44. package/src/codegen/package.ts +56 -0
  45. package/src/codegen/patches.ts +108 -0
  46. package/src/codegen/snapshot.test.ts +95 -0
  47. package/src/codegen/typecheck.test.ts +24 -0
  48. package/src/codegen/typecheck.ts +4 -0
  49. package/src/codegen/versions.ts +43 -0
  50. package/src/composites/autoscaled-service.ts +236 -0
  51. package/src/composites/composites.test.ts +1109 -0
  52. package/src/composites/cron-workload.ts +167 -0
  53. package/src/composites/index.ts +14 -0
  54. package/src/composites/namespace-env.ts +163 -0
  55. package/src/composites/node-agent.ts +224 -0
  56. package/src/composites/stateful-app.ts +134 -0
  57. package/src/composites/web-app.ts +180 -0
  58. package/src/composites/worker-pool.ts +230 -0
  59. package/src/coverage.test.ts +27 -0
  60. package/src/coverage.ts +35 -0
  61. package/src/crd/loader.ts +112 -0
  62. package/src/crd/parser.test.ts +217 -0
  63. package/src/crd/parser.ts +279 -0
  64. package/src/crd/types.ts +54 -0
  65. package/src/default-labels.test.ts +111 -0
  66. package/src/default-labels.ts +122 -0
  67. package/src/generated/index.d.ts +2934 -0
  68. package/src/generated/index.ts +203 -0
  69. package/src/generated/lexicon-k8s.json +1413 -0
  70. package/src/generated/runtime.ts +4 -0
  71. package/src/import/generator.test.ts +121 -0
  72. package/src/import/generator.ts +285 -0
  73. package/src/import/parser.test.ts +156 -0
  74. package/src/import/parser.ts +204 -0
  75. package/src/import/roundtrip.test.ts +86 -0
  76. package/src/index.ts +38 -0
  77. package/src/lint/post-synth/k8s-helpers.test.ts +219 -0
  78. package/src/lint/post-synth/k8s-helpers.ts +149 -0
  79. package/src/lint/post-synth/post-synth.test.ts +969 -0
  80. package/src/lint/post-synth/wk8005.ts +59 -0
  81. package/src/lint/post-synth/wk8006.ts +68 -0
  82. package/src/lint/post-synth/wk8041.ts +73 -0
  83. package/src/lint/post-synth/wk8042.ts +48 -0
  84. package/src/lint/post-synth/wk8101.ts +65 -0
  85. package/src/lint/post-synth/wk8102.ts +42 -0
  86. package/src/lint/post-synth/wk8103.ts +45 -0
  87. package/src/lint/post-synth/wk8104.ts +69 -0
  88. package/src/lint/post-synth/wk8105.ts +45 -0
  89. package/src/lint/post-synth/wk8201.ts +55 -0
  90. package/src/lint/post-synth/wk8202.ts +46 -0
  91. package/src/lint/post-synth/wk8203.ts +46 -0
  92. package/src/lint/post-synth/wk8204.ts +54 -0
  93. package/src/lint/post-synth/wk8205.ts +56 -0
  94. package/src/lint/post-synth/wk8207.ts +45 -0
  95. package/src/lint/post-synth/wk8208.ts +45 -0
  96. package/src/lint/post-synth/wk8209.ts +45 -0
  97. package/src/lint/post-synth/wk8301.ts +51 -0
  98. package/src/lint/post-synth/wk8302.ts +46 -0
  99. package/src/lint/post-synth/wk8303.ts +96 -0
  100. package/src/lint/rules/hardcoded-namespace.ts +56 -0
  101. package/src/lint/rules/rules.test.ts +69 -0
  102. package/src/lsp/completions.test.ts +64 -0
  103. package/src/lsp/completions.ts +20 -0
  104. package/src/lsp/hover.test.ts +69 -0
  105. package/src/lsp/hover.ts +68 -0
  106. package/src/package-cli.ts +28 -0
  107. package/src/plugin.test.ts +209 -0
  108. package/src/plugin.ts +915 -0
  109. package/src/serializer.test.ts +275 -0
  110. package/src/serializer.ts +278 -0
  111. package/src/spec/fetch.test.ts +24 -0
  112. package/src/spec/fetch.ts +68 -0
  113. package/src/spec/parse.test.ts +102 -0
  114. package/src/spec/parse.ts +477 -0
  115. package/src/testdata/manifests/configmap.yaml +7 -0
  116. package/src/testdata/manifests/deployment.yaml +22 -0
  117. package/src/testdata/manifests/full-app.yaml +61 -0
  118. package/src/testdata/manifests/secret.yaml +7 -0
  119. package/src/testdata/manifests/service.yaml +15 -0
  120. package/src/validate-cli.ts +21 -0
  121. package/src/validate.test.ts +29 -0
  122. package/src/validate.ts +46 -0
  123. package/src/variables.ts +36 -0
@@ -0,0 +1,1109 @@
1
+ import { describe, test, expect, jest } from "bun:test";
2
+ import { WebApp } from "./web-app";
3
+ import { StatefulApp } from "./stateful-app";
4
+ import { CronWorkload } from "./cron-workload";
5
+ import { AutoscaledService } from "./autoscaled-service";
6
+ import { WorkerPool } from "./worker-pool";
7
+ import { NamespaceEnv } from "./namespace-env";
8
+ import { NodeAgent } from "./node-agent";
9
+
10
+ // ── WebApp ──────────────────────────────────────────────────────────
11
+
12
+ describe("WebApp", () => {
13
+ test("returns deployment and service", () => {
14
+ const result = WebApp({ name: "app", image: "app:1.0" });
15
+ expect(result.deployment).toBeDefined();
16
+ expect(result.service).toBeDefined();
17
+ });
18
+
19
+ test("does not include ingress by default", () => {
20
+ const result = WebApp({ name: "app", image: "app:1.0" });
21
+ expect(result.ingress).toBeUndefined();
22
+ });
23
+
24
+ test("includes ingress when ingressHost is set", () => {
25
+ const result = WebApp({
26
+ name: "app",
27
+ image: "app:1.0",
28
+ ingressHost: "app.example.com",
29
+ });
30
+ expect(result.ingress).toBeDefined();
31
+ const spec = result.ingress!.spec as any;
32
+ expect(spec.rules[0].host).toBe("app.example.com");
33
+ });
34
+
35
+ test("ingress includes TLS when ingressTlsSecret is set", () => {
36
+ const result = WebApp({
37
+ name: "app",
38
+ image: "app:1.0",
39
+ ingressHost: "app.example.com",
40
+ ingressTlsSecret: "tls-secret",
41
+ });
42
+ const spec = result.ingress!.spec as any;
43
+ expect(spec.tls).toBeDefined();
44
+ expect(spec.tls[0].secretName).toBe("tls-secret");
45
+ });
46
+
47
+ test("props flow through (image, port, replicas)", () => {
48
+ const result = WebApp({
49
+ name: "web",
50
+ image: "web:2.0",
51
+ port: 3000,
52
+ replicas: 5,
53
+ });
54
+ const spec = result.deployment.spec as any;
55
+ expect(spec.replicas).toBe(5);
56
+ const container = spec.template.spec.containers[0];
57
+ expect(container.image).toBe("web:2.0");
58
+ expect(container.ports[0].containerPort).toBe(3000);
59
+ });
60
+
61
+ test("default port is 80", () => {
62
+ const result = WebApp({ name: "app", image: "app:1.0" });
63
+ const spec = result.deployment.spec as any;
64
+ const container = spec.template.spec.containers[0];
65
+ expect(container.ports[0].containerPort).toBe(80);
66
+ });
67
+
68
+ test("default replicas is 2", () => {
69
+ const result = WebApp({ name: "app", image: "app:1.0" });
70
+ const spec = result.deployment.spec as any;
71
+ expect(spec.replicas).toBe(2);
72
+ });
73
+
74
+ test("service type is ClusterIP", () => {
75
+ const result = WebApp({ name: "app", image: "app:1.0" });
76
+ const spec = result.service.spec as any;
77
+ expect(spec.type).toBe("ClusterIP");
78
+ });
79
+
80
+ test("includes common labels", () => {
81
+ const result = WebApp({ name: "app", image: "app:1.0" });
82
+ const meta = result.deployment.metadata as any;
83
+ expect(meta.labels["app.kubernetes.io/name"]).toBe("app");
84
+ expect(meta.labels["app.kubernetes.io/managed-by"]).toBe("chant");
85
+ });
86
+
87
+ test("namespace is set when provided", () => {
88
+ const result = WebApp({
89
+ name: "app",
90
+ image: "app:1.0",
91
+ namespace: "prod",
92
+ });
93
+ const meta = result.deployment.metadata as any;
94
+ expect(meta.namespace).toBe("prod");
95
+ });
96
+
97
+ test("env vars passed to container", () => {
98
+ const result = WebApp({
99
+ name: "app",
100
+ image: "app:1.0",
101
+ env: [{ name: "FOO", value: "bar" }],
102
+ });
103
+ const spec = result.deployment.spec as any;
104
+ const container = spec.template.spec.containers[0];
105
+ expect(container.env).toEqual([{ name: "FOO", value: "bar" }]);
106
+ });
107
+
108
+ test("component labels on each resource", () => {
109
+ const result = WebApp({
110
+ name: "app",
111
+ image: "app:1.0",
112
+ ingressHost: "app.example.com",
113
+ });
114
+ expect((result.deployment.metadata as any).labels["app.kubernetes.io/component"]).toBe("server");
115
+ expect((result.service.metadata as any).labels["app.kubernetes.io/component"]).toBe("server");
116
+ expect((result.ingress!.metadata as any).labels["app.kubernetes.io/component"]).toBe("ingress");
117
+ });
118
+ });
119
+
120
+ // ── StatefulApp ─────────────────────────────────────────────────────
121
+
122
+ describe("StatefulApp", () => {
123
+ test("returns statefulSet and service", () => {
124
+ const result = StatefulApp({ name: "db", image: "postgres:16" });
125
+ expect(result.statefulSet).toBeDefined();
126
+ expect(result.service).toBeDefined();
127
+ });
128
+
129
+ test("service is headless (clusterIP: None)", () => {
130
+ const result = StatefulApp({ name: "db", image: "postgres:16" });
131
+ const spec = result.service.spec as any;
132
+ expect(spec.clusterIP).toBe("None");
133
+ });
134
+
135
+ test("includes volumeClaimTemplates", () => {
136
+ const result = StatefulApp({
137
+ name: "db",
138
+ image: "postgres:16",
139
+ storageSize: "20Gi",
140
+ });
141
+ const spec = result.statefulSet.spec as any;
142
+ expect(spec.volumeClaimTemplates).toBeDefined();
143
+ expect(spec.volumeClaimTemplates[0].spec.resources.requests.storage).toBe(
144
+ "20Gi",
145
+ );
146
+ });
147
+
148
+ test("default port is 5432", () => {
149
+ const result = StatefulApp({ name: "db", image: "postgres:16" });
150
+ const spec = result.statefulSet.spec as any;
151
+ const container = spec.template.spec.containers[0];
152
+ expect(container.ports[0].containerPort).toBe(5432);
153
+ });
154
+
155
+ test("default replicas is 1", () => {
156
+ const result = StatefulApp({ name: "db", image: "postgres:16" });
157
+ const spec = result.statefulSet.spec as any;
158
+ expect(spec.replicas).toBe(1);
159
+ });
160
+
161
+ test("serviceName matches name", () => {
162
+ const result = StatefulApp({ name: "db", image: "postgres:16" });
163
+ const spec = result.statefulSet.spec as any;
164
+ expect(spec.serviceName).toBe("db");
165
+ });
166
+
167
+ test("storageClassName set when provided", () => {
168
+ const result = StatefulApp({
169
+ name: "db",
170
+ image: "postgres:16",
171
+ storageClassName: "ssd",
172
+ });
173
+ const spec = result.statefulSet.spec as any;
174
+ expect(spec.volumeClaimTemplates[0].spec.storageClassName).toBe("ssd");
175
+ });
176
+
177
+ test("component labels on each resource", () => {
178
+ const result = StatefulApp({ name: "db", image: "postgres:16" });
179
+ expect((result.statefulSet.metadata as any).labels["app.kubernetes.io/component"]).toBe("database");
180
+ expect((result.service.metadata as any).labels["app.kubernetes.io/component"]).toBe("database");
181
+ });
182
+ });
183
+
184
+ // ── CronWorkload ────────────────────────────────────────────────────
185
+
186
+ describe("CronWorkload", () => {
187
+ test("returns cronJob, serviceAccount, role, roleBinding", () => {
188
+ const result = CronWorkload({
189
+ name: "backup",
190
+ image: "backup:1.0",
191
+ schedule: "0 2 * * *",
192
+ });
193
+ expect(result.cronJob).toBeDefined();
194
+ expect(result.serviceAccount).toBeDefined();
195
+ expect(result.role).toBeDefined();
196
+ expect(result.roleBinding).toBeDefined();
197
+ });
198
+
199
+ test("cronJob has correct schedule", () => {
200
+ const result = CronWorkload({
201
+ name: "backup",
202
+ image: "backup:1.0",
203
+ schedule: "0 2 * * *",
204
+ });
205
+ const spec = result.cronJob.spec as any;
206
+ expect(spec.schedule).toBe("0 2 * * *");
207
+ });
208
+
209
+ test("RBAC references correct ServiceAccount", () => {
210
+ const result = CronWorkload({
211
+ name: "backup",
212
+ image: "backup:1.0",
213
+ schedule: "0 2 * * *",
214
+ });
215
+ const binding = result.roleBinding as any;
216
+ expect(binding.subjects[0].name).toBe("backup-sa");
217
+ expect(binding.roleRef.name).toBe("backup-role");
218
+ });
219
+
220
+ test("serviceAccount name follows naming convention", () => {
221
+ const result = CronWorkload({
222
+ name: "cleanup",
223
+ image: "cleanup:1.0",
224
+ schedule: "*/5 * * * *",
225
+ });
226
+ const meta = result.serviceAccount.metadata as any;
227
+ expect(meta.name).toBe("cleanup-sa");
228
+ });
229
+
230
+ test("custom RBAC rules are used", () => {
231
+ const result = CronWorkload({
232
+ name: "backup",
233
+ image: "backup:1.0",
234
+ schedule: "0 2 * * *",
235
+ rbacRules: [
236
+ { apiGroups: [""], resources: ["secrets"], verbs: ["get"] },
237
+ ],
238
+ });
239
+ const role = result.role as any;
240
+ expect(role.rules[0].resources).toEqual(["secrets"]);
241
+ expect(role.rules[0].verbs).toEqual(["get"]);
242
+ });
243
+
244
+ test("default RBAC rules when none provided", () => {
245
+ const result = CronWorkload({
246
+ name: "backup",
247
+ image: "backup:1.0",
248
+ schedule: "0 2 * * *",
249
+ });
250
+ const role = result.role as any;
251
+ expect(role.rules.length).toBeGreaterThan(0);
252
+ });
253
+
254
+ test("command and args passed through", () => {
255
+ const result = CronWorkload({
256
+ name: "backup",
257
+ image: "backup:1.0",
258
+ schedule: "0 2 * * *",
259
+ command: ["pg_dump"],
260
+ args: ["-h", "postgres"],
261
+ });
262
+ const spec = result.cronJob.spec as any;
263
+ const container =
264
+ spec.jobTemplate.spec.template.spec.containers[0];
265
+ expect(container.command).toEqual(["pg_dump"]);
266
+ expect(container.args).toEqual(["-h", "postgres"]);
267
+ });
268
+
269
+ test("default restartPolicy is OnFailure", () => {
270
+ const result = CronWorkload({
271
+ name: "backup",
272
+ image: "backup:1.0",
273
+ schedule: "0 2 * * *",
274
+ });
275
+ const spec = result.cronJob.spec as any;
276
+ expect(spec.jobTemplate.spec.template.spec.restartPolicy).toBe(
277
+ "OnFailure",
278
+ );
279
+ });
280
+
281
+ test("includes common labels on all resources", () => {
282
+ const result = CronWorkload({
283
+ name: "backup",
284
+ image: "backup:1.0",
285
+ schedule: "0 2 * * *",
286
+ });
287
+
288
+ for (const resource of [
289
+ result.cronJob,
290
+ result.serviceAccount,
291
+ result.role,
292
+ result.roleBinding,
293
+ ]) {
294
+ const meta = resource.metadata as any;
295
+ expect(meta.labels["app.kubernetes.io/name"]).toBe("backup");
296
+ }
297
+ });
298
+
299
+ test("component labels on each resource", () => {
300
+ const result = CronWorkload({
301
+ name: "backup",
302
+ image: "backup:1.0",
303
+ schedule: "0 2 * * *",
304
+ });
305
+ expect((result.cronJob.metadata as any).labels["app.kubernetes.io/component"]).toBe("worker");
306
+ expect((result.serviceAccount.metadata as any).labels["app.kubernetes.io/component"]).toBe("worker");
307
+ expect((result.role.metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
308
+ expect((result.roleBinding.metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
309
+ });
310
+ });
311
+
312
+ // ── AutoscaledService ──────────────────────────────────────────────
313
+
314
+ describe("AutoscaledService", () => {
315
+ const minProps = {
316
+ name: "api",
317
+ image: "api:1.0",
318
+ maxReplicas: 10,
319
+ cpuRequest: "100m",
320
+ memoryRequest: "128Mi",
321
+ };
322
+
323
+ test("returns deployment, service, hpa, pdb", () => {
324
+ const result = AutoscaledService(minProps);
325
+ expect(result.deployment).toBeDefined();
326
+ expect(result.service).toBeDefined();
327
+ expect(result.hpa).toBeDefined();
328
+ expect(result.pdb).toBeDefined();
329
+ });
330
+
331
+ test("default port is 80", () => {
332
+ const result = AutoscaledService(minProps);
333
+ const spec = result.deployment.spec as any;
334
+ const container = spec.template.spec.containers[0];
335
+ expect(container.ports[0].containerPort).toBe(80);
336
+ });
337
+
338
+ test("default minReplicas is 2", () => {
339
+ const result = AutoscaledService(minProps);
340
+ const spec = result.deployment.spec as any;
341
+ expect(spec.replicas).toBe(2);
342
+ const hpaSpec = result.hpa.spec as any;
343
+ expect(hpaSpec.minReplicas).toBe(2);
344
+ });
345
+
346
+ test("HPA scaleTargetRef references the deployment", () => {
347
+ const result = AutoscaledService(minProps);
348
+ const hpaSpec = result.hpa.spec as any;
349
+ expect(hpaSpec.scaleTargetRef.kind).toBe("Deployment");
350
+ expect(hpaSpec.scaleTargetRef.name).toBe("api");
351
+ });
352
+
353
+ test("HPA has CPU metric with default 70%", () => {
354
+ const result = AutoscaledService(minProps);
355
+ const hpaSpec = result.hpa.spec as any;
356
+ expect(hpaSpec.metrics[0].resource.name).toBe("cpu");
357
+ expect(hpaSpec.metrics[0].resource.target.averageUtilization).toBe(70);
358
+ });
359
+
360
+ test("HPA includes memory metric when targetMemoryPercent set", () => {
361
+ const result = AutoscaledService({ ...minProps, targetMemoryPercent: 80 });
362
+ const hpaSpec = result.hpa.spec as any;
363
+ expect(hpaSpec.metrics).toHaveLength(2);
364
+ expect(hpaSpec.metrics[1].resource.name).toBe("memory");
365
+ expect(hpaSpec.metrics[1].resource.target.averageUtilization).toBe(80);
366
+ });
367
+
368
+ test("no memory metric by default", () => {
369
+ const result = AutoscaledService(minProps);
370
+ const hpaSpec = result.hpa.spec as any;
371
+ expect(hpaSpec.metrics).toHaveLength(1);
372
+ });
373
+
374
+ test("PDB selector matches deployment pod labels", () => {
375
+ const result = AutoscaledService(minProps);
376
+ const pdbSpec = result.pdb.spec as any;
377
+ expect(pdbSpec.selector.matchLabels["app.kubernetes.io/name"]).toBe("api");
378
+ expect(pdbSpec.minAvailable).toBe(1);
379
+ });
380
+
381
+ test("resource requests are always set", () => {
382
+ const result = AutoscaledService(minProps);
383
+ const spec = result.deployment.spec as any;
384
+ const container = spec.template.spec.containers[0];
385
+ expect(container.resources.requests.cpu).toBe("100m");
386
+ expect(container.resources.requests.memory).toBe("128Mi");
387
+ });
388
+
389
+ test("resource limits only set when provided", () => {
390
+ const result = AutoscaledService(minProps);
391
+ const spec = result.deployment.spec as any;
392
+ const container = spec.template.spec.containers[0];
393
+ expect(container.resources.limits).toBeUndefined();
394
+
395
+ const withLimits = AutoscaledService({ ...minProps, cpuLimit: "1", memoryLimit: "512Mi" });
396
+ const spec2 = withLimits.deployment.spec as any;
397
+ const container2 = spec2.template.spec.containers[0];
398
+ expect(container2.resources.limits.cpu).toBe("1");
399
+ expect(container2.resources.limits.memory).toBe("512Mi");
400
+ });
401
+
402
+ test("includes health probes", () => {
403
+ const result = AutoscaledService(minProps);
404
+ const spec = result.deployment.spec as any;
405
+ const container = spec.template.spec.containers[0];
406
+ expect(container.livenessProbe).toBeDefined();
407
+ expect(container.readinessProbe).toBeDefined();
408
+ });
409
+
410
+ test("service type is ClusterIP", () => {
411
+ const result = AutoscaledService(minProps);
412
+ const spec = result.service.spec as any;
413
+ expect(spec.type).toBe("ClusterIP");
414
+ });
415
+
416
+ test("includes common labels on all resources", () => {
417
+ const result = AutoscaledService(minProps);
418
+ for (const resource of [result.deployment, result.service, result.hpa, result.pdb]) {
419
+ const meta = resource.metadata as any;
420
+ expect(meta.labels["app.kubernetes.io/name"]).toBe("api");
421
+ expect(meta.labels["app.kubernetes.io/managed-by"]).toBe("chant");
422
+ }
423
+ });
424
+
425
+ test("namespace propagated to all resources", () => {
426
+ const result = AutoscaledService({ ...minProps, namespace: "prod" });
427
+ for (const resource of [result.deployment, result.service, result.hpa, result.pdb]) {
428
+ const meta = resource.metadata as any;
429
+ expect(meta.namespace).toBe("prod");
430
+ }
431
+ });
432
+
433
+ test("env vars passed to container", () => {
434
+ const result = AutoscaledService({
435
+ ...minProps,
436
+ env: [{ name: "LOG_LEVEL", value: "debug" }],
437
+ });
438
+ const spec = result.deployment.spec as any;
439
+ const container = spec.template.spec.containers[0];
440
+ expect(container.env).toEqual([{ name: "LOG_LEVEL", value: "debug" }]);
441
+ });
442
+
443
+ test("component labels on each resource", () => {
444
+ const result = AutoscaledService(minProps);
445
+ expect((result.deployment.metadata as any).labels["app.kubernetes.io/component"]).toBe("server");
446
+ expect((result.service.metadata as any).labels["app.kubernetes.io/component"]).toBe("server");
447
+ expect((result.hpa.metadata as any).labels["app.kubernetes.io/component"]).toBe("autoscaler");
448
+ expect((result.pdb.metadata as any).labels["app.kubernetes.io/component"]).toBe("disruption-budget");
449
+ });
450
+
451
+ test("default probe paths are /healthz and /readyz", () => {
452
+ const result = AutoscaledService(minProps);
453
+ const spec = result.deployment.spec as any;
454
+ const container = spec.template.spec.containers[0];
455
+ expect(container.livenessProbe.httpGet.path).toBe("/healthz");
456
+ expect(container.readinessProbe.httpGet.path).toBe("/readyz");
457
+ });
458
+
459
+ test("custom probe paths override defaults", () => {
460
+ const result = AutoscaledService({
461
+ ...minProps,
462
+ livenessPath: "/alive",
463
+ readinessPath: "/ready",
464
+ });
465
+ const spec = result.deployment.spec as any;
466
+ const container = spec.template.spec.containers[0];
467
+ expect(container.livenessProbe.httpGet.path).toBe("/alive");
468
+ expect(container.readinessProbe.httpGet.path).toBe("/ready");
469
+ });
470
+
471
+ test("probe targets container port", () => {
472
+ const result = AutoscaledService({ ...minProps, port: 8080 });
473
+ const spec = result.deployment.spec as any;
474
+ const container = spec.template.spec.containers[0];
475
+ expect(container.livenessProbe.httpGet.port).toBe(8080);
476
+ expect(container.readinessProbe.httpGet.port).toBe(8080);
477
+ });
478
+
479
+ test("topologySpread not present by default", () => {
480
+ const result = AutoscaledService(minProps);
481
+ const spec = result.deployment.spec as any;
482
+ expect(spec.template.spec.topologySpreadConstraints).toBeUndefined();
483
+ });
484
+
485
+ test("topologySpread: true adds zone constraint", () => {
486
+ const result = AutoscaledService({ ...minProps, topologySpread: true });
487
+ const spec = result.deployment.spec as any;
488
+ const tsc = spec.template.spec.topologySpreadConstraints;
489
+ expect(tsc).toHaveLength(1);
490
+ expect(tsc[0].topologyKey).toBe("topology.kubernetes.io/zone");
491
+ expect(tsc[0].maxSkew).toBe(1);
492
+ expect(tsc[0].whenUnsatisfiable).toBe("DoNotSchedule");
493
+ expect(tsc[0].labelSelector.matchLabels["app.kubernetes.io/name"]).toBe("api");
494
+ });
495
+
496
+ test("topologySpread custom object", () => {
497
+ const result = AutoscaledService({
498
+ ...minProps,
499
+ topologySpread: { maxSkew: 2, topologyKey: "kubernetes.io/hostname" },
500
+ });
501
+ const spec = result.deployment.spec as any;
502
+ const tsc = spec.template.spec.topologySpreadConstraints;
503
+ expect(tsc[0].maxSkew).toBe(2);
504
+ expect(tsc[0].topologyKey).toBe("kubernetes.io/hostname");
505
+ });
506
+
507
+ test("minAvailable as string percentage", () => {
508
+ const result = AutoscaledService({ ...minProps, minAvailable: "50%" });
509
+ const pdbSpec = result.pdb.spec as any;
510
+ expect(pdbSpec.minAvailable).toBe("50%");
511
+ });
512
+
513
+ test("custom targetCPUPercent", () => {
514
+ const result = AutoscaledService({ ...minProps, targetCPUPercent: 85 });
515
+ const hpaSpec = result.hpa.spec as any;
516
+ expect(hpaSpec.metrics[0].resource.target.averageUtilization).toBe(85);
517
+ });
518
+
519
+ test("pod template labels include extra labels", () => {
520
+ const result = AutoscaledService({ ...minProps, labels: { team: "platform" } });
521
+ const spec = result.deployment.spec as any;
522
+ const podLabels = spec.template.metadata.labels;
523
+ expect(podLabels.team).toBe("platform");
524
+ expect(podLabels["app.kubernetes.io/name"]).toBe("api");
525
+ });
526
+ });
527
+
528
+ // ── WorkerPool ─────────────────────────────────────────────────────
529
+
530
+ describe("WorkerPool", () => {
531
+ const minProps = { name: "worker", image: "worker:1.0" };
532
+
533
+ test("returns deployment, serviceAccount, role, roleBinding", () => {
534
+ const result = WorkerPool(minProps);
535
+ expect(result.deployment).toBeDefined();
536
+ expect(result.serviceAccount).toBeDefined();
537
+ expect(result.role).toBeDefined();
538
+ expect(result.roleBinding).toBeDefined();
539
+ });
540
+
541
+ test("no configMap or hpa by default", () => {
542
+ const result = WorkerPool(minProps);
543
+ expect(result.configMap).toBeUndefined();
544
+ expect(result.hpa).toBeUndefined();
545
+ });
546
+
547
+ test("default replicas is 1", () => {
548
+ const result = WorkerPool(minProps);
549
+ const spec = result.deployment.spec as any;
550
+ expect(spec.replicas).toBe(1);
551
+ });
552
+
553
+ test("RBAC naming convention", () => {
554
+ const result = WorkerPool(minProps);
555
+ const saMeta = result.serviceAccount!.metadata as any;
556
+ const roleMeta = result.role!.metadata as any;
557
+ const bindingMeta = result.roleBinding!.metadata as any;
558
+ expect(saMeta.name).toBe("worker-sa");
559
+ expect(roleMeta.name).toBe("worker-role");
560
+ expect(bindingMeta.name).toBe("worker-binding");
561
+ });
562
+
563
+ test("default RBAC rules for secrets and configmaps", () => {
564
+ const result = WorkerPool(minProps);
565
+ const role = result.role! as any;
566
+ expect(role.rules[0].resources).toEqual(["secrets", "configmaps"]);
567
+ expect(role.rules[0].verbs).toEqual(["get"]);
568
+ });
569
+
570
+ test("custom RBAC rules are used", () => {
571
+ const result = WorkerPool({
572
+ ...minProps,
573
+ rbacRules: [{ apiGroups: ["batch"], resources: ["jobs"], verbs: ["create"] }],
574
+ });
575
+ const role = result.role! as any;
576
+ expect(role.rules[0].resources).toEqual(["jobs"]);
577
+ });
578
+
579
+ test("command and args passed through", () => {
580
+ const result = WorkerPool({
581
+ ...minProps,
582
+ command: ["bundle", "exec", "sidekiq"],
583
+ args: ["-c", "5"],
584
+ });
585
+ const spec = result.deployment.spec as any;
586
+ const container = spec.template.spec.containers[0];
587
+ expect(container.command).toEqual(["bundle", "exec", "sidekiq"]);
588
+ expect(container.args).toEqual(["-c", "5"]);
589
+ });
590
+
591
+ test("config creates ConfigMap with envFrom", () => {
592
+ const result = WorkerPool({
593
+ ...minProps,
594
+ config: { REDIS_URL: "redis://redis:6379" },
595
+ });
596
+ expect(result.configMap).toBeDefined();
597
+ const data = (result.configMap as any).data;
598
+ expect(data.REDIS_URL).toBe("redis://redis:6379");
599
+
600
+ const spec = result.deployment.spec as any;
601
+ const container = spec.template.spec.containers[0];
602
+ expect(container.envFrom[0].configMapRef.name).toBe("worker-config");
603
+ });
604
+
605
+ test("autoscaling creates HPA and overrides replicas", () => {
606
+ const result = WorkerPool({
607
+ ...minProps,
608
+ autoscaling: { minReplicas: 2, maxReplicas: 8, targetCPUPercent: 60 },
609
+ });
610
+ expect(result.hpa).toBeDefined();
611
+ const hpaSpec = (result.hpa as any).spec;
612
+ expect(hpaSpec.minReplicas).toBe(2);
613
+ expect(hpaSpec.maxReplicas).toBe(8);
614
+ expect(hpaSpec.scaleTargetRef.name).toBe("worker");
615
+
616
+ const spec = result.deployment.spec as any;
617
+ expect(spec.replicas).toBe(2);
618
+ });
619
+
620
+ test("default resource limits", () => {
621
+ const result = WorkerPool(minProps);
622
+ const spec = result.deployment.spec as any;
623
+ const container = spec.template.spec.containers[0];
624
+ expect(container.resources.requests.cpu).toBe("100m");
625
+ expect(container.resources.requests.memory).toBe("128Mi");
626
+ expect(container.resources.limits.cpu).toBe("500m");
627
+ expect(container.resources.limits.memory).toBe("256Mi");
628
+ });
629
+
630
+ test("serviceAccountName on pod spec", () => {
631
+ const result = WorkerPool(minProps);
632
+ const spec = result.deployment.spec as any;
633
+ expect(spec.template.spec.serviceAccountName).toBe("worker-sa");
634
+ });
635
+
636
+ test("includes common labels on all resources", () => {
637
+ const result = WorkerPool(minProps);
638
+ for (const resource of [result.deployment, result.serviceAccount!, result.role!, result.roleBinding!]) {
639
+ const meta = resource.metadata as any;
640
+ expect(meta.labels["app.kubernetes.io/name"]).toBe("worker");
641
+ expect(meta.labels["app.kubernetes.io/managed-by"]).toBe("chant");
642
+ }
643
+ });
644
+
645
+ test("namespace propagated", () => {
646
+ const result = WorkerPool({ ...minProps, namespace: "jobs" });
647
+ for (const resource of [result.deployment, result.serviceAccount!, result.role!, result.roleBinding!]) {
648
+ const meta = resource.metadata as any;
649
+ expect(meta.namespace).toBe("jobs");
650
+ }
651
+ });
652
+
653
+ test("rbacRules: [] produces no RBAC resources", () => {
654
+ const result = WorkerPool({ ...minProps, rbacRules: [] });
655
+ expect(result.serviceAccount).toBeUndefined();
656
+ expect(result.role).toBeUndefined();
657
+ expect(result.roleBinding).toBeUndefined();
658
+ const spec = result.deployment.spec as any;
659
+ expect(spec.template.spec.serviceAccountName).toBeUndefined();
660
+ });
661
+
662
+ test("rbacRules undefined produces default RBAC", () => {
663
+ const result = WorkerPool(minProps);
664
+ expect(result.serviceAccount).toBeDefined();
665
+ expect(result.role).toBeDefined();
666
+ expect(result.roleBinding).toBeDefined();
667
+ const role = result.role as any;
668
+ expect(role.rules[0].resources).toEqual(["secrets", "configmaps"]);
669
+ });
670
+
671
+ test("env vars on container", () => {
672
+ const result = WorkerPool({
673
+ ...minProps,
674
+ env: [{ name: "LOG_LEVEL", value: "debug" }],
675
+ });
676
+ const spec = result.deployment.spec as any;
677
+ const container = spec.template.spec.containers[0];
678
+ expect(container.env).toEqual([{ name: "LOG_LEVEL", value: "debug" }]);
679
+ });
680
+
681
+ test("ConfigMap carries namespace and labels", () => {
682
+ const result = WorkerPool({
683
+ ...minProps,
684
+ config: { KEY: "val" },
685
+ namespace: "jobs",
686
+ });
687
+ const meta = (result.configMap as any).metadata;
688
+ expect(meta.namespace).toBe("jobs");
689
+ expect(meta.labels["app.kubernetes.io/name"]).toBe("worker");
690
+ });
691
+
692
+ test("HPA carries namespace", () => {
693
+ const result = WorkerPool({
694
+ ...minProps,
695
+ autoscaling: { minReplicas: 1, maxReplicas: 5 },
696
+ namespace: "jobs",
697
+ });
698
+ const meta = (result.hpa as any).metadata;
699
+ expect(meta.namespace).toBe("jobs");
700
+ });
701
+
702
+ test("autoscaling default targetCPUPercent is 70", () => {
703
+ const result = WorkerPool({
704
+ ...minProps,
705
+ autoscaling: { minReplicas: 1, maxReplicas: 5 },
706
+ });
707
+ const hpaSpec = (result.hpa as any).spec;
708
+ expect(hpaSpec.metrics[0].resource.target.averageUtilization).toBe(70);
709
+ });
710
+
711
+ test("component labels on each resource", () => {
712
+ const result = WorkerPool({
713
+ ...minProps,
714
+ config: { K: "V" },
715
+ autoscaling: { minReplicas: 1, maxReplicas: 5 },
716
+ });
717
+ expect((result.deployment.metadata as any).labels["app.kubernetes.io/component"]).toBe("worker");
718
+ expect((result.serviceAccount!.metadata as any).labels["app.kubernetes.io/component"]).toBe("worker");
719
+ expect((result.role!.metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
720
+ expect((result.roleBinding!.metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
721
+ expect((result.configMap!.metadata as any).labels["app.kubernetes.io/component"]).toBe("config");
722
+ expect((result.hpa!.metadata as any).labels["app.kubernetes.io/component"]).toBe("autoscaler");
723
+ });
724
+ });
725
+
726
+ // ── NamespaceEnv ───────────────────────────────────────────────────
727
+
728
+ describe("NamespaceEnv", () => {
729
+ test("returns namespace only with minimal props", () => {
730
+ const result = NamespaceEnv({
731
+ name: "team-alpha",
732
+ defaultDenyIngress: false,
733
+ });
734
+ expect(result.namespace).toBeDefined();
735
+ expect(result.resourceQuota).toBeUndefined();
736
+ expect(result.limitRange).toBeUndefined();
737
+ expect(result.networkPolicy).toBeUndefined();
738
+ });
739
+
740
+ test("default-deny ingress NetworkPolicy created by default", () => {
741
+ const result = NamespaceEnv({ name: "team-alpha" });
742
+ expect(result.networkPolicy).toBeDefined();
743
+ const spec = result.networkPolicy!.spec as any;
744
+ expect(spec.policyTypes).toEqual(["Ingress"]);
745
+ expect(spec.podSelector).toEqual({});
746
+ });
747
+
748
+ test("egress deny when enabled", () => {
749
+ const result = NamespaceEnv({
750
+ name: "team-alpha",
751
+ defaultDenyEgress: true,
752
+ });
753
+ const spec = result.networkPolicy!.spec as any;
754
+ expect(spec.policyTypes).toContain("Ingress");
755
+ expect(spec.policyTypes).toContain("Egress");
756
+ });
757
+
758
+ test("ResourceQuota created when quota props set", () => {
759
+ const result = NamespaceEnv({
760
+ name: "team-alpha",
761
+ cpuQuota: "8",
762
+ memoryQuota: "16Gi",
763
+ maxPods: 50,
764
+ });
765
+ expect(result.resourceQuota).toBeDefined();
766
+ const spec = result.resourceQuota!.spec as any;
767
+ expect(spec.hard["limits.cpu"]).toBe("8");
768
+ expect(spec.hard["limits.memory"]).toBe("16Gi");
769
+ expect(spec.hard.pods).toBe("50");
770
+ });
771
+
772
+ test("ResourceQuota in correct namespace", () => {
773
+ const result = NamespaceEnv({ name: "team-alpha", cpuQuota: "4" });
774
+ const meta = result.resourceQuota!.metadata as any;
775
+ expect(meta.namespace).toBe("team-alpha");
776
+ expect(meta.name).toBe("team-alpha-quota");
777
+ });
778
+
779
+ test("LimitRange created when limit defaults set", () => {
780
+ const result = NamespaceEnv({
781
+ name: "team-alpha",
782
+ defaultCpuRequest: "100m",
783
+ defaultMemoryRequest: "128Mi",
784
+ defaultCpuLimit: "500m",
785
+ defaultMemoryLimit: "512Mi",
786
+ });
787
+ expect(result.limitRange).toBeDefined();
788
+ const spec = result.limitRange!.spec as any;
789
+ const limit = spec.limits[0];
790
+ expect(limit.type).toBe("Container");
791
+ expect(limit.default.cpu).toBe("500m");
792
+ expect(limit.default.memory).toBe("512Mi");
793
+ expect(limit.defaultRequest.cpu).toBe("100m");
794
+ expect(limit.defaultRequest.memory).toBe("128Mi");
795
+ });
796
+
797
+ test("LimitRange in correct namespace", () => {
798
+ const result = NamespaceEnv({ name: "team-alpha", defaultCpuLimit: "1" });
799
+ const meta = result.limitRange!.metadata as any;
800
+ expect(meta.namespace).toBe("team-alpha");
801
+ expect(meta.name).toBe("team-alpha-limits");
802
+ });
803
+
804
+ test("includes common labels", () => {
805
+ const result = NamespaceEnv({ name: "team-alpha" });
806
+ const meta = result.namespace.metadata as any;
807
+ expect(meta.labels["app.kubernetes.io/name"]).toBe("team-alpha");
808
+ expect(meta.labels["app.kubernetes.io/managed-by"]).toBe("chant");
809
+ });
810
+
811
+ test("namespace resource has no namespace field (cluster-scoped)", () => {
812
+ const result = NamespaceEnv({ name: "team-alpha" });
813
+ const meta = result.namespace.metadata as any;
814
+ expect(meta.namespace).toBeUndefined();
815
+ });
816
+
817
+ test("warns when quota set without limits", () => {
818
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
819
+ NamespaceEnv({ name: "warn-test", cpuQuota: "4", defaultDenyIngress: false });
820
+ expect(warnSpy).toHaveBeenCalledWith(
821
+ expect.stringContaining("ResourceQuota set but no LimitRange defaults"),
822
+ );
823
+ warnSpy.mockRestore();
824
+ });
825
+
826
+ test("no warning when both quota and limits set", () => {
827
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
828
+ NamespaceEnv({
829
+ name: "both-test",
830
+ cpuQuota: "4",
831
+ defaultCpuRequest: "100m",
832
+ defaultDenyIngress: false,
833
+ });
834
+ expect(warnSpy).not.toHaveBeenCalled();
835
+ warnSpy.mockRestore();
836
+ });
837
+
838
+ test("no warning when limits only (no quota)", () => {
839
+ const warnSpy = jest.spyOn(console, "warn").mockImplementation(() => {});
840
+ NamespaceEnv({
841
+ name: "limits-only",
842
+ defaultCpuRequest: "100m",
843
+ defaultDenyIngress: false,
844
+ });
845
+ expect(warnSpy).not.toHaveBeenCalled();
846
+ warnSpy.mockRestore();
847
+ });
848
+
849
+ test("egress-only deny (no ingress)", () => {
850
+ const result = NamespaceEnv({
851
+ name: "egress-only",
852
+ defaultDenyIngress: false,
853
+ defaultDenyEgress: true,
854
+ });
855
+ expect(result.networkPolicy).toBeDefined();
856
+ const spec = result.networkPolicy!.spec as any;
857
+ expect(spec.policyTypes).toEqual(["Egress"]);
858
+ });
859
+
860
+ test("NetworkPolicy namespace is the namespace name", () => {
861
+ const result = NamespaceEnv({ name: "team-beta", defaultDenyIngress: true });
862
+ const meta = result.networkPolicy!.metadata as any;
863
+ expect(meta.namespace).toBe("team-beta");
864
+ });
865
+
866
+ test("extra labels on all resources", () => {
867
+ const result = NamespaceEnv({
868
+ name: "team-gamma",
869
+ cpuQuota: "4",
870
+ defaultCpuRequest: "100m",
871
+ defaultDenyIngress: true,
872
+ labels: { env: "staging" },
873
+ });
874
+ for (const resource of [result.namespace, result.resourceQuota!, result.limitRange!, result.networkPolicy!]) {
875
+ const meta = resource.metadata as any;
876
+ expect(meta.labels.env).toBe("staging");
877
+ }
878
+ });
879
+
880
+ test("component labels on each resource", () => {
881
+ const result = NamespaceEnv({
882
+ name: "team-delta",
883
+ cpuQuota: "4",
884
+ defaultCpuRequest: "100m",
885
+ defaultDenyIngress: true,
886
+ });
887
+ expect((result.namespace.metadata as any).labels["app.kubernetes.io/component"]).toBe("namespace");
888
+ expect((result.resourceQuota!.metadata as any).labels["app.kubernetes.io/component"]).toBe("quota");
889
+ expect((result.limitRange!.metadata as any).labels["app.kubernetes.io/component"]).toBe("limits");
890
+ expect((result.networkPolicy!.metadata as any).labels["app.kubernetes.io/component"]).toBe("network-policy");
891
+ });
892
+ });
893
+
894
+ // ── NodeAgent ──────────────────────────────────────────────────────
895
+
896
+ describe("NodeAgent", () => {
897
+ const minProps = {
898
+ name: "log-agent",
899
+ image: "fluentd:v1.16",
900
+ rbacRules: [
901
+ { apiGroups: [""], resources: ["pods", "namespaces"], verbs: ["get", "list", "watch"] },
902
+ ],
903
+ };
904
+
905
+ test("returns daemonSet, serviceAccount, clusterRole, clusterRoleBinding", () => {
906
+ const result = NodeAgent(minProps);
907
+ expect(result.daemonSet).toBeDefined();
908
+ expect(result.serviceAccount).toBeDefined();
909
+ expect(result.clusterRole).toBeDefined();
910
+ expect(result.clusterRoleBinding).toBeDefined();
911
+ });
912
+
913
+ test("no configMap by default", () => {
914
+ const result = NodeAgent(minProps);
915
+ expect(result.configMap).toBeUndefined();
916
+ });
917
+
918
+ test("uses ClusterRole/ClusterRoleBinding (not Role)", () => {
919
+ const result = NodeAgent(minProps);
920
+ const binding = result.clusterRoleBinding as any;
921
+ expect(binding.roleRef.kind).toBe("ClusterRole");
922
+ expect(binding.roleRef.apiGroup).toBe("rbac.authorization.k8s.io");
923
+ });
924
+
925
+ test("ClusterRole/ClusterRoleBinding are cluster-scoped (no namespace)", () => {
926
+ const result = NodeAgent({ ...minProps, namespace: "monitoring" });
927
+ const crMeta = result.clusterRole.metadata as any;
928
+ const crbMeta = result.clusterRoleBinding.metadata as any;
929
+ expect(crMeta.namespace).toBeUndefined();
930
+ expect(crbMeta.namespace).toBeUndefined();
931
+ });
932
+
933
+ test("namespaced resources get namespace", () => {
934
+ const result = NodeAgent({ ...minProps, namespace: "monitoring" });
935
+ const dsMeta = result.daemonSet.metadata as any;
936
+ const saMeta = result.serviceAccount.metadata as any;
937
+ expect(dsMeta.namespace).toBe("monitoring");
938
+ expect(saMeta.namespace).toBe("monitoring");
939
+ });
940
+
941
+ test("tolerateAllTaints adds Exists toleration by default", () => {
942
+ const result = NodeAgent(minProps);
943
+ const spec = result.daemonSet.spec as any;
944
+ const tolerations = spec.template.spec.tolerations;
945
+ expect(tolerations).toEqual([{ operator: "Exists" }]);
946
+ });
947
+
948
+ test("tolerateAllTaints can be disabled", () => {
949
+ const result = NodeAgent({ ...minProps, tolerateAllTaints: false });
950
+ const spec = result.daemonSet.spec as any;
951
+ expect(spec.template.spec.tolerations).toBeUndefined();
952
+ });
953
+
954
+ test("hostPaths mounted with readOnly true by default", () => {
955
+ const result = NodeAgent({
956
+ ...minProps,
957
+ hostPaths: [{ name: "varlog", hostPath: "/var/log", mountPath: "/var/log" }],
958
+ });
959
+ const spec = result.daemonSet.spec as any;
960
+ const volumes = spec.template.spec.volumes;
961
+ expect(volumes[0].name).toBe("varlog");
962
+ expect(volumes[0].hostPath.path).toBe("/var/log");
963
+
964
+ const mounts = spec.template.spec.containers[0].volumeMounts;
965
+ expect(mounts[0].mountPath).toBe("/var/log");
966
+ expect(mounts[0].readOnly).toBe(true);
967
+ });
968
+
969
+ test("hostPaths readOnly can be set to false", () => {
970
+ const result = NodeAgent({
971
+ ...minProps,
972
+ hostPaths: [{ name: "data", hostPath: "/data", mountPath: "/data", readOnly: false }],
973
+ });
974
+ const spec = result.daemonSet.spec as any;
975
+ const mounts = spec.template.spec.containers[0].volumeMounts;
976
+ expect(mounts[0].readOnly).toBe(false);
977
+ });
978
+
979
+ test("config creates ConfigMap mounted at /etc/{name}/", () => {
980
+ const result = NodeAgent({
981
+ ...minProps,
982
+ config: { "fluent.conf": "some config" },
983
+ });
984
+ expect(result.configMap).toBeDefined();
985
+ const data = (result.configMap as any).data;
986
+ expect(data["fluent.conf"]).toBe("some config");
987
+
988
+ const spec = result.daemonSet.spec as any;
989
+ const volumes = spec.template.spec.volumes;
990
+ const configVol = volumes.find((v: any) => v.name === "config");
991
+ expect(configVol.configMap.name).toBe("log-agent-config");
992
+
993
+ const mounts = spec.template.spec.containers[0].volumeMounts;
994
+ const configMount = mounts.find((m: any) => m.name === "config");
995
+ expect(configMount.mountPath).toBe("/etc/log-agent");
996
+ expect(configMount.readOnly).toBe(true);
997
+ });
998
+
999
+ test("port creates metrics port on container", () => {
1000
+ const result = NodeAgent({ ...minProps, port: 9100 });
1001
+ const spec = result.daemonSet.spec as any;
1002
+ const container = spec.template.spec.containers[0];
1003
+ expect(container.ports[0].containerPort).toBe(9100);
1004
+ expect(container.ports[0].name).toBe("metrics");
1005
+ });
1006
+
1007
+ test("RBAC naming convention", () => {
1008
+ const result = NodeAgent(minProps);
1009
+ const saMeta = result.serviceAccount.metadata as any;
1010
+ const crMeta = result.clusterRole.metadata as any;
1011
+ const crbMeta = result.clusterRoleBinding.metadata as any;
1012
+ expect(saMeta.name).toBe("log-agent-sa");
1013
+ expect(crMeta.name).toBe("log-agent-role");
1014
+ expect(crbMeta.name).toBe("log-agent-binding");
1015
+ });
1016
+
1017
+ test("RBAC rules passed through to ClusterRole", () => {
1018
+ const result = NodeAgent(minProps);
1019
+ const cr = result.clusterRole as any;
1020
+ expect(cr.rules[0].resources).toEqual(["pods", "namespaces"]);
1021
+ });
1022
+
1023
+ test("includes common labels on all resources", () => {
1024
+ const result = NodeAgent(minProps);
1025
+ for (const resource of [result.daemonSet, result.serviceAccount, result.clusterRole, result.clusterRoleBinding]) {
1026
+ const meta = resource.metadata as any;
1027
+ expect(meta.labels["app.kubernetes.io/name"]).toBe("log-agent");
1028
+ expect(meta.labels["app.kubernetes.io/managed-by"]).toBe("chant");
1029
+ }
1030
+ });
1031
+
1032
+ test("env vars passed to container", () => {
1033
+ const result = NodeAgent({
1034
+ ...minProps,
1035
+ env: [{ name: "LOG_LEVEL", value: "info" }],
1036
+ });
1037
+ const spec = result.daemonSet.spec as any;
1038
+ const container = spec.template.spec.containers[0];
1039
+ expect(container.env).toEqual([{ name: "LOG_LEVEL", value: "info" }]);
1040
+ });
1041
+
1042
+ test("serviceAccountName on pod spec", () => {
1043
+ const result = NodeAgent(minProps);
1044
+ const spec = result.daemonSet.spec as any;
1045
+ expect(spec.template.spec.serviceAccountName).toBe("log-agent-sa");
1046
+ });
1047
+
1048
+ test("default resource requests and limits on container", () => {
1049
+ const result = NodeAgent(minProps);
1050
+ const spec = result.daemonSet.spec as any;
1051
+ const container = spec.template.spec.containers[0];
1052
+ expect(container.resources.requests.cpu).toBe("50m");
1053
+ expect(container.resources.requests.memory).toBe("64Mi");
1054
+ expect(container.resources.limits.cpu).toBe("200m");
1055
+ expect(container.resources.limits.memory).toBe("128Mi");
1056
+ });
1057
+
1058
+ test("custom resource limits", () => {
1059
+ const result = NodeAgent({
1060
+ ...minProps,
1061
+ cpuRequest: "100m",
1062
+ memoryRequest: "256Mi",
1063
+ cpuLimit: "500m",
1064
+ memoryLimit: "512Mi",
1065
+ });
1066
+ const spec = result.daemonSet.spec as any;
1067
+ const container = spec.template.spec.containers[0];
1068
+ expect(container.resources.requests.cpu).toBe("100m");
1069
+ expect(container.resources.requests.memory).toBe("256Mi");
1070
+ expect(container.resources.limits.cpu).toBe("500m");
1071
+ expect(container.resources.limits.memory).toBe("512Mi");
1072
+ });
1073
+
1074
+ test("multiple hostPaths", () => {
1075
+ const result = NodeAgent({
1076
+ ...minProps,
1077
+ hostPaths: [
1078
+ { name: "varlog", hostPath: "/var/log", mountPath: "/var/log" },
1079
+ { name: "run", hostPath: "/run", mountPath: "/run", readOnly: false },
1080
+ ],
1081
+ });
1082
+ const spec = result.daemonSet.spec as any;
1083
+ expect(spec.template.spec.volumes).toHaveLength(2);
1084
+ expect(spec.template.spec.containers[0].volumeMounts).toHaveLength(2);
1085
+ expect(spec.template.spec.containers[0].volumeMounts[1].readOnly).toBe(false);
1086
+ });
1087
+
1088
+ test("configMap carries namespace", () => {
1089
+ const result = NodeAgent({
1090
+ ...minProps,
1091
+ config: { key: "val" },
1092
+ namespace: "monitoring",
1093
+ });
1094
+ const meta = (result.configMap as any).metadata;
1095
+ expect(meta.namespace).toBe("monitoring");
1096
+ });
1097
+
1098
+ test("component labels on each resource", () => {
1099
+ const result = NodeAgent({
1100
+ ...minProps,
1101
+ config: { key: "val" },
1102
+ });
1103
+ expect((result.daemonSet.metadata as any).labels["app.kubernetes.io/component"]).toBe("agent");
1104
+ expect((result.serviceAccount.metadata as any).labels["app.kubernetes.io/component"]).toBe("agent");
1105
+ expect((result.clusterRole.metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
1106
+ expect((result.clusterRoleBinding.metadata as any).labels["app.kubernetes.io/component"]).toBe("rbac");
1107
+ expect((result.configMap!.metadata as any).labels["app.kubernetes.io/component"]).toBe("config");
1108
+ });
1109
+ });