@intentius/chant-lexicon-k8s 0.0.14 → 0.0.15

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 (48) hide show
  1. package/dist/integrity.json +6 -3
  2. package/dist/manifest.json +1 -1
  3. package/dist/rules/wk8204.ts +33 -1
  4. package/dist/rules/wk8304.ts +70 -0
  5. package/dist/rules/wk8305.ts +115 -0
  6. package/dist/rules/wk8306.ts +50 -0
  7. package/package.json +27 -24
  8. package/src/codegen/docs.ts +1 -1
  9. package/src/composites/adot-collector.ts +8 -2
  10. package/src/composites/agic-ingress.ts +149 -0
  11. package/src/composites/alb-ingress.ts +2 -1
  12. package/src/composites/autoscaled-service.ts +25 -7
  13. package/src/composites/azure-disk-storage-class.ts +82 -0
  14. package/src/composites/azure-file-storage-class.ts +77 -0
  15. package/src/composites/azure-monitor-collector.ts +232 -0
  16. package/src/composites/batch-job.ts +36 -3
  17. package/src/composites/composites.test.ts +701 -0
  18. package/src/composites/config-connector-context.ts +62 -0
  19. package/src/composites/configured-app.ts +6 -0
  20. package/src/composites/cron-workload.ts +6 -0
  21. package/src/composites/ebs-storage-class.ts +4 -4
  22. package/src/composites/external-dns-agent.ts +6 -0
  23. package/src/composites/filestore-storage-class.ts +79 -0
  24. package/src/composites/fluent-bit-agent.ts +5 -0
  25. package/src/composites/gce-pd-storage-class.ts +85 -0
  26. package/src/composites/gke-gateway.ts +143 -0
  27. package/src/composites/index.ts +19 -0
  28. package/src/composites/metrics-server.ts +1 -1
  29. package/src/composites/monitored-service.ts +6 -0
  30. package/src/composites/network-isolated-app.ts +6 -0
  31. package/src/composites/node-agent.ts +6 -0
  32. package/src/composites/security-context.ts +10 -0
  33. package/src/composites/sidecar-app.ts +6 -0
  34. package/src/composites/stateful-app.ts +4 -7
  35. package/src/composites/web-app.ts +4 -7
  36. package/src/composites/worker-pool.ts +4 -7
  37. package/src/composites/workload-identity-sa.ts +118 -0
  38. package/src/composites/workload-identity-service-account.ts +116 -0
  39. package/src/index.ts +6 -1
  40. package/src/lint/post-synth/post-synth.test.ts +362 -1
  41. package/src/lint/post-synth/wk8204.ts +33 -1
  42. package/src/lint/post-synth/wk8304.ts +70 -0
  43. package/src/lint/post-synth/wk8305.ts +115 -0
  44. package/src/lint/post-synth/wk8306.ts +50 -0
  45. package/src/plugin.test.ts +2 -2
  46. package/src/plugin.ts +4 -1
  47. package/src/serializer.test.ts +120 -0
  48. package/src/serializer.ts +16 -4
@@ -1,4 +1,5 @@
1
1
  import { describe, test, expect, jest } from "bun:test";
2
+ import { emitYAML } from "@intentius/chant/yaml";
2
3
  import { WebApp } from "./web-app";
3
4
  import { StatefulApp } from "./stateful-app";
4
5
  import { CronWorkload } from "./cron-workload";
@@ -20,6 +21,11 @@ import { FluentBitAgent } from "./fluent-bit-agent";
20
21
  import { ExternalDnsAgent } from "./external-dns-agent";
21
22
  import { AdotCollector } from "./adot-collector";
22
23
  import { MetricsServer } from "./metrics-server";
24
+ import { WorkloadIdentityServiceAccount } from "./workload-identity-service-account";
25
+ import { GcePdStorageClass } from "./gce-pd-storage-class";
26
+ import { FilestoreStorageClass } from "./filestore-storage-class";
27
+ import { GkeGateway } from "./gke-gateway";
28
+ import { ConfigConnectorContext } from "./config-connector-context";
23
29
 
24
30
  // ── WebApp ──────────────────────────────────────────────────────────
25
31
 
@@ -537,6 +543,63 @@ describe("AutoscaledService", () => {
537
543
  expect(podLabels.team).toBe("platform");
538
544
  expect(podLabels["app.kubernetes.io/name"]).toBe("api");
539
545
  });
546
+
547
+ test("serviceAccountName wired into pod spec", () => {
548
+ const result = AutoscaledService({ ...minProps, serviceAccountName: "my-sa" });
549
+ const spec = result.deployment.spec as any;
550
+ expect(spec.template.spec.serviceAccountName).toBe("my-sa");
551
+ });
552
+
553
+ test("no serviceAccountName by default", () => {
554
+ const result = AutoscaledService(minProps);
555
+ const spec = result.deployment.spec as any;
556
+ expect(spec.template.spec.serviceAccountName).toBeUndefined();
557
+ });
558
+
559
+ test("volumes and volumeMounts wired into pod spec", () => {
560
+ const result = AutoscaledService({
561
+ ...minProps,
562
+ volumes: [{ name: "data", emptyDir: {} }],
563
+ volumeMounts: [{ name: "data", mountPath: "/data" }],
564
+ });
565
+ const spec = result.deployment.spec as any;
566
+ expect(spec.template.spec.volumes).toEqual([{ name: "data", emptyDir: {} }]);
567
+ expect(spec.template.spec.containers[0].volumeMounts).toEqual([{ name: "data", mountPath: "/data" }]);
568
+ });
569
+
570
+ test("tmpDirs generates emptyDir volumes and mounts", () => {
571
+ const result = AutoscaledService({
572
+ ...minProps,
573
+ tmpDirs: ["/tmp", "/var/cache/nginx"],
574
+ });
575
+ const spec = result.deployment.spec as any;
576
+ expect(spec.template.spec.volumes).toEqual([
577
+ { name: "tmp-0", emptyDir: {} },
578
+ { name: "tmp-1", emptyDir: {} },
579
+ ]);
580
+ expect(spec.template.spec.containers[0].volumeMounts).toEqual([
581
+ { name: "tmp-0", mountPath: "/tmp" },
582
+ { name: "tmp-1", mountPath: "/var/cache/nginx" },
583
+ ]);
584
+ });
585
+
586
+ test("tmpDirs merges with explicit volumes/volumeMounts", () => {
587
+ const result = AutoscaledService({
588
+ ...minProps,
589
+ volumes: [{ name: "config", configMap: { name: "app-config" } }],
590
+ volumeMounts: [{ name: "config", mountPath: "/etc/config" }],
591
+ tmpDirs: ["/tmp"],
592
+ });
593
+ const spec = result.deployment.spec as any;
594
+ expect(spec.template.spec.volumes).toEqual([
595
+ { name: "config", configMap: { name: "app-config" } },
596
+ { name: "tmp-0", emptyDir: {} },
597
+ ]);
598
+ expect(spec.template.spec.containers[0].volumeMounts).toEqual([
599
+ { name: "config", mountPath: "/etc/config" },
600
+ { name: "tmp-0", mountPath: "/tmp" },
601
+ ]);
602
+ });
540
603
  });
541
604
 
542
605
  // ── WorkerPool ─────────────────────────────────────────────────────
@@ -1160,6 +1223,25 @@ describe("WebApp hardening", () => {
1160
1223
  expect(container.securityContext.runAsNonRoot).toBe(true);
1161
1224
  });
1162
1225
 
1226
+ test("securityContext supports PSS restricted fields", () => {
1227
+ const result = WebApp({
1228
+ name: "app",
1229
+ image: "app:1.0",
1230
+ securityContext: {
1231
+ runAsNonRoot: true,
1232
+ readOnlyRootFilesystem: true,
1233
+ allowPrivilegeEscalation: false,
1234
+ capabilities: { drop: ["ALL"] },
1235
+ seccompProfile: { type: "RuntimeDefault" },
1236
+ },
1237
+ });
1238
+ const spec = result.deployment.spec as any;
1239
+ const sc = spec.template.spec.containers[0].securityContext;
1240
+ expect(sc.allowPrivilegeEscalation).toBe(false);
1241
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
1242
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
1243
+ });
1244
+
1163
1245
  test("terminationGracePeriodSeconds set", () => {
1164
1246
  const result = WebApp({ name: "app", image: "app:1.0", terminationGracePeriodSeconds: 60 });
1165
1247
  const spec = result.deployment.spec as any;
@@ -1216,6 +1298,24 @@ describe("StatefulApp hardening", () => {
1216
1298
  const spec = result.statefulSet.spec as any;
1217
1299
  expect(spec.template.spec.containers[0].securityContext.runAsNonRoot).toBe(true);
1218
1300
  });
1301
+
1302
+ test("securityContext supports PSS restricted fields", () => {
1303
+ const result = StatefulApp({
1304
+ name: "db",
1305
+ image: "postgres:16",
1306
+ securityContext: {
1307
+ runAsNonRoot: true,
1308
+ allowPrivilegeEscalation: false,
1309
+ capabilities: { drop: ["ALL"] },
1310
+ seccompProfile: { type: "RuntimeDefault" },
1311
+ },
1312
+ });
1313
+ const spec = result.statefulSet.spec as any;
1314
+ const sc = spec.template.spec.containers[0].securityContext;
1315
+ expect(sc.allowPrivilegeEscalation).toBe(false);
1316
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
1317
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
1318
+ });
1219
1319
  });
1220
1320
 
1221
1321
  describe("WorkerPool hardening", () => {
@@ -1235,6 +1335,24 @@ describe("WorkerPool hardening", () => {
1235
1335
  const spec = result.deployment.spec as any;
1236
1336
  expect(spec.template.spec.containers[0].securityContext.runAsNonRoot).toBe(true);
1237
1337
  });
1338
+
1339
+ test("securityContext supports PSS restricted fields", () => {
1340
+ const result = WorkerPool({
1341
+ name: "w",
1342
+ image: "w:1.0",
1343
+ securityContext: {
1344
+ runAsNonRoot: true,
1345
+ allowPrivilegeEscalation: false,
1346
+ capabilities: { drop: ["ALL"] },
1347
+ seccompProfile: { type: "RuntimeDefault" },
1348
+ },
1349
+ });
1350
+ const spec = result.deployment.spec as any;
1351
+ const sc = spec.template.spec.containers[0].securityContext;
1352
+ expect(sc.allowPrivilegeEscalation).toBe(false);
1353
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
1354
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
1355
+ });
1238
1356
  });
1239
1357
 
1240
1358
  describe("AutoscaledService hardening", () => {
@@ -1258,6 +1376,23 @@ describe("AutoscaledService hardening", () => {
1258
1376
  expect(spec.template.spec.containers[0].securityContext.runAsNonRoot).toBe(true);
1259
1377
  });
1260
1378
 
1379
+ test("securityContext supports PSS restricted fields", () => {
1380
+ const result = AutoscaledService({
1381
+ ...minProps,
1382
+ securityContext: {
1383
+ runAsNonRoot: true,
1384
+ allowPrivilegeEscalation: false,
1385
+ capabilities: { drop: ["ALL"] },
1386
+ seccompProfile: { type: "RuntimeDefault" },
1387
+ },
1388
+ });
1389
+ const spec = result.deployment.spec as any;
1390
+ const sc = spec.template.spec.containers[0].securityContext;
1391
+ expect(sc.allowPrivilegeEscalation).toBe(false);
1392
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
1393
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
1394
+ });
1395
+
1261
1396
  test("terminationGracePeriodSeconds set", () => {
1262
1397
  const result = AutoscaledService({ ...minProps, terminationGracePeriodSeconds: 30 });
1263
1398
  const spec = result.deployment.spec as any;
@@ -1704,6 +1839,25 @@ describe("AlbIngress", () => {
1704
1839
  const meta = result.ingress.metadata as any;
1705
1840
  expect(meta.annotations["alb.ingress.kubernetes.io/scheme"]).toBe("internal");
1706
1841
  });
1842
+
1843
+ test("empty-string certificateArn does NOT set ssl-redirect", () => {
1844
+ const result = AlbIngress({ ...minProps, certificateArn: "" });
1845
+ const meta = result.ingress.metadata as any;
1846
+ expect(meta.annotations["alb.ingress.kubernetes.io/ssl-redirect"]).toBeUndefined();
1847
+ expect(meta.annotations["alb.ingress.kubernetes.io/certificate-arn"]).toBeUndefined();
1848
+ });
1849
+
1850
+ test("explicit sslRedirect: true overrides empty certificateArn", () => {
1851
+ const result = AlbIngress({ ...minProps, certificateArn: "", sslRedirect: true });
1852
+ const meta = result.ingress.metadata as any;
1853
+ expect(meta.annotations["alb.ingress.kubernetes.io/ssl-redirect"]).toBe("443");
1854
+ });
1855
+
1856
+ test("explicit sslRedirect: false suppresses redirect even with cert", () => {
1857
+ const result = AlbIngress({ ...minProps, certificateArn: "arn:aws:acm:us-east-1:123:cert/abc", sslRedirect: false });
1858
+ const meta = result.ingress.metadata as any;
1859
+ expect(meta.annotations["alb.ingress.kubernetes.io/ssl-redirect"]).toBeUndefined();
1860
+ });
1707
1861
  });
1708
1862
 
1709
1863
  // ── EbsStorageClass ─────────────────────────────────────────────────
@@ -1742,6 +1896,20 @@ describe("EbsStorageClass", () => {
1742
1896
  const result = EbsStorageClass({ name: "sc" });
1743
1897
  expect((result.storageClass.metadata as any).namespace).toBeUndefined();
1744
1898
  });
1899
+
1900
+ test("numeric iops and throughput coerced to strings", () => {
1901
+ const result = EbsStorageClass({ name: "perf", iops: 5000, throughput: 250 });
1902
+ const params = (result.storageClass as any).parameters;
1903
+ expect(params.iops).toBe("5000");
1904
+ expect(params.throughput).toBe("250");
1905
+ });
1906
+
1907
+ test("string iops and throughput passed through", () => {
1908
+ const result = EbsStorageClass({ name: "perf", iops: "3000", throughput: "125" });
1909
+ const params = (result.storageClass as any).parameters;
1910
+ expect(params.iops).toBe("3000");
1911
+ expect(params.throughput).toBe("125");
1912
+ });
1745
1913
  });
1746
1914
 
1747
1915
  // ── EfsStorageClass ─────────────────────────────────────────────────
@@ -1990,3 +2158,536 @@ describe("MetricsServer", () => {
1990
2158
  expect(spec.template.spec.containers[0].image).toBe("custom:v1");
1991
2159
  });
1992
2160
  });
2161
+
2162
+ // ── PSS restricted securityContext passthrough tests ─────────────
2163
+
2164
+ const pssSecurityContext = {
2165
+ runAsNonRoot: true,
2166
+ readOnlyRootFilesystem: true,
2167
+ allowPrivilegeEscalation: false,
2168
+ capabilities: { drop: ["ALL"] },
2169
+ seccompProfile: { type: "RuntimeDefault" },
2170
+ };
2171
+
2172
+ describe("BatchJob securityContext", () => {
2173
+ test("securityContext passed through to container", () => {
2174
+ const result = BatchJob({
2175
+ name: "migrate",
2176
+ image: "migrate:1.0",
2177
+ securityContext: pssSecurityContext,
2178
+ });
2179
+ const spec = result.job.spec as any;
2180
+ const sc = spec.template.spec.containers[0].securityContext;
2181
+ expect(sc.runAsNonRoot).toBe(true);
2182
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2183
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
2184
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
2185
+ });
2186
+ });
2187
+
2188
+ describe("CronWorkload securityContext", () => {
2189
+ test("securityContext passed through to container", () => {
2190
+ const result = CronWorkload({
2191
+ name: "backup",
2192
+ image: "backup:1.0",
2193
+ schedule: "0 2 * * *",
2194
+ securityContext: pssSecurityContext,
2195
+ });
2196
+ const spec = result.cronJob.spec as any;
2197
+ const sc = spec.jobTemplate.spec.template.spec.containers[0].securityContext;
2198
+ expect(sc.runAsNonRoot).toBe(true);
2199
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2200
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
2201
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
2202
+ });
2203
+ });
2204
+
2205
+ describe("NodeAgent securityContext", () => {
2206
+ test("securityContext passed through to container", () => {
2207
+ const result = NodeAgent({
2208
+ name: "agent",
2209
+ image: "agent:1.0",
2210
+ rbacRules: [{ apiGroups: [""], resources: ["pods"], verbs: ["get"] }],
2211
+ securityContext: pssSecurityContext,
2212
+ });
2213
+ const spec = result.daemonSet.spec as any;
2214
+ const sc = spec.template.spec.containers[0].securityContext;
2215
+ expect(sc.runAsNonRoot).toBe(true);
2216
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2217
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
2218
+ });
2219
+ });
2220
+
2221
+ describe("ConfiguredApp securityContext", () => {
2222
+ test("securityContext passed through to container", () => {
2223
+ const result = ConfiguredApp({
2224
+ name: "api",
2225
+ image: "api:1.0",
2226
+ securityContext: pssSecurityContext,
2227
+ });
2228
+ const spec = result.deployment.spec as any;
2229
+ const sc = spec.template.spec.containers[0].securityContext;
2230
+ expect(sc.runAsNonRoot).toBe(true);
2231
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2232
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
2233
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
2234
+ });
2235
+ });
2236
+
2237
+ describe("SidecarApp securityContext", () => {
2238
+ test("securityContext passed through to primary container", () => {
2239
+ const result = SidecarApp({
2240
+ name: "api",
2241
+ image: "api:1.0",
2242
+ sidecars: [{ name: "envoy", image: "envoy:1.0" }],
2243
+ securityContext: pssSecurityContext,
2244
+ });
2245
+ const spec = result.deployment.spec as any;
2246
+ const sc = spec.template.spec.containers[0].securityContext;
2247
+ expect(sc.runAsNonRoot).toBe(true);
2248
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2249
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
2250
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
2251
+ });
2252
+ });
2253
+
2254
+ describe("NetworkIsolatedApp securityContext", () => {
2255
+ test("securityContext passed through to container", () => {
2256
+ const result = NetworkIsolatedApp({
2257
+ name: "api",
2258
+ image: "api:1.0",
2259
+ securityContext: pssSecurityContext,
2260
+ });
2261
+ const spec = result.deployment.spec as any;
2262
+ const sc = spec.template.spec.containers[0].securityContext;
2263
+ expect(sc.runAsNonRoot).toBe(true);
2264
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2265
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
2266
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
2267
+ });
2268
+ });
2269
+
2270
+ describe("MonitoredService securityContext", () => {
2271
+ test("securityContext passed through to container", () => {
2272
+ const result = MonitoredService({
2273
+ name: "api",
2274
+ image: "api:1.0",
2275
+ securityContext: pssSecurityContext,
2276
+ });
2277
+ const spec = result.deployment.spec as any;
2278
+ const sc = spec.template.spec.containers[0].securityContext;
2279
+ expect(sc.runAsNonRoot).toBe(true);
2280
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2281
+ expect(sc.capabilities).toEqual({ drop: ["ALL"] });
2282
+ expect(sc.seccompProfile).toEqual({ type: "RuntimeDefault" });
2283
+ });
2284
+ });
2285
+
2286
+ // ── Infrastructure composites: hardcoded security defaults ──────
2287
+
2288
+ describe("ExternalDnsAgent security defaults", () => {
2289
+ test("container has hardcoded PSS-safe securityContext", () => {
2290
+ const result = ExternalDnsAgent({
2291
+ iamRoleArn: "arn:aws:iam::123456789012:role/test",
2292
+ domainFilters: ["example.com"],
2293
+ });
2294
+ const spec = result.deployment.spec as any;
2295
+ const sc = spec.template.spec.containers[0].securityContext;
2296
+ expect(sc.runAsNonRoot).toBe(true);
2297
+ expect(sc.readOnlyRootFilesystem).toBe(true);
2298
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2299
+ });
2300
+ });
2301
+
2302
+ describe("FluentBitAgent security defaults", () => {
2303
+ test("container runs as root (needs host log access) with other PSS hardening", () => {
2304
+ const result = FluentBitAgent({
2305
+ logGroup: "/aws/eks/test/containers",
2306
+ region: "us-east-1",
2307
+ clusterName: "test",
2308
+ });
2309
+ const spec = result.daemonSet.spec as any;
2310
+ const sc = spec.template.spec.containers[0].securityContext;
2311
+ expect(sc.runAsUser).toBe(0);
2312
+ expect(sc.runAsNonRoot).toBeUndefined();
2313
+ expect(sc.readOnlyRootFilesystem).toBe(true);
2314
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2315
+ });
2316
+ });
2317
+
2318
+ describe("AdotCollector security defaults", () => {
2319
+ test("container runs as non-root with explicit UID (ADOT 'aoc' user)", () => {
2320
+ const result = AdotCollector({
2321
+ region: "us-east-1",
2322
+ clusterName: "test",
2323
+ });
2324
+ const spec = result.daemonSet.spec as any;
2325
+ const sc = spec.template.spec.containers[0].securityContext;
2326
+ expect(sc.runAsNonRoot).toBe(true);
2327
+ expect(sc.runAsUser).toBe(10001);
2328
+ expect(sc.readOnlyRootFilesystem).toBe(true);
2329
+ expect(sc.allowPrivilegeEscalation).toBe(false);
2330
+ });
2331
+ });
2332
+
2333
+ // ── Phase 3B: Composite serialization smoke tests ───────────────
2334
+
2335
+ describe("Composite YAML serialization smoke tests", () => {
2336
+ function serializeCompositeProps(props: Record<string, Record<string, unknown>>): string {
2337
+ return Object.values(props)
2338
+ .map((p) => emitYAML(p, 0))
2339
+ .join("\n---\n");
2340
+ }
2341
+
2342
+ test("ExternalDnsAgent serializes to valid YAML", () => {
2343
+ const result = ExternalDnsAgent({
2344
+ iamRoleArn: "arn:aws:iam::123456789012:role/test",
2345
+ domainFilters: ["example.com"],
2346
+ });
2347
+ const yaml = serializeCompositeProps(result as any);
2348
+ expect(yaml).toContain("external-dns");
2349
+ expect(yaml).not.toContain("[object Object]");
2350
+ });
2351
+
2352
+ test("FluentBitAgent serializes multiline config correctly", () => {
2353
+ const result = FluentBitAgent({
2354
+ logGroup: "/aws/eks/test/containers",
2355
+ region: "us-east-1",
2356
+ clusterName: "test",
2357
+ });
2358
+ const yaml = serializeCompositeProps(result as any);
2359
+ // Multiline config should use | block scalar, not flatten
2360
+ expect(yaml).toContain("|");
2361
+ expect(yaml).toContain("[SERVICE]");
2362
+ expect(yaml).toContain("[INPUT]");
2363
+ expect(yaml).not.toContain("\\n");
2364
+ });
2365
+
2366
+ test("AdotCollector serializes multiline config correctly", () => {
2367
+ const result = AdotCollector({
2368
+ region: "us-east-1",
2369
+ clusterName: "test",
2370
+ });
2371
+ const yaml = serializeCompositeProps(result as any);
2372
+ expect(yaml).toContain("|");
2373
+ expect(yaml).toContain("receivers:");
2374
+ expect(yaml).toContain("exporters:");
2375
+ expect(yaml).not.toContain("\\n");
2376
+ });
2377
+
2378
+ test("MetricsServer serializes to valid YAML", () => {
2379
+ const result = MetricsServer({});
2380
+ const yaml = serializeCompositeProps(result as any);
2381
+ expect(yaml).toContain("metrics-server");
2382
+ expect(yaml).not.toContain("[object Object]");
2383
+ });
2384
+ });
2385
+
2386
+ // ── Phase 4A: MetricsServer RBAC completeness ──────────────────
2387
+
2388
+ describe("MetricsServer RBAC completeness", () => {
2389
+ test("clusterRole includes configmaps resource", () => {
2390
+ const result = MetricsServer({});
2391
+ const rules = result.clusterRole.rules as any[];
2392
+ const hasConfigmaps = rules.some((r: any) => r.resources?.includes("configmaps"));
2393
+ expect(hasConfigmaps).toBe(true);
2394
+ });
2395
+ });
2396
+
2397
+ // ── Phase 4B: AdotCollector command vs args ─────────────────────
2398
+
2399
+ describe("AdotCollector command vs args", () => {
2400
+ test("container uses args (not command) for config flag", () => {
2401
+ const result = AdotCollector({
2402
+ region: "us-east-1",
2403
+ clusterName: "test",
2404
+ });
2405
+ const spec = result.daemonSet.spec as any;
2406
+ const container = spec.template.spec.containers[0];
2407
+ // Config flag should be in args, not command
2408
+ expect(container.args).toContain("--config=/etc/adot/config.yaml");
2409
+ expect(container.command).toBeUndefined();
2410
+ });
2411
+ });
2412
+
2413
+ // ── Phase 4C: AdotCollector pipeline exporter separation ────────
2414
+
2415
+ describe("AdotCollector pipeline exporter separation", () => {
2416
+ test("metrics pipeline does NOT include awsxray", () => {
2417
+ const result = AdotCollector({
2418
+ region: "us-east-1",
2419
+ clusterName: "test",
2420
+ exporters: ["cloudwatch", "xray"],
2421
+ });
2422
+ const config = (result.configMap as any).data["config.yaml"] as string;
2423
+ // Extract metrics pipeline exporters line
2424
+ const metricsMatch = config.match(/metrics:\s*\n\s*receivers:.*\n\s*processors:.*\n\s*exporters:\s*\[([^\]]+)\]/);
2425
+ expect(metricsMatch).toBeDefined();
2426
+ const metricsExporters = metricsMatch![1];
2427
+ expect(metricsExporters).not.toContain("awsxray");
2428
+ expect(metricsExporters).toContain("awsemf");
2429
+ });
2430
+
2431
+ test("traces pipeline does NOT include awsemf", () => {
2432
+ const result = AdotCollector({
2433
+ region: "us-east-1",
2434
+ clusterName: "test",
2435
+ exporters: ["cloudwatch", "xray"],
2436
+ });
2437
+ const config = (result.configMap as any).data["config.yaml"] as string;
2438
+ // Extract traces pipeline exporters line
2439
+ const tracesMatch = config.match(/traces:\s*\n\s*receivers:.*\n\s*processors:.*\n\s*exporters:\s*\[([^\]]+)\]/);
2440
+ expect(tracesMatch).toBeDefined();
2441
+ const tracesExporters = tracesMatch![1];
2442
+ expect(tracesExporters).not.toContain("awsemf");
2443
+ expect(tracesExporters).toContain("awsxray");
2444
+ });
2445
+
2446
+ test("cloudwatch-only: traces pipeline falls back to valid default", () => {
2447
+ const result = AdotCollector({
2448
+ region: "us-east-1",
2449
+ clusterName: "test",
2450
+ exporters: ["cloudwatch"],
2451
+ });
2452
+ const config = (result.configMap as any).data["config.yaml"] as string;
2453
+ // Traces pipeline should still have an exporter (fallback to awsxray)
2454
+ const tracesMatch = config.match(/traces:\s*\n\s*receivers:.*\n\s*processors:.*\n\s*exporters:\s*\[([^\]]+)\]/);
2455
+ expect(tracesMatch).toBeDefined();
2456
+ const tracesExporters = tracesMatch![1].trim();
2457
+ expect(tracesExporters.length).toBeGreaterThan(0);
2458
+ });
2459
+ });
2460
+
2461
+ // ── Phase 4D: AdotCollector config parseable as YAML ────────────
2462
+
2463
+ describe("AdotCollector config structure", () => {
2464
+ test("generated config has required top-level sections", () => {
2465
+ const result = AdotCollector({
2466
+ region: "us-east-1",
2467
+ clusterName: "test",
2468
+ });
2469
+ const config = (result.configMap as any).data["config.yaml"] as string;
2470
+ expect(config).toContain("receivers:");
2471
+ expect(config).toContain("exporters:");
2472
+ expect(config).toContain("processors:");
2473
+ expect(config).toContain("service:");
2474
+ expect(config).toContain("pipelines:");
2475
+ expect(config).toContain("metrics:");
2476
+ expect(config).toContain("traces:");
2477
+ });
2478
+ });
2479
+
2480
+ // ── WorkloadIdentityServiceAccount ──────────────────────────────────
2481
+
2482
+ describe("WorkloadIdentityServiceAccount", () => {
2483
+ const minProps = { name: "app-sa", gcpServiceAccountEmail: "sa@my-project.iam.gserviceaccount.com" };
2484
+
2485
+ test("returns serviceAccount with Workload Identity annotation", () => {
2486
+ const result = WorkloadIdentityServiceAccount(minProps);
2487
+ expect(result.serviceAccount).toBeDefined();
2488
+ const meta = result.serviceAccount.metadata as any;
2489
+ expect(meta.annotations["iam.gke.io/gcp-service-account"]).toBe("sa@my-project.iam.gserviceaccount.com");
2490
+ });
2491
+
2492
+ test("no RBAC by default", () => {
2493
+ const result = WorkloadIdentityServiceAccount(minProps);
2494
+ expect(result.role).toBeUndefined();
2495
+ expect(result.roleBinding).toBeUndefined();
2496
+ });
2497
+
2498
+ test("RBAC created when rules provided", () => {
2499
+ const result = WorkloadIdentityServiceAccount({
2500
+ ...minProps,
2501
+ rbacRules: [{ apiGroups: [""], resources: ["secrets"], verbs: ["get"] }],
2502
+ });
2503
+ expect(result.role).toBeDefined();
2504
+ expect(result.roleBinding).toBeDefined();
2505
+ const role = result.role as any;
2506
+ expect(role.rules[0].resources).toEqual(["secrets"]);
2507
+ });
2508
+
2509
+ test("namespace propagated", () => {
2510
+ const result = WorkloadIdentityServiceAccount({ ...minProps, namespace: "prod" });
2511
+ expect((result.serviceAccount.metadata as any).namespace).toBe("prod");
2512
+ });
2513
+
2514
+ test("component labels", () => {
2515
+ const result = WorkloadIdentityServiceAccount(minProps);
2516
+ expect((result.serviceAccount.metadata as any).labels["app.kubernetes.io/component"]).toBe("service-account");
2517
+ });
2518
+ });
2519
+
2520
+ // ── GcePdStorageClass ───────────────────────────────────────────────
2521
+
2522
+ describe("GcePdStorageClass", () => {
2523
+ test("returns storageClass with GCE PD provisioner", () => {
2524
+ const result = GcePdStorageClass({ name: "pd-balanced" });
2525
+ expect(result.storageClass).toBeDefined();
2526
+ expect((result.storageClass as any).provisioner).toBe("pd.csi.storage.gke.io");
2527
+ });
2528
+
2529
+ test("default type is pd-balanced", () => {
2530
+ const result = GcePdStorageClass({ name: "default" });
2531
+ expect((result.storageClass as any).parameters.type).toBe("pd-balanced");
2532
+ });
2533
+
2534
+ test("custom type", () => {
2535
+ const result = GcePdStorageClass({ name: "ssd", type: "pd-ssd" });
2536
+ expect((result.storageClass as any).parameters.type).toBe("pd-ssd");
2537
+ });
2538
+
2539
+ test("regional-pd replication type", () => {
2540
+ const result = GcePdStorageClass({ name: "regional", replicationType: "regional-pd" });
2541
+ expect((result.storageClass as any).parameters["replication-type"]).toBe("regional-pd");
2542
+ });
2543
+
2544
+ test("no replication-type param when none", () => {
2545
+ const result = GcePdStorageClass({ name: "default" });
2546
+ expect((result.storageClass as any).parameters["replication-type"]).toBeUndefined();
2547
+ });
2548
+
2549
+ test("allowVolumeExpansion default true", () => {
2550
+ const result = GcePdStorageClass({ name: "exp" });
2551
+ expect((result.storageClass as any).allowVolumeExpansion).toBe(true);
2552
+ });
2553
+
2554
+ test("storageClass is cluster-scoped (no namespace)", () => {
2555
+ const result = GcePdStorageClass({ name: "sc" });
2556
+ expect((result.storageClass.metadata as any).namespace).toBeUndefined();
2557
+ });
2558
+ });
2559
+
2560
+ // ── FilestoreStorageClass ───────────────────────────────────────────
2561
+
2562
+ describe("FilestoreStorageClass", () => {
2563
+ test("returns storageClass with Filestore provisioner", () => {
2564
+ const result = FilestoreStorageClass({ name: "filestore" });
2565
+ expect((result.storageClass as any).provisioner).toBe("filestore.csi.storage.gke.io");
2566
+ });
2567
+
2568
+ test("default tier is standard", () => {
2569
+ const result = FilestoreStorageClass({ name: "fs" });
2570
+ expect((result.storageClass as any).parameters.tier).toBe("standard");
2571
+ });
2572
+
2573
+ test("custom tier", () => {
2574
+ const result = FilestoreStorageClass({ name: "premium-fs", tier: "premium" });
2575
+ expect((result.storageClass as any).parameters.tier).toBe("premium");
2576
+ });
2577
+
2578
+ test("network parameter set when provided", () => {
2579
+ const result = FilestoreStorageClass({ name: "fs", network: "my-vpc" });
2580
+ expect((result.storageClass as any).parameters.network).toBe("my-vpc");
2581
+ });
2582
+
2583
+ test("no network parameter by default", () => {
2584
+ const result = FilestoreStorageClass({ name: "fs" });
2585
+ expect((result.storageClass as any).parameters.network).toBeUndefined();
2586
+ });
2587
+ });
2588
+
2589
+ // ── GkeGateway ──────────────────────────────────────────────────────
2590
+
2591
+ describe("GkeGateway", () => {
2592
+ const minProps = {
2593
+ name: "api-gateway",
2594
+ hosts: [{ hostname: "api.example.com", paths: [{ path: "/", serviceName: "api", servicePort: 80 }] }],
2595
+ };
2596
+
2597
+ test("returns gateway and httpRoute", () => {
2598
+ const result = GkeGateway(minProps);
2599
+ expect(result.gateway).toBeDefined();
2600
+ expect(result.httpRoute).toBeDefined();
2601
+ });
2602
+
2603
+ test("default gatewayClassName", () => {
2604
+ const result = GkeGateway(minProps);
2605
+ const spec = result.gateway.spec as any;
2606
+ expect(spec.gatewayClassName).toBe("gke-l7-global-external-managed");
2607
+ });
2608
+
2609
+ test("custom gatewayClassName", () => {
2610
+ const result = GkeGateway({ ...minProps, gatewayClassName: "gke-l7-rilb" });
2611
+ const spec = result.gateway.spec as any;
2612
+ expect(spec.gatewayClassName).toBe("gke-l7-rilb");
2613
+ });
2614
+
2615
+ test("HTTP listener when no certificate", () => {
2616
+ const result = GkeGateway(minProps);
2617
+ const spec = result.gateway.spec as any;
2618
+ expect(spec.listeners[0].protocol).toBe("HTTP");
2619
+ expect(spec.listeners[0].port).toBe(80);
2620
+ });
2621
+
2622
+ test("HTTPS listener with certificate", () => {
2623
+ const result = GkeGateway({ ...minProps, certificateName: "api-cert" });
2624
+ const spec = result.gateway.spec as any;
2625
+ expect(spec.listeners[0].protocol).toBe("HTTPS");
2626
+ expect(spec.listeners[0].port).toBe(443);
2627
+ expect(spec.listeners[0].tls.certificateRefs[0].name).toBe("api-cert");
2628
+ });
2629
+
2630
+ test("httpRoute references parent gateway", () => {
2631
+ const result = GkeGateway(minProps);
2632
+ const spec = result.httpRoute.spec as any;
2633
+ expect(spec.parentRefs[0].name).toBe("api-gateway");
2634
+ });
2635
+
2636
+ test("httpRoute has hostnames", () => {
2637
+ const result = GkeGateway(minProps);
2638
+ const spec = result.httpRoute.spec as any;
2639
+ expect(spec.hostnames).toEqual(["api.example.com"]);
2640
+ });
2641
+
2642
+ test("httpRoute rules map to backend services", () => {
2643
+ const result = GkeGateway(minProps);
2644
+ const spec = result.httpRoute.spec as any;
2645
+ expect(spec.rules[0].backendRefs[0].name).toBe("api");
2646
+ expect(spec.rules[0].backendRefs[0].port).toBe(80);
2647
+ });
2648
+
2649
+ test("namespace propagated to both resources", () => {
2650
+ const result = GkeGateway({ ...minProps, namespace: "prod" });
2651
+ expect((result.gateway.metadata as any).namespace).toBe("prod");
2652
+ expect((result.httpRoute.metadata as any).namespace).toBe("prod");
2653
+ });
2654
+ });
2655
+
2656
+ // ── ConfigConnectorContext ───────────────────────────────────────────
2657
+
2658
+ describe("ConfigConnectorContext", () => {
2659
+ const minProps = { googleServiceAccountEmail: "cnrm@my-project.iam.gserviceaccount.com" };
2660
+
2661
+ test("returns context with apiVersion and kind", () => {
2662
+ const result = ConfigConnectorContext(minProps);
2663
+ expect(result.context).toBeDefined();
2664
+ expect((result.context as any).apiVersion).toBe("core.cnrm.cloud.google.com/v1beta1");
2665
+ expect((result.context as any).kind).toBe("ConfigConnectorContext");
2666
+ });
2667
+
2668
+ test("googleServiceAccount in spec", () => {
2669
+ const result = ConfigConnectorContext(minProps);
2670
+ const spec = (result.context as any).spec;
2671
+ expect(spec.googleServiceAccount).toBe("cnrm@my-project.iam.gserviceaccount.com");
2672
+ });
2673
+
2674
+ test("default stateIntoSpec is absent", () => {
2675
+ const result = ConfigConnectorContext(minProps);
2676
+ expect((result.context as any).spec.stateIntoSpec).toBe("absent");
2677
+ });
2678
+
2679
+ test("custom stateIntoSpec", () => {
2680
+ const result = ConfigConnectorContext({ ...minProps, stateIntoSpec: "merge" });
2681
+ expect((result.context as any).spec.stateIntoSpec).toBe("merge");
2682
+ });
2683
+
2684
+ test("default namespace is default", () => {
2685
+ const result = ConfigConnectorContext(minProps);
2686
+ expect((result.context as any).metadata.namespace).toBe("default");
2687
+ });
2688
+
2689
+ test("custom namespace", () => {
2690
+ const result = ConfigConnectorContext({ ...minProps, namespace: "config-connector" });
2691
+ expect((result.context as any).metadata.namespace).toBe("config-connector");
2692
+ });
2693
+ });