@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
@@ -22,6 +22,9 @@ import { wk8209 } from "./wk8209";
22
22
  import { wk8301 } from "./wk8301";
23
23
  import { wk8302 } from "./wk8302";
24
24
  import { wk8303 } from "./wk8303";
25
+ import { wk8304 } from "./wk8304";
26
+ import { wk8305 } from "./wk8305";
27
+ import { wk8306 } from "./wk8306";
25
28
 
26
29
  function makeCtx(yaml: string): PostSynthContext {
27
30
  return {
@@ -592,7 +595,7 @@ describe("WK8204: runAsNonRoot", () => {
592
595
  expect(diags[0].checkId).toBe("WK8204");
593
596
  });
594
597
 
595
- test("passes with runAsNonRoot: true", () => {
598
+ test("warns when runAsNonRoot: true but no runAsUser", () => {
596
599
  const ctx = makeCtx(JSON.stringify({
597
600
  apiVersion: "apps/v1",
598
601
  kind: "Deployment",
@@ -608,6 +611,68 @@ describe("WK8204: runAsNonRoot", () => {
608
611
  },
609
612
  }));
610
613
  const diags = wk8204.check(ctx);
614
+ expect(diags.length).toBe(1);
615
+ expect(diags[0].checkId).toBe("WK8204");
616
+ expect(diags[0].message).toContain("no explicit runAsUser");
617
+ });
618
+
619
+ test("warns when runAsNonRoot: true with runAsUser: 0 (contradictory)", () => {
620
+ const ctx = makeCtx(JSON.stringify({
621
+ apiVersion: "apps/v1",
622
+ kind: "Deployment",
623
+ metadata: { name: "app" },
624
+ spec: {
625
+ template: {
626
+ spec: {
627
+ containers: [
628
+ { name: "app", image: "app:1.0", securityContext: { runAsNonRoot: true, runAsUser: 0 } },
629
+ ],
630
+ },
631
+ },
632
+ },
633
+ }));
634
+ const diags = wk8204.check(ctx);
635
+ expect(diags.length).toBe(1);
636
+ expect(diags[0].checkId).toBe("WK8204");
637
+ expect(diags[0].message).toContain("contradictory");
638
+ });
639
+
640
+ test("passes with runAsNonRoot: true and runAsUser: 65534", () => {
641
+ const ctx = makeCtx(JSON.stringify({
642
+ apiVersion: "apps/v1",
643
+ kind: "Deployment",
644
+ metadata: { name: "app" },
645
+ spec: {
646
+ template: {
647
+ spec: {
648
+ containers: [
649
+ { name: "app", image: "app:1.0", securityContext: { runAsNonRoot: true, runAsUser: 65534 } },
650
+ ],
651
+ },
652
+ },
653
+ },
654
+ }));
655
+ const diags = wk8204.check(ctx);
656
+ expect(diags.length).toBe(0);
657
+ });
658
+
659
+ test("pod-level runAsUser satisfies container-level runAsNonRoot", () => {
660
+ const ctx = makeCtx(JSON.stringify({
661
+ apiVersion: "apps/v1",
662
+ kind: "Deployment",
663
+ metadata: { name: "app" },
664
+ spec: {
665
+ template: {
666
+ spec: {
667
+ securityContext: { runAsUser: 1000 },
668
+ containers: [
669
+ { name: "app", image: "app:1.0", securityContext: { runAsNonRoot: true } },
670
+ ],
671
+ },
672
+ },
673
+ },
674
+ }));
675
+ const diags = wk8204.check(ctx);
611
676
  expect(diags.length).toBe(0);
612
677
  });
613
678
  });
@@ -967,3 +1032,299 @@ spec:
967
1032
  expect(diags.length).toBe(0);
968
1033
  });
969
1034
  });
1035
+
1036
+ // ── WK8304: SSL redirect without certificate ────────────────────────
1037
+
1038
+ describe("WK8304: SSL redirect without certificate", () => {
1039
+ test("flags ssl-redirect without certificate-arn", () => {
1040
+ const ctx = makeCtx(JSON.stringify({
1041
+ apiVersion: "networking.k8s.io/v1",
1042
+ kind: "Ingress",
1043
+ metadata: {
1044
+ name: "app-ingress",
1045
+ annotations: {
1046
+ "alb.ingress.kubernetes.io/ssl-redirect": "443",
1047
+ "alb.ingress.kubernetes.io/scheme": "internet-facing",
1048
+ },
1049
+ },
1050
+ spec: { rules: [] },
1051
+ }));
1052
+ const diags = wk8304.check(ctx);
1053
+ expect(diags.length).toBe(1);
1054
+ expect(diags[0].checkId).toBe("WK8304");
1055
+ expect(diags[0].severity).toBe("warning");
1056
+ });
1057
+
1058
+ test("flags ssl-redirect with valid cert but no HTTPS in listen-ports", () => {
1059
+ const ctx = makeCtx(JSON.stringify({
1060
+ apiVersion: "networking.k8s.io/v1",
1061
+ kind: "Ingress",
1062
+ metadata: {
1063
+ name: "app-ingress",
1064
+ annotations: {
1065
+ "alb.ingress.kubernetes.io/ssl-redirect": "443",
1066
+ "alb.ingress.kubernetes.io/certificate-arn": "arn:aws:acm:us-east-1:123:certificate/abc",
1067
+ "alb.ingress.kubernetes.io/listen-ports": '[{"HTTP":80}]',
1068
+ },
1069
+ },
1070
+ spec: { rules: [] },
1071
+ }));
1072
+ const diags = wk8304.check(ctx);
1073
+ expect(diags.length).toBe(1);
1074
+ expect(diags[0].checkId).toBe("WK8304");
1075
+ });
1076
+
1077
+ test("passes with valid cert and HTTPS listen-ports", () => {
1078
+ const ctx = makeCtx(JSON.stringify({
1079
+ apiVersion: "networking.k8s.io/v1",
1080
+ kind: "Ingress",
1081
+ metadata: {
1082
+ name: "app-ingress",
1083
+ annotations: {
1084
+ "alb.ingress.kubernetes.io/ssl-redirect": "443",
1085
+ "alb.ingress.kubernetes.io/certificate-arn": "arn:aws:acm:us-east-1:123:certificate/abc",
1086
+ "alb.ingress.kubernetes.io/listen-ports": '[{"HTTPS":443}]',
1087
+ },
1088
+ },
1089
+ spec: { rules: [] },
1090
+ }));
1091
+ const diags = wk8304.check(ctx);
1092
+ expect(diags.length).toBe(0);
1093
+ });
1094
+
1095
+ test("passes with no ssl-redirect annotation", () => {
1096
+ const ctx = makeCtx(JSON.stringify({
1097
+ apiVersion: "networking.k8s.io/v1",
1098
+ kind: "Ingress",
1099
+ metadata: {
1100
+ name: "app-ingress",
1101
+ annotations: {
1102
+ "alb.ingress.kubernetes.io/scheme": "internet-facing",
1103
+ },
1104
+ },
1105
+ spec: { rules: [] },
1106
+ }));
1107
+ const diags = wk8304.check(ctx);
1108
+ expect(diags.length).toBe(0);
1109
+ });
1110
+ });
1111
+
1112
+ // ── WK8305: Ingress port not matching Service ───────────────────────
1113
+
1114
+ describe("WK8305: Ingress port not matching Service", () => {
1115
+ test("flags Ingress backend port not on Service", () => {
1116
+ const svc = JSON.stringify({
1117
+ apiVersion: "v1",
1118
+ kind: "Service",
1119
+ metadata: { name: "api", namespace: "default" },
1120
+ spec: { ports: [{ port: 80, targetPort: 8080 }] },
1121
+ });
1122
+ const ingress = JSON.stringify({
1123
+ apiVersion: "networking.k8s.io/v1",
1124
+ kind: "Ingress",
1125
+ metadata: { name: "api-ingress", namespace: "default" },
1126
+ spec: {
1127
+ rules: [{
1128
+ host: "api.example.com",
1129
+ http: {
1130
+ paths: [{
1131
+ path: "/",
1132
+ backend: { service: { name: "api", port: { number: 8080 } } },
1133
+ }],
1134
+ },
1135
+ }],
1136
+ },
1137
+ });
1138
+ const ctx = makeCtx(`${svc}\n---\n${ingress}`);
1139
+ const diags = wk8305.check(ctx);
1140
+ expect(diags.length).toBe(1);
1141
+ expect(diags[0].checkId).toBe("WK8305");
1142
+ expect(diags[0].severity).toBe("warning");
1143
+ });
1144
+
1145
+ test("passes when port matches Service", () => {
1146
+ const svc = JSON.stringify({
1147
+ apiVersion: "v1",
1148
+ kind: "Service",
1149
+ metadata: { name: "api", namespace: "default" },
1150
+ spec: { ports: [{ port: 80, targetPort: 8080 }] },
1151
+ });
1152
+ const ingress = JSON.stringify({
1153
+ apiVersion: "networking.k8s.io/v1",
1154
+ kind: "Ingress",
1155
+ metadata: { name: "api-ingress", namespace: "default" },
1156
+ spec: {
1157
+ rules: [{
1158
+ host: "api.example.com",
1159
+ http: {
1160
+ paths: [{
1161
+ path: "/",
1162
+ backend: { service: { name: "api", port: { number: 80 } } },
1163
+ }],
1164
+ },
1165
+ }],
1166
+ },
1167
+ });
1168
+ const ctx = makeCtx(`${svc}\n---\n${ingress}`);
1169
+ const diags = wk8305.check(ctx);
1170
+ expect(diags.length).toBe(0);
1171
+ });
1172
+
1173
+ test("skips when Service not in manifest set", () => {
1174
+ const ctx = makeCtx(JSON.stringify({
1175
+ apiVersion: "networking.k8s.io/v1",
1176
+ kind: "Ingress",
1177
+ metadata: { name: "api-ingress", namespace: "default" },
1178
+ spec: {
1179
+ rules: [{
1180
+ host: "api.example.com",
1181
+ http: {
1182
+ paths: [{
1183
+ path: "/",
1184
+ backend: { service: { name: "external-svc", port: { number: 443 } } },
1185
+ }],
1186
+ },
1187
+ }],
1188
+ },
1189
+ }));
1190
+ const diags = wk8305.check(ctx);
1191
+ expect(diags.length).toBe(0);
1192
+ });
1193
+
1194
+ test("passes with multiple Services, correct match", () => {
1195
+ const svc1 = JSON.stringify({
1196
+ apiVersion: "v1",
1197
+ kind: "Service",
1198
+ metadata: { name: "api", namespace: "prod" },
1199
+ spec: { ports: [{ port: 80 }, { port: 443 }] },
1200
+ });
1201
+ const svc2 = JSON.stringify({
1202
+ apiVersion: "v1",
1203
+ kind: "Service",
1204
+ metadata: { name: "web", namespace: "prod" },
1205
+ spec: { ports: [{ port: 3000 }] },
1206
+ });
1207
+ const ingress = JSON.stringify({
1208
+ apiVersion: "networking.k8s.io/v1",
1209
+ kind: "Ingress",
1210
+ metadata: { name: "main-ingress", namespace: "prod" },
1211
+ spec: {
1212
+ rules: [{
1213
+ host: "app.example.com",
1214
+ http: {
1215
+ paths: [
1216
+ { path: "/api", backend: { service: { name: "api", port: { number: 443 } } } },
1217
+ { path: "/", backend: { service: { name: "web", port: { number: 3000 } } } },
1218
+ ],
1219
+ },
1220
+ }],
1221
+ },
1222
+ });
1223
+ const ctx = makeCtx(`${svc1}\n---\n${svc2}\n---\n${ingress}`);
1224
+ const diags = wk8305.check(ctx);
1225
+ expect(diags.length).toBe(0);
1226
+ });
1227
+ });
1228
+
1229
+ // ── WK8306: Container command starts with flag ───────────────────
1230
+
1231
+ describe("WK8306: Container command starts with flag", () => {
1232
+ test("flags command[0] starting with --", () => {
1233
+ const ctx = makeCtx(JSON.stringify({
1234
+ apiVersion: "apps/v1",
1235
+ kind: "Deployment",
1236
+ metadata: { name: "adot" },
1237
+ spec: {
1238
+ template: {
1239
+ spec: {
1240
+ containers: [
1241
+ { name: "collector", image: "otel:1.0", command: ["--config=/etc/adot/config.yaml"] },
1242
+ ],
1243
+ },
1244
+ },
1245
+ },
1246
+ }));
1247
+ const diags = wk8306.check(ctx);
1248
+ expect(diags.length).toBe(1);
1249
+ expect(diags[0].checkId).toBe("WK8306");
1250
+ expect(diags[0].severity).toBe("error");
1251
+ expect(diags[0].message).toContain("--config");
1252
+ });
1253
+
1254
+ test("flags command[0] starting with -", () => {
1255
+ const ctx = makeCtx(JSON.stringify({
1256
+ apiVersion: "apps/v1",
1257
+ kind: "Deployment",
1258
+ metadata: { name: "app" },
1259
+ spec: {
1260
+ template: {
1261
+ spec: {
1262
+ containers: [
1263
+ { name: "app", image: "app:1.0", command: ["-c", "echo hello"] },
1264
+ ],
1265
+ },
1266
+ },
1267
+ },
1268
+ }));
1269
+ const diags = wk8306.check(ctx);
1270
+ expect(diags.length).toBe(1);
1271
+ expect(diags[0].severity).toBe("error");
1272
+ });
1273
+
1274
+ test("passes when command[0] is a binary path", () => {
1275
+ const ctx = makeCtx(JSON.stringify({
1276
+ apiVersion: "apps/v1",
1277
+ kind: "Deployment",
1278
+ metadata: { name: "app" },
1279
+ spec: {
1280
+ template: {
1281
+ spec: {
1282
+ containers: [
1283
+ { name: "app", image: "app:1.0", command: ["/usr/bin/app", "--flag"] },
1284
+ ],
1285
+ },
1286
+ },
1287
+ },
1288
+ }));
1289
+ const diags = wk8306.check(ctx);
1290
+ expect(diags.length).toBe(0);
1291
+ });
1292
+
1293
+ test("passes when flags are in args (correct usage)", () => {
1294
+ const ctx = makeCtx(JSON.stringify({
1295
+ apiVersion: "apps/v1",
1296
+ kind: "Deployment",
1297
+ metadata: { name: "app" },
1298
+ spec: {
1299
+ template: {
1300
+ spec: {
1301
+ containers: [
1302
+ { name: "app", image: "app:1.0", args: ["--config=foo"] },
1303
+ ],
1304
+ },
1305
+ },
1306
+ },
1307
+ }));
1308
+ const diags = wk8306.check(ctx);
1309
+ expect(diags.length).toBe(0);
1310
+ });
1311
+
1312
+ test("passes when no command field", () => {
1313
+ const ctx = makeCtx(JSON.stringify({
1314
+ apiVersion: "apps/v1",
1315
+ kind: "Deployment",
1316
+ metadata: { name: "app" },
1317
+ spec: {
1318
+ template: {
1319
+ spec: {
1320
+ containers: [
1321
+ { name: "app", image: "app:1.0" },
1322
+ ],
1323
+ },
1324
+ },
1325
+ },
1326
+ }));
1327
+ const diags = wk8306.check(ctx);
1328
+ expect(diags.length).toBe(0);
1329
+ });
1330
+ });
@@ -4,6 +4,10 @@
4
4
  * Containers should set securityContext.runAsNonRoot to true at either
5
5
  * the container level or pod level. Running as root inside a container
6
6
  * increases the blast radius of a container breakout.
7
+ *
8
+ * Additionally warns when runAsNonRoot: true is set but no explicit
9
+ * runAsUser is provided — without a numeric UID, K8s relies on the
10
+ * image's USER directive, which may be root (UID 0).
7
11
  */
8
12
 
9
13
  import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
@@ -30,13 +34,17 @@ export const wk8204: PostSynthCheck = {
30
34
  // Check pod-level securityContext
31
35
  const podSecCtx = podSpec.securityContext as Record<string, unknown> | undefined;
32
36
  const podRunAsNonRoot = podSecCtx?.runAsNonRoot === true;
37
+ const podRunAsUser = podSecCtx?.runAsUser;
33
38
 
34
39
  const containers = extractContainers(manifest);
35
40
  for (const container of containers) {
36
41
  const secCtx = container.securityContext;
37
42
  const containerRunAsNonRoot = secCtx?.runAsNonRoot === true;
43
+ const containerRunAsUser = secCtx?.runAsUser;
44
+
45
+ const hasRunAsNonRoot = podRunAsNonRoot || containerRunAsNonRoot;
38
46
 
39
- if (!podRunAsNonRoot && !containerRunAsNonRoot) {
47
+ if (!hasRunAsNonRoot) {
40
48
  diagnostics.push({
41
49
  checkId: "WK8204",
42
50
  severity: "warning",
@@ -44,6 +52,30 @@ export const wk8204: PostSynthCheck = {
44
52
  entity: resourceName,
45
53
  lexicon: "k8s",
46
54
  });
55
+ continue;
56
+ }
57
+
58
+ // runAsNonRoot is true — check for explicit runAsUser
59
+ const effectiveRunAsUser = containerRunAsUser ?? podRunAsUser;
60
+
61
+ if (effectiveRunAsUser === 0) {
62
+ // Contradictory: runAsNonRoot: true + runAsUser: 0
63
+ diagnostics.push({
64
+ checkId: "WK8204",
65
+ severity: "warning",
66
+ message: `Container "${container.name ?? "(unnamed)"}" in ${manifest.kind} "${resourceName}" has runAsNonRoot: true but runAsUser: 0 — these settings are contradictory and the container will fail to start`,
67
+ entity: resourceName,
68
+ lexicon: "k8s",
69
+ });
70
+ } else if (effectiveRunAsUser === undefined || effectiveRunAsUser === null) {
71
+ // runAsNonRoot: true but no explicit UID
72
+ diagnostics.push({
73
+ checkId: "WK8204",
74
+ severity: "warning",
75
+ message: `Container "${container.name ?? "(unnamed)"}" in ${manifest.kind} "${resourceName}" has runAsNonRoot: true but no explicit runAsUser — set a numeric UID to ensure the container doesn't run as root`,
76
+ entity: resourceName,
77
+ lexicon: "k8s",
78
+ });
47
79
  }
48
80
  }
49
81
  }
@@ -0,0 +1,70 @@
1
+ /**
2
+ * WK8304: SSL Redirect Without Certificate
3
+ *
4
+ * Flags Ingress resources that have `alb.ingress.kubernetes.io/ssl-redirect`
5
+ * annotation set but are missing `alb.ingress.kubernetes.io/certificate-arn`
6
+ * or don't have HTTPS in their listen-ports.
7
+ */
8
+
9
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
10
+ import { getPrimaryOutput, parseK8sManifests } from "./k8s-helpers";
11
+
12
+ export const wk8304: PostSynthCheck = {
13
+ id: "WK8304",
14
+ description: "SSL redirect without certificate — ssl-redirect annotation requires a valid certificate-arn and HTTPS listen-ports",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const [, output] of ctx.outputs) {
20
+ const yaml = getPrimaryOutput(output);
21
+ const manifests = parseK8sManifests(yaml);
22
+
23
+ for (const manifest of manifests) {
24
+ if (manifest.kind !== "Ingress") continue;
25
+
26
+ const annotations = manifest.metadata?.annotations as Record<string, string> | undefined;
27
+ if (!annotations) continue;
28
+
29
+ const sslRedirect = annotations["alb.ingress.kubernetes.io/ssl-redirect"];
30
+ if (!sslRedirect) continue;
31
+
32
+ const resourceName = manifest.metadata?.name ?? "Ingress";
33
+ const certArn = annotations["alb.ingress.kubernetes.io/certificate-arn"];
34
+
35
+ if (!certArn) {
36
+ diagnostics.push({
37
+ checkId: "WK8304",
38
+ severity: "warning",
39
+ message: `Ingress "${resourceName}" has ssl-redirect annotation but no certificate-arn — HTTPS redirect will fail without a TLS certificate`,
40
+ entity: resourceName,
41
+ lexicon: "k8s",
42
+ });
43
+ continue;
44
+ }
45
+
46
+ // Check listen-ports includes HTTPS
47
+ const listenPorts = annotations["alb.ingress.kubernetes.io/listen-ports"];
48
+ if (listenPorts) {
49
+ try {
50
+ const ports = JSON.parse(listenPorts) as Array<Record<string, number>>;
51
+ const hasHttps = ports.some((p) => "HTTPS" in p);
52
+ if (!hasHttps) {
53
+ diagnostics.push({
54
+ checkId: "WK8304",
55
+ severity: "warning",
56
+ message: `Ingress "${resourceName}" has ssl-redirect but listen-ports does not include HTTPS`,
57
+ entity: resourceName,
58
+ lexicon: "k8s",
59
+ });
60
+ }
61
+ } catch {
62
+ // Can't parse listen-ports — skip this check
63
+ }
64
+ }
65
+ }
66
+ }
67
+
68
+ return diagnostics;
69
+ },
70
+ };
@@ -0,0 +1,115 @@
1
+ /**
2
+ * WK8305: Ingress Port Not Matching Service
3
+ *
4
+ * Flags Ingress backends whose `service.port.number` does not match
5
+ * any declared port on the referenced Service in the manifest set.
6
+ */
7
+
8
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
9
+ import { getPrimaryOutput, parseK8sManifests } from "./k8s-helpers";
10
+ import type { K8sManifest } from "./k8s-helpers";
11
+
12
+ export const wk8305: PostSynthCheck = {
13
+ id: "WK8305",
14
+ description: "Ingress port not matching Service — backend port must match a declared Service port",
15
+
16
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
17
+ const diagnostics: PostSynthDiagnostic[] = [];
18
+
19
+ for (const [, output] of ctx.outputs) {
20
+ const yaml = getPrimaryOutput(output);
21
+ const manifests = parseK8sManifests(yaml);
22
+
23
+ // Build a map of Service name+namespace → set of port numbers
24
+ const servicePorts = collectServicePorts(manifests);
25
+
26
+ for (const manifest of manifests) {
27
+ if (manifest.kind !== "Ingress") continue;
28
+
29
+ const ingressName = manifest.metadata?.name ?? "Ingress";
30
+ const ingressNamespace = manifest.metadata?.namespace ?? "default";
31
+ const spec = manifest.spec;
32
+ if (!spec) continue;
33
+
34
+ const rules = spec.rules as Array<Record<string, unknown>> | undefined;
35
+ if (!rules) continue;
36
+
37
+ for (const rule of rules) {
38
+ const http = rule.http as Record<string, unknown> | undefined;
39
+ if (!http) continue;
40
+
41
+ const paths = http.paths as Array<Record<string, unknown>> | undefined;
42
+ if (!paths) continue;
43
+
44
+ for (const pathEntry of paths) {
45
+ const backend = pathEntry.backend as Record<string, unknown> | undefined;
46
+ if (!backend) continue;
47
+
48
+ const service = backend.service as Record<string, unknown> | undefined;
49
+ if (!service) continue;
50
+
51
+ const svcName = service.name as string | undefined;
52
+ const port = service.port as Record<string, unknown> | undefined;
53
+ const portNumber = port?.number as number | undefined;
54
+
55
+ if (!svcName || portNumber === undefined) continue;
56
+
57
+ // Look up the Service in the manifest set
58
+ const key = `${ingressNamespace}/${svcName}`;
59
+ const knownPorts = servicePorts.get(key);
60
+
61
+ // Skip if the Service is not in the manifest set (external service)
62
+ if (!knownPorts) continue;
63
+
64
+ if (!knownPorts.has(portNumber)) {
65
+ diagnostics.push({
66
+ checkId: "WK8305",
67
+ severity: "warning",
68
+ message: `Ingress "${ingressName}" references Service "${svcName}" port ${portNumber}, but the Service only declares ports [${[...knownPorts].join(", ")}]`,
69
+ entity: ingressName,
70
+ lexicon: "k8s",
71
+ });
72
+ }
73
+ }
74
+ }
75
+ }
76
+ }
77
+
78
+ return diagnostics;
79
+ },
80
+ };
81
+
82
+ /**
83
+ * Collect port numbers from all Service manifests, keyed by namespace/name.
84
+ */
85
+ function collectServicePorts(manifests: K8sManifest[]): Map<string, Set<number>> {
86
+ const result = new Map<string, Set<number>>();
87
+
88
+ for (const manifest of manifests) {
89
+ if (manifest.kind !== "Service") continue;
90
+
91
+ const name = manifest.metadata?.name;
92
+ const namespace = manifest.metadata?.namespace ?? "default";
93
+ if (!name) continue;
94
+
95
+ const key = `${namespace}/${name}`;
96
+ const ports = new Set<number>();
97
+
98
+ const spec = manifest.spec;
99
+ if (spec) {
100
+ const specPorts = spec.ports as Array<Record<string, unknown>> | undefined;
101
+ if (specPorts) {
102
+ for (const p of specPorts) {
103
+ const port = p.port as number | undefined;
104
+ if (port !== undefined) {
105
+ ports.add(port);
106
+ }
107
+ }
108
+ }
109
+ }
110
+
111
+ result.set(key, ports);
112
+ }
113
+
114
+ return result;
115
+ }
@@ -0,0 +1,50 @@
1
+ /**
2
+ * WK8306: Container Command Starts With Flag
3
+ *
4
+ * If `command[0]` starts with `-` or `--`, it's almost certainly a mistake —
5
+ * the first element should be the binary/entrypoint, flags belong in `args`.
6
+ * This causes OCI runtime errors because the container runtime tries to
7
+ * execute the flag as a binary.
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, parseK8sManifests, extractContainers, WORKLOAD_KINDS } from "./k8s-helpers";
12
+
13
+ export const wk8306: PostSynthCheck = {
14
+ id: "WK8306",
15
+ description: "Container command starts with flag — first element should be a binary, not a flag",
16
+
17
+ check(ctx: PostSynthContext): PostSynthDiagnostic[] {
18
+ const diagnostics: PostSynthDiagnostic[] = [];
19
+
20
+ for (const [, output] of ctx.outputs) {
21
+ const yaml = getPrimaryOutput(output);
22
+ const manifests = parseK8sManifests(yaml);
23
+
24
+ for (const manifest of manifests) {
25
+ if (!manifest.kind || !WORKLOAD_KINDS.has(manifest.kind)) continue;
26
+
27
+ const resourceName = manifest.metadata?.name ?? manifest.kind;
28
+ const containers = extractContainers(manifest);
29
+
30
+ for (const container of containers) {
31
+ const command = (container as Record<string, unknown>).command as unknown[] | undefined;
32
+ if (!Array.isArray(command) || command.length === 0) continue;
33
+
34
+ const firstArg = String(command[0]);
35
+ if (firstArg.startsWith("-")) {
36
+ diagnostics.push({
37
+ checkId: "WK8306",
38
+ severity: "error",
39
+ message: `Container "${container.name ?? "(unnamed)"}" in ${manifest.kind} "${resourceName}" has command[0]="${firstArg}" which starts with a flag — the first element should be the binary, flags belong in args`,
40
+ entity: resourceName,
41
+ lexicon: "k8s",
42
+ });
43
+ }
44
+ }
45
+ }
46
+ }
47
+
48
+ return diagnostics;
49
+ },
50
+ };
@@ -30,10 +30,10 @@ describe("k8sPlugin", () => {
30
30
  expect(rules.some((r) => r.id === "WK8001")).toBe(true);
31
31
  });
32
32
 
33
- test("postSynthChecks() returns array of 20 checks", () => {
33
+ test("postSynthChecks() returns array of 22 checks", () => {
34
34
  const checks = k8sPlugin.postSynthChecks!();
35
35
  expect(Array.isArray(checks)).toBe(true);
36
- expect(checks.length).toBe(20);
36
+ expect(checks.length).toBe(23);
37
37
  });
38
38
 
39
39
  test("intrinsics() returns empty array", () => {