@intentius/chant-lexicon-k8s 0.1.0 → 0.1.5

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 (54) hide show
  1. package/dist/integrity.json +42 -39
  2. package/dist/manifest.json +1 -1
  3. package/dist/meta.json +121 -0
  4. package/dist/rules/k8s-helpers.ts +39 -0
  5. package/dist/rules/wk8401.ts +98 -0
  6. package/dist/rules/wk8402.ts +43 -0
  7. package/dist/rules/wk8403.ts +60 -0
  8. package/dist/types/index.d.ts +30 -0
  9. package/package.json +11 -7
  10. package/src/actions/actions.test.ts +1 -1
  11. package/src/codegen/generate-cli.ts +1 -1
  12. package/src/codegen/generate.ts +22 -0
  13. package/src/codegen/naming.test.ts +1 -1
  14. package/src/codegen/package.ts +2 -5
  15. package/src/codegen/snapshot.test.ts +1 -1
  16. package/src/codegen/typecheck.test.ts +1 -1
  17. package/src/composites/cockroachdb-region-stack.ts +553 -0
  18. package/src/composites/composites.test.ts +4 -4
  19. package/src/composites/index.ts +8 -0
  20. package/src/composites/ray-cluster.ts +590 -0
  21. package/src/composites/ray-job.ts +235 -0
  22. package/src/composites/ray-service.ts +271 -0
  23. package/src/coverage.test.ts +1 -1
  24. package/src/crd/crd-sources.ts +29 -0
  25. package/src/crd/loader.ts +13 -21
  26. package/src/crd/parser.test.ts +1 -1
  27. package/src/crd/parser.ts +17 -12
  28. package/src/default-labels.test.ts +1 -1
  29. package/src/generated/index.d.ts +30 -0
  30. package/src/generated/index.ts +13 -0
  31. package/src/generated/lexicon-k8s.json +121 -0
  32. package/src/import/generator.test.ts +1 -1
  33. package/src/import/parser.test.ts +1 -1
  34. package/src/import/roundtrip.test.ts +1 -1
  35. package/src/index.ts +4 -0
  36. package/src/lint/post-synth/k8s-helpers.test.ts +1 -1
  37. package/src/lint/post-synth/k8s-helpers.ts +39 -0
  38. package/src/lint/post-synth/post-synth.test.ts +149 -1
  39. package/src/lint/post-synth/wk8401.ts +98 -0
  40. package/src/lint/post-synth/wk8402.ts +43 -0
  41. package/src/lint/post-synth/wk8403.ts +60 -0
  42. package/src/lint/rules/rules.test.ts +1 -1
  43. package/src/lsp/completions.test.ts +1 -1
  44. package/src/lsp/hover.test.ts +1 -1
  45. package/src/package-cli.ts +1 -1
  46. package/src/plugin.test.ts +3 -3
  47. package/src/plugin.ts +7 -9
  48. package/src/serializer.test.ts +1 -1
  49. package/src/serializer.ts +2 -0
  50. package/src/skills/chant-k8s-ray.md +252 -0
  51. package/src/spec/fetch.test.ts +1 -1
  52. package/src/spec/parse.test.ts +1 -1
  53. package/src/validate-cli.ts +1 -1
  54. package/src/validate.test.ts +1 -1
@@ -1244,6 +1244,36 @@ export declare class PVC {
1244
1244
  readonly uid: string;
1245
1245
  }
1246
1246
 
1247
+ export declare class RayCluster {
1248
+ constructor(props: {
1249
+ metadata?: Record<string, unknown>;
1250
+ spec?: Record<string, unknown>;
1251
+ });
1252
+ readonly name: string;
1253
+ readonly namespace: string;
1254
+ readonly uid: string;
1255
+ }
1256
+
1257
+ export declare class RayJob {
1258
+ constructor(props: {
1259
+ metadata?: Record<string, unknown>;
1260
+ spec?: Record<string, unknown>;
1261
+ });
1262
+ readonly name: string;
1263
+ readonly namespace: string;
1264
+ readonly uid: string;
1265
+ }
1266
+
1267
+ export declare class RayService {
1268
+ constructor(props: {
1269
+ metadata?: Record<string, unknown>;
1270
+ spec?: Record<string, unknown>;
1271
+ });
1272
+ readonly name: string;
1273
+ readonly namespace: string;
1274
+ readonly uid: string;
1275
+ }
1276
+
1247
1277
  export declare class ReplicaSet {
1248
1278
  constructor(props: {
1249
1279
  /** If the Labels of a ReplicaSet are empty, they are defaulted to be the same as the Pod(s) that the ReplicaSet manages. Standard object's metadata. More info: https://git.k8s.io/community/contributors/devel/sig-architecture/api-conventions.md#metadata */
@@ -96,6 +96,9 @@ export const PriorityLevelConfiguration = createResource("K8s::Flowcontrol::Prio
96
96
  export const PriorityLevelConfigurationList = createResource("K8s::Flowcontrol::PriorityLevelConfigurationList", "k8s", {"name":"name","namespace":"namespace","uid":"uid"});
97
97
  export const PV = createResource("K8s::Core::PersistentVolume", "k8s", {"name":"name","namespace":"namespace","uid":"uid"});
98
98
  export const PVC = createResource("K8s::Core::PersistentVolumeClaim", "k8s", {"name":"name","namespace":"namespace","uid":"uid"});
99
+ export const RayCluster = createResource("K8s::Ray::RayCluster", "k8s", {"name":"name","namespace":"namespace","uid":"uid"});
100
+ export const RayJob = createResource("K8s::Ray::RayJob", "k8s", {"name":"name","namespace":"namespace","uid":"uid"});
101
+ export const RayService = createResource("K8s::Ray::RayService", "k8s", {"name":"name","namespace":"namespace","uid":"uid"});
99
102
  export const ReplicaSet = createResource("K8s::Apps::ReplicaSet", "k8s", {"name":"name","namespace":"namespace","uid":"uid"});
100
103
  export const ReplicaSetList = createResource("K8s::Apps::ReplicaSetList", "k8s", {"name":"name","namespace":"namespace","uid":"uid"});
101
104
  export const ReplicationController = createResource("K8s::Core::ReplicationController", "k8s", {"name":"name","namespace":"namespace","uid":"uid"});
@@ -186,6 +189,16 @@ export const PodSpec = createProperty("K8s::Core::PodSpec", "k8s");
186
189
  export const PodTemplateSpec = createProperty("K8s::Core::PodTemplateSpec", "k8s");
187
190
  export const PolicyRule = createProperty("K8s::Rbac::PolicyRule", "k8s");
188
191
  export const Probe = createProperty("K8s::Core::Probe", "k8s");
192
+ export const RayCluster_AutoscalerOptions = createProperty("K8s::Ray::RayCluster.autoscalerOptions", "k8s");
193
+ export const RayCluster_GcsFaultToleranceOptions = createProperty("K8s::Ray::RayCluster.gcsFaultToleranceOptions", "k8s");
194
+ export const RayCluster_HeadGroupSpec = createProperty("K8s::Ray::RayCluster.headGroupSpec", "k8s");
195
+ export const RayCluster_WorkerGroupSpec = createProperty("K8s::Ray::RayCluster.workerGroupSpecs", "k8s");
196
+ export const RayJob_RayClusterSpec = createProperty("K8s::Ray::RayJob.rayClusterSpec", "k8s");
197
+ export const RayJob_SubmitterConfig = createProperty("K8s::Ray::RayJob.submitterConfig", "k8s");
198
+ export const RayJob_SubmitterPodTemplate = createProperty("K8s::Ray::RayJob.submitterPodTemplate", "k8s");
199
+ export const RayService_RayClusterConfig = createProperty("K8s::Ray::RayService.rayClusterConfig", "k8s");
200
+ export const RayService_ServeService = createProperty("K8s::Ray::RayService.serveService", "k8s");
201
+ export const RayService_UpgradeStrategy = createProperty("K8s::Ray::RayService.upgradeStrategy", "k8s");
189
202
  export const ResourceRequirements = createProperty("K8s::Core::ResourceRequirements", "k8s");
190
203
  export const RoleRef = createProperty("K8s::Rbac::RoleRef", "k8s");
191
204
  export const RollingUpdateDeployment = createProperty("K8s::Apps::RollingUpdateDeployment", "k8s");
@@ -46,6 +46,11 @@
46
46
  "kind": "property",
47
47
  "lexicon": "k8s"
48
48
  },
49
+ "AutoscalerOptions": {
50
+ "resourceType": "K8s::Ray::RayCluster.autoscalerOptions",
51
+ "kind": "property",
52
+ "lexicon": "k8s"
53
+ },
49
54
  "BatchJob": {
50
55
  "resourceType": "K8s::Batch::Job",
51
56
  "kind": "resource",
@@ -439,6 +444,11 @@
439
444
  "apiVersion": "flowcontrol.apiserver.k8s.io/v1",
440
445
  "gvkKind": "FlowSchemaList"
441
446
  },
447
+ "GcsFaultToleranceOptions": {
448
+ "resourceType": "K8s::Ray::RayCluster.gcsFaultToleranceOptions",
449
+ "kind": "property",
450
+ "lexicon": "k8s"
451
+ },
442
452
  "HPA": {
443
453
  "resourceType": "K8s::Autoscaling::HorizontalPodAutoscaler",
444
454
  "kind": "resource",
@@ -456,6 +466,11 @@
456
466
  "kind": "property",
457
467
  "lexicon": "k8s"
458
468
  },
469
+ "HeadGroupSpec": {
470
+ "resourceType": "K8s::Ray::RayCluster.headGroupSpec",
471
+ "kind": "property",
472
+ "lexicon": "k8s"
473
+ },
459
474
  "HorizontalPodAutoscaler": {
460
475
  "resourceType": "K8s::Autoscaling::HorizontalPodAutoscaler",
461
476
  "kind": "resource",
@@ -942,6 +957,87 @@
942
957
  }
943
958
  }
944
959
  },
960
+ "RayCluster": {
961
+ "resourceType": "K8s::Ray::RayCluster",
962
+ "kind": "resource",
963
+ "lexicon": "k8s",
964
+ "apiVersion": "ray.io/v1",
965
+ "gvkKind": "RayCluster"
966
+ },
967
+ "RayClusterConfig": {
968
+ "resourceType": "K8s::Ray::RayService.rayClusterConfig",
969
+ "kind": "property",
970
+ "lexicon": "k8s"
971
+ },
972
+ "RayClusterSpec": {
973
+ "resourceType": "K8s::Ray::RayJob.rayClusterSpec",
974
+ "kind": "property",
975
+ "lexicon": "k8s"
976
+ },
977
+ "RayCluster_AutoscalerOptions": {
978
+ "resourceType": "K8s::Ray::RayCluster.autoscalerOptions",
979
+ "kind": "property",
980
+ "lexicon": "k8s"
981
+ },
982
+ "RayCluster_GcsFaultToleranceOptions": {
983
+ "resourceType": "K8s::Ray::RayCluster.gcsFaultToleranceOptions",
984
+ "kind": "property",
985
+ "lexicon": "k8s"
986
+ },
987
+ "RayCluster_HeadGroupSpec": {
988
+ "resourceType": "K8s::Ray::RayCluster.headGroupSpec",
989
+ "kind": "property",
990
+ "lexicon": "k8s"
991
+ },
992
+ "RayCluster_WorkerGroupSpec": {
993
+ "resourceType": "K8s::Ray::RayCluster.workerGroupSpecs",
994
+ "kind": "property",
995
+ "lexicon": "k8s"
996
+ },
997
+ "RayJob": {
998
+ "resourceType": "K8s::Ray::RayJob",
999
+ "kind": "resource",
1000
+ "lexicon": "k8s",
1001
+ "apiVersion": "ray.io/v1",
1002
+ "gvkKind": "RayJob"
1003
+ },
1004
+ "RayJob_RayClusterSpec": {
1005
+ "resourceType": "K8s::Ray::RayJob.rayClusterSpec",
1006
+ "kind": "property",
1007
+ "lexicon": "k8s"
1008
+ },
1009
+ "RayJob_SubmitterConfig": {
1010
+ "resourceType": "K8s::Ray::RayJob.submitterConfig",
1011
+ "kind": "property",
1012
+ "lexicon": "k8s"
1013
+ },
1014
+ "RayJob_SubmitterPodTemplate": {
1015
+ "resourceType": "K8s::Ray::RayJob.submitterPodTemplate",
1016
+ "kind": "property",
1017
+ "lexicon": "k8s"
1018
+ },
1019
+ "RayService": {
1020
+ "resourceType": "K8s::Ray::RayService",
1021
+ "kind": "resource",
1022
+ "lexicon": "k8s",
1023
+ "apiVersion": "ray.io/v1",
1024
+ "gvkKind": "RayService"
1025
+ },
1026
+ "RayService_RayClusterConfig": {
1027
+ "resourceType": "K8s::Ray::RayService.rayClusterConfig",
1028
+ "kind": "property",
1029
+ "lexicon": "k8s"
1030
+ },
1031
+ "RayService_ServeService": {
1032
+ "resourceType": "K8s::Ray::RayService.serveService",
1033
+ "kind": "property",
1034
+ "lexicon": "k8s"
1035
+ },
1036
+ "RayService_UpgradeStrategy": {
1037
+ "resourceType": "K8s::Ray::RayService.upgradeStrategy",
1038
+ "kind": "property",
1039
+ "lexicon": "k8s"
1040
+ },
945
1041
  "ReplicaSet": {
946
1042
  "resourceType": "K8s::Apps::ReplicaSet",
947
1043
  "kind": "resource",
@@ -1164,6 +1260,11 @@
1164
1260
  "apiVersion": "authorization.k8s.io/v1",
1165
1261
  "gvkKind": "SelfSubjectRulesReview"
1166
1262
  },
1263
+ "ServeService": {
1264
+ "resourceType": "K8s::Ray::RayService.serveService",
1265
+ "kind": "property",
1266
+ "lexicon": "k8s"
1267
+ },
1167
1268
  "Service": {
1168
1269
  "resourceType": "K8s::Core::Service",
1169
1270
  "kind": "resource",
@@ -1281,6 +1382,16 @@
1281
1382
  "apiVersion": "authorization.k8s.io/v1",
1282
1383
  "gvkKind": "SubjectAccessReview"
1283
1384
  },
1385
+ "SubmitterConfig": {
1386
+ "resourceType": "K8s::Ray::RayJob.submitterConfig",
1387
+ "kind": "property",
1388
+ "lexicon": "k8s"
1389
+ },
1390
+ "SubmitterPodTemplate": {
1391
+ "resourceType": "K8s::Ray::RayJob.submitterPodTemplate",
1392
+ "kind": "property",
1393
+ "lexicon": "k8s"
1394
+ },
1284
1395
  "TCPSocketAction": {
1285
1396
  "resourceType": "K8s::Core::TCPSocketAction",
1286
1397
  "kind": "property",
@@ -1323,6 +1434,11 @@
1323
1434
  }
1324
1435
  }
1325
1436
  },
1437
+ "UpgradeStrategy": {
1438
+ "resourceType": "K8s::Ray::RayService.upgradeStrategy",
1439
+ "kind": "property",
1440
+ "lexicon": "k8s"
1441
+ },
1326
1442
  "ValidatingAdmissionPolicy": {
1327
1443
  "resourceType": "K8s::Admissionregistration::ValidatingAdmissionPolicy",
1328
1444
  "kind": "resource",
@@ -1409,5 +1525,10 @@
1409
1525
  "lexicon": "k8s",
1410
1526
  "apiVersion": "v1",
1411
1527
  "gvkKind": "WatchEvent"
1528
+ },
1529
+ "WorkerGroupSpec": {
1530
+ "resourceType": "K8s::Ray::RayCluster.workerGroupSpecs",
1531
+ "kind": "property",
1532
+ "lexicon": "k8s"
1412
1533
  }
1413
1534
  }
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { K8sGenerator } from "./generator";
3
3
  import type { TemplateIR, ResourceIR } from "@intentius/chant/import/parser";
4
4
 
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { K8sParser } from "./parser";
3
3
 
4
4
  const parser = new K8sParser();
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import { readFileSync } from "fs";
3
3
  import { join, dirname } from "path";
4
4
  import { fileURLToPath } from "url";
package/src/index.ts CHANGED
@@ -22,6 +22,7 @@ export {
22
22
  IrsaServiceAccount, AlbIngress, EbsStorageClass, EfsStorageClass, FluentBitAgent, ExternalDnsAgent, AdotCollector,
23
23
  MetricsServer, WorkloadIdentityServiceAccount, GcePdStorageClass, FilestoreStorageClass, GkeGateway, ConfigConnectorContext,
24
24
  GceIngress, CockroachDbCluster,
25
+ RayCluster, RayJob, RayService,
25
26
  AgicIngress, AzureDiskStorageClass, AzureFileStorageClass, AzureMonitorCollector,
26
27
  AksWorkloadIdentityServiceAccount,
27
28
  GkeFluentBitAgent, GkeOtelCollector, GkeExternalDnsAgent, AksExternalDnsAgent,
@@ -44,6 +45,9 @@ export type {
44
45
  GkeGatewayProps, GkeGatewayResult,
45
46
  ConfigConnectorContextProps, ConfigConnectorContextResult,
46
47
  GceIngressProps, GceIngressResult, CockroachDbClusterProps, CockroachDbClusterResult,
48
+ RayClusterProps, RayClusterResult, RayClusterSpec, ResourceSpec, HeadGroupSpec, WorkerGroupSpec,
49
+ RayJobProps, RayJobResult,
50
+ RayServiceProps, RayServiceResult,
47
51
  AgicIngressProps, AgicIngressResult,
48
52
  AzureDiskStorageClassProps, AzureDiskStorageClassResult,
49
53
  AzureFileStorageClassProps, AzureFileStorageClassResult,
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import {
3
3
  parseK8sManifests,
4
4
  extractContainers,
@@ -147,3 +147,42 @@ export const WORKLOAD_KINDS = new Set([
147
147
  "Job",
148
148
  "CronJob",
149
149
  ]);
150
+
151
+ /**
152
+ * Parse a Kubernetes memory string to bytes.
153
+ *
154
+ * Handles binary suffixes (Ki, Mi, Gi, Ti) and SI suffixes (K, M, G, T).
155
+ * Returns NaN for unrecognised strings — callers should skip checks on NaN.
156
+ *
157
+ * @example
158
+ * parseMemoryBytes("4Gi") // 4294967296
159
+ * parseMemoryBytes("512Mi") // 536870912
160
+ * parseMemoryBytes("2048") // 2048
161
+ */
162
+ export function parseMemoryBytes(s: string): number {
163
+ const m = /^([0-9]+(?:\.[0-9]+)?)(Ki|Mi|Gi|Ti|K|M|G|T)?$/.exec(s.trim());
164
+ if (!m) return NaN;
165
+ const n = parseFloat(m[1]);
166
+ const multipliers: Record<string, number> = {
167
+ Ki: 1024, Mi: 1024 ** 2, Gi: 1024 ** 3, Ti: 1024 ** 4,
168
+ K: 1000, M: 1000 ** 2, G: 1000 ** 3, T: 1000 ** 4,
169
+ };
170
+ return n * (multipliers[m[2] ?? ""] ?? 1);
171
+ }
172
+
173
+ /**
174
+ * Extract the Ray version from an image tag.
175
+ *
176
+ * Looks for a semver-style version prefix in the image tag:
177
+ * "rayproject/ray:2.40.0" → "2.40.0"
178
+ * "rayproject/ray:2.40.0-py310" → "2.40.0"
179
+ * "us-docker.pkg.dev/.../ray:2.9.3-gpu" → "2.9.3"
180
+ * "rayproject/ray:latest" → undefined
181
+ *
182
+ * Returns undefined when no version can be found.
183
+ */
184
+ export function extractRayVersion(image: string): string | undefined {
185
+ const tag = image.includes(":") ? image.split(":").pop()! : image;
186
+ const m = /^([0-9]+\.[0-9]+\.[0-9]+)/.exec(tag);
187
+ return m ? m[1] : undefined;
188
+ }
@@ -1,4 +1,4 @@
1
- import { describe, test, expect } from "bun:test";
1
+ import { describe, test, expect } from "vitest";
2
2
  import type { PostSynthContext } from "@intentius/chant/lint/post-synth";
3
3
 
4
4
  // Import all checks
@@ -25,6 +25,9 @@ import { wk8303 } from "./wk8303";
25
25
  import { wk8304 } from "./wk8304";
26
26
  import { wk8305 } from "./wk8305";
27
27
  import { wk8306 } from "./wk8306";
28
+ import { wk8401 } from "./wk8401";
29
+ import { wk8402 } from "./wk8402";
30
+ import { wk8403 } from "./wk8403";
28
31
 
29
32
  function makeCtx(yaml: string): PostSynthContext {
30
33
  return {
@@ -1328,3 +1331,148 @@ describe("WK8306: Container command starts with flag", () => {
1328
1331
  expect(diags.length).toBe(0);
1329
1332
  });
1330
1333
  });
1334
+
1335
+ // ── WK8401: shmSize exceeds memory limit ────────────────────────────────────
1336
+
1337
+ function makeRayCluster(overrides: {
1338
+ shmSizeLimit?: string;
1339
+ memoryLimit?: string;
1340
+ workerShmSizeLimit?: string;
1341
+ workerMemoryLimit?: string;
1342
+ rayVersion?: string;
1343
+ headImage?: string;
1344
+ }) {
1345
+ const headImage = overrides.headImage ?? "rayproject/ray:2.40.0-py310-cpu";
1346
+ return JSON.stringify({
1347
+ apiVersion: "ray.io/v1alpha1",
1348
+ kind: "RayCluster",
1349
+ metadata: { name: "ray" },
1350
+ spec: {
1351
+ ...(overrides.rayVersion !== undefined && { rayVersion: overrides.rayVersion }),
1352
+ headGroupSpec: {
1353
+ template: {
1354
+ spec: {
1355
+ volumes: [{ name: "dshm", emptyDir: { medium: "Memory", ...(overrides.shmSizeLimit !== undefined && { sizeLimit: overrides.shmSizeLimit }) } }],
1356
+ containers: [{ name: "ray-head", image: headImage, resources: { limits: { memory: overrides.memoryLimit ?? "8Gi" } } }],
1357
+ },
1358
+ },
1359
+ },
1360
+ workerGroupSpecs: [
1361
+ {
1362
+ groupName: "cpu",
1363
+ template: {
1364
+ spec: {
1365
+ volumes: [{ name: "dshm", emptyDir: { medium: "Memory", ...(overrides.workerShmSizeLimit !== undefined && { sizeLimit: overrides.workerShmSizeLimit }) } }],
1366
+ containers: [{ name: "ray-worker", image: headImage, resources: { limits: { memory: overrides.workerMemoryLimit ?? "4Gi" } } }],
1367
+ },
1368
+ },
1369
+ },
1370
+ ],
1371
+ },
1372
+ });
1373
+ }
1374
+
1375
+ describe("WK8401: shmSize exceeds memory limit", () => {
1376
+ test("passes when shmSize equals memory limit", () => {
1377
+ const ctx = makeCtx(makeRayCluster({ shmSizeLimit: "8Gi", memoryLimit: "8Gi" }));
1378
+ const diags = wk8401.check(ctx);
1379
+ expect(diags.filter((d) => d.checkId === "WK8401").length).toBe(0);
1380
+ });
1381
+
1382
+ test("passes when shmSize is less than memory limit", () => {
1383
+ const ctx = makeCtx(makeRayCluster({ shmSizeLimit: "2Gi", memoryLimit: "8Gi" }));
1384
+ const diags = wk8401.check(ctx);
1385
+ expect(diags.filter((d) => d.checkId === "WK8401").length).toBe(0);
1386
+ });
1387
+
1388
+ test("errors when head shmSize exceeds memory limit", () => {
1389
+ const ctx = makeCtx(makeRayCluster({ shmSizeLimit: "16Gi", memoryLimit: "8Gi" }));
1390
+ const diags = wk8401.check(ctx);
1391
+ const errors = diags.filter((d) => d.checkId === "WK8401" && d.severity === "error");
1392
+ expect(errors.length).toBeGreaterThanOrEqual(1);
1393
+ expect(errors[0].message).toContain("head");
1394
+ expect(errors[0].message).toContain("16Gi");
1395
+ });
1396
+
1397
+ test("errors when worker shmSize exceeds memory limit", () => {
1398
+ const ctx = makeCtx(makeRayCluster({ workerShmSizeLimit: "8Gi", workerMemoryLimit: "4Gi" }));
1399
+ const diags = wk8401.check(ctx);
1400
+ const errors = diags.filter((d) => d.checkId === "WK8401" && d.severity === "error");
1401
+ expect(errors.length).toBeGreaterThanOrEqual(1);
1402
+ expect(errors[0].message).toContain("worker");
1403
+ });
1404
+
1405
+ test("skips check when no sizeLimit set on emptyDir", () => {
1406
+ const ctx = makeCtx(makeRayCluster({ shmSizeLimit: undefined }));
1407
+ const diags = wk8401.check(ctx);
1408
+ expect(diags.filter((d) => d.checkId === "WK8401").length).toBe(0);
1409
+ });
1410
+
1411
+ test("ignores non-RayCluster manifests", () => {
1412
+ const ctx = makeCtx(JSON.stringify({
1413
+ apiVersion: "apps/v1",
1414
+ kind: "Deployment",
1415
+ metadata: { name: "app" },
1416
+ spec: { template: { spec: { volumes: [{ name: "dshm", emptyDir: { medium: "Memory", sizeLimit: "16Gi" } }], containers: [{ name: "app", image: "app:1.0", resources: { limits: { memory: "4Gi" } } }] } } },
1417
+ }));
1418
+ const diags = wk8401.check(ctx);
1419
+ expect(diags.filter((d) => d.checkId === "WK8401").length).toBe(0);
1420
+ });
1421
+ });
1422
+
1423
+ // ── WK8402: RayCluster missing spec.rayVersion ───────────────────────────────
1424
+
1425
+ describe("WK8402: RayCluster missing spec.rayVersion", () => {
1426
+ test("passes when rayVersion is set", () => {
1427
+ const ctx = makeCtx(makeRayCluster({ rayVersion: "2.40.0" }));
1428
+ const diags = wk8402.check(ctx);
1429
+ expect(diags.filter((d) => d.checkId === "WK8402").length).toBe(0);
1430
+ });
1431
+
1432
+ test("warns when rayVersion is absent", () => {
1433
+ const ctx = makeCtx(makeRayCluster({}));
1434
+ const diags = wk8402.check(ctx);
1435
+ const warns = diags.filter((d) => d.checkId === "WK8402");
1436
+ expect(warns.length).toBe(1);
1437
+ expect(warns[0].severity).toBe("warning");
1438
+ expect(warns[0].message).toContain("latest");
1439
+ });
1440
+
1441
+ test("ignores non-RayCluster manifests", () => {
1442
+ const ctx = makeCtx(JSON.stringify({ apiVersion: "apps/v1", kind: "Deployment", metadata: { name: "app" }, spec: {} }));
1443
+ const diags = wk8402.check(ctx);
1444
+ expect(diags.filter((d) => d.checkId === "WK8402").length).toBe(0);
1445
+ });
1446
+ });
1447
+
1448
+ // ── WK8403: spec.rayVersion / image tag mismatch ─────────────────────────────
1449
+
1450
+ describe("WK8403: spec.rayVersion does not match image tag", () => {
1451
+ test("passes when versions match", () => {
1452
+ const ctx = makeCtx(makeRayCluster({ rayVersion: "2.40.0", headImage: "rayproject/ray:2.40.0-py310-cpu" }));
1453
+ const diags = wk8403.check(ctx);
1454
+ expect(diags.filter((d) => d.checkId === "WK8403").length).toBe(0);
1455
+ });
1456
+
1457
+ test("warns when rayVersion does not match image tag", () => {
1458
+ const ctx = makeCtx(makeRayCluster({ rayVersion: "2.39.0", headImage: "rayproject/ray:2.40.0-py310-cpu" }));
1459
+ const diags = wk8403.check(ctx);
1460
+ const warns = diags.filter((d) => d.checkId === "WK8403");
1461
+ expect(warns.length).toBe(1);
1462
+ expect(warns[0].severity).toBe("warning");
1463
+ expect(warns[0].message).toContain("2.39.0");
1464
+ expect(warns[0].message).toContain("2.40.0");
1465
+ });
1466
+
1467
+ test("skips when rayVersion is absent (WK8402 covers that)", () => {
1468
+ const ctx = makeCtx(makeRayCluster({ headImage: "rayproject/ray:2.40.0" }));
1469
+ const diags = wk8403.check(ctx);
1470
+ expect(diags.filter((d) => d.checkId === "WK8403").length).toBe(0);
1471
+ });
1472
+
1473
+ test("skips when image tag has no parseable version", () => {
1474
+ const ctx = makeCtx(makeRayCluster({ rayVersion: "2.40.0", headImage: "rayproject/ray:latest" }));
1475
+ const diags = wk8403.check(ctx);
1476
+ expect(diags.filter((d) => d.checkId === "WK8403").length).toBe(0);
1477
+ });
1478
+ });
@@ -0,0 +1,98 @@
1
+ /**
2
+ * WK8401: shmSize exceeds container memory limit
3
+ *
4
+ * A RayCluster pod uses an emptyDir volume with medium: Memory for /dev/shm.
5
+ * Kubernetes counts that memory against the container's memory limit — if the
6
+ * sizeLimit exceeds the container's memory limit the pod will never schedule
7
+ * (Kubelet rejects it with "Invalid value … must be less than or equal to memory limit").
8
+ */
9
+
10
+ import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
11
+ import { getPrimaryOutput, parseK8sManifests, parseMemoryBytes } from "./k8s-helpers";
12
+
13
+ export const wk8401: PostSynthCheck = {
14
+ id: "WK8401",
15
+ description: "shmSize must not exceed the container memory limit — pod will not schedule if it does",
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 !== "RayCluster") continue;
26
+
27
+ const clusterName = manifest.metadata?.name ?? "RayCluster";
28
+ const spec = manifest.spec as Record<string, unknown> | undefined;
29
+ if (!spec) continue;
30
+
31
+ const groups: Array<{ label: string; templateSpec: Record<string, unknown> }> = [];
32
+
33
+ // Head group
34
+ const headGroupSpec = spec.headGroupSpec as Record<string, unknown> | undefined;
35
+ if (headGroupSpec) {
36
+ const tmpl = headGroupSpec.template as Record<string, unknown> | undefined;
37
+ const podSpec = tmpl?.spec as Record<string, unknown> | undefined;
38
+ if (podSpec) groups.push({ label: "head", templateSpec: podSpec });
39
+ }
40
+
41
+ // Worker groups
42
+ const workerGroupSpecs = spec.workerGroupSpecs as Array<Record<string, unknown>> | undefined;
43
+ if (Array.isArray(workerGroupSpecs)) {
44
+ for (const wg of workerGroupSpecs) {
45
+ const name = (wg.groupName as string | undefined) ?? "worker";
46
+ const tmpl = wg.template as Record<string, unknown> | undefined;
47
+ const podSpec = tmpl?.spec as Record<string, unknown> | undefined;
48
+ if (podSpec) groups.push({ label: `worker "${name}"`, templateSpec: podSpec });
49
+ }
50
+ }
51
+
52
+ for (const { label, templateSpec } of groups) {
53
+ // Find the emptyDir Memory volume (dshm)
54
+ const volumes = templateSpec.volumes as Array<Record<string, unknown>> | undefined;
55
+ if (!Array.isArray(volumes)) continue;
56
+
57
+ for (const vol of volumes) {
58
+ const emptyDir = vol.emptyDir as Record<string, unknown> | undefined;
59
+ if (!emptyDir || emptyDir.medium !== "Memory") continue;
60
+
61
+ const sizeLimit = emptyDir.sizeLimit as string | undefined;
62
+ if (!sizeLimit) continue;
63
+
64
+ const shmBytes = parseMemoryBytes(sizeLimit);
65
+ if (isNaN(shmBytes)) continue;
66
+
67
+ // Find memory limit from the first container that mounts this volume
68
+ const containers = templateSpec.containers as Array<Record<string, unknown>> | undefined;
69
+ if (!Array.isArray(containers)) continue;
70
+
71
+ for (const container of containers) {
72
+ const resources = container.resources as Record<string, unknown> | undefined;
73
+ const limits = resources?.limits as Record<string, unknown> | undefined;
74
+ const memLimit = limits?.memory as string | undefined;
75
+ if (!memLimit) continue;
76
+
77
+ const memBytes = parseMemoryBytes(memLimit);
78
+ if (isNaN(memBytes)) continue;
79
+
80
+ if (shmBytes > memBytes) {
81
+ diagnostics.push({
82
+ checkId: "WK8401",
83
+ severity: "error",
84
+ message: `RayCluster "${clusterName}" ${label}: shmSize "${sizeLimit}" exceeds memory limit "${memLimit}" — pod will not schedule`,
85
+ entity: clusterName,
86
+ lexicon: "k8s",
87
+ });
88
+ }
89
+ break; // Only check the first container per group
90
+ }
91
+ }
92
+ }
93
+ }
94
+ }
95
+
96
+ return diagnostics;
97
+ },
98
+ };
@@ -0,0 +1,43 @@
1
+ /**
2
+ * WK8402: RayCluster missing spec.rayVersion
3
+ *
4
+ * KubeRay uses spec.rayVersion to select the Ray autoscaler sidecar image.
5
+ * Without it, KubeRay defaults to the "latest" tag — autoscaler and Ray head
6
+ * may run mismatched versions, leading to silent protocol failures.
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 wk8402: PostSynthCheck = {
13
+ id: "WK8402",
14
+ description: "RayCluster should set spec.rayVersion so KubeRay selects the correct autoscaler image",
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 !== "RayCluster") continue;
25
+
26
+ const name = manifest.metadata?.name ?? "RayCluster";
27
+ const rayVersion = (manifest.spec as Record<string, unknown> | undefined)?.rayVersion;
28
+
29
+ if (!rayVersion) {
30
+ diagnostics.push({
31
+ checkId: "WK8402",
32
+ severity: "warning",
33
+ message: `RayCluster "${name}" is missing spec.rayVersion — KubeRay autoscaler will pull the "latest" image tag`,
34
+ entity: name,
35
+ lexicon: "k8s",
36
+ });
37
+ }
38
+ }
39
+ }
40
+
41
+ return diagnostics;
42
+ },
43
+ };