@intentius/chant-lexicon-k8s 0.0.24 → 0.1.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/integrity.json +28 -25
- package/dist/manifest.json +1 -1
- package/dist/meta.json +121 -0
- package/dist/rules/k8s-helpers.ts +39 -0
- package/dist/rules/wk8401.ts +98 -0
- package/dist/rules/wk8402.ts +43 -0
- package/dist/rules/wk8403.ts +60 -0
- package/dist/skills/chant-k8s-gke.md +1 -1
- package/dist/types/index.d.ts +30 -0
- package/package.json +7 -4
- package/src/codegen/generate.ts +22 -0
- package/src/composites/cockroachdb-cluster.ts +16 -0
- package/src/composites/composites.test.ts +4 -4
- package/src/composites/config-connector-context.ts +3 -3
- package/src/composites/index.ts +6 -0
- package/src/composites/ray-cluster.ts +590 -0
- package/src/composites/ray-job.ts +235 -0
- package/src/composites/ray-service.ts +271 -0
- package/src/crd/crd-sources.ts +29 -0
- package/src/crd/parser.ts +17 -12
- package/src/generated/index.d.ts +30 -0
- package/src/generated/index.ts +13 -0
- package/src/generated/lexicon-k8s.json +121 -0
- package/src/index.ts +4 -0
- package/src/lint/post-synth/k8s-helpers.ts +39 -0
- package/src/lint/post-synth/post-synth.test.ts +148 -0
- package/src/lint/post-synth/wk8401.ts +98 -0
- package/src/lint/post-synth/wk8402.ts +43 -0
- package/src/lint/post-synth/wk8403.ts +60 -0
- package/src/plugin.test.ts +2 -2
- package/src/serializer.ts +6 -0
- package/src/skills/chant-k8s-gke.md +1 -1
- package/src/skills/chant-k8s-ray.md +252 -0
|
@@ -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
|
}
|
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,
|
|
@@ -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
|
+
}
|
|
@@ -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
|
+
};
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* WK8403: spec.rayVersion does not match head image tag
|
|
3
|
+
*
|
|
4
|
+
* When spec.rayVersion is set but doesn't match the version in the head
|
|
5
|
+
* container image tag, the KubeRay autoscaler sidecar will run a different
|
|
6
|
+
* Ray version than the cluster. This can cause gRPC compatibility failures.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
import type { PostSynthCheck, PostSynthContext, PostSynthDiagnostic } from "@intentius/chant/lint/post-synth";
|
|
10
|
+
import { getPrimaryOutput, parseK8sManifests, extractRayVersion } from "./k8s-helpers";
|
|
11
|
+
|
|
12
|
+
export const wk8403: PostSynthCheck = {
|
|
13
|
+
id: "WK8403",
|
|
14
|
+
description: "spec.rayVersion should match the Ray version in the head container image tag",
|
|
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 clusterName = manifest.metadata?.name ?? "RayCluster";
|
|
27
|
+
const spec = manifest.spec as Record<string, unknown> | undefined;
|
|
28
|
+
if (!spec) continue;
|
|
29
|
+
|
|
30
|
+
const rayVersion = spec.rayVersion as string | undefined;
|
|
31
|
+
if (!rayVersion) continue; // WK8402 covers the missing case
|
|
32
|
+
|
|
33
|
+
// Extract version from head container image
|
|
34
|
+
const headGroupSpec = spec.headGroupSpec as Record<string, unknown> | undefined;
|
|
35
|
+
const tmpl = headGroupSpec?.template as Record<string, unknown> | undefined;
|
|
36
|
+
const podSpec = tmpl?.spec as Record<string, unknown> | undefined;
|
|
37
|
+
const containers = podSpec?.containers as Array<Record<string, unknown>> | undefined;
|
|
38
|
+
if (!Array.isArray(containers) || containers.length === 0) continue;
|
|
39
|
+
|
|
40
|
+
const image = containers[0].image as string | undefined;
|
|
41
|
+
if (!image) continue;
|
|
42
|
+
|
|
43
|
+
const imageVersion = extractRayVersion(image);
|
|
44
|
+
if (!imageVersion) continue; // Can't determine version from tag
|
|
45
|
+
|
|
46
|
+
if (imageVersion !== rayVersion) {
|
|
47
|
+
diagnostics.push({
|
|
48
|
+
checkId: "WK8403",
|
|
49
|
+
severity: "warning",
|
|
50
|
+
message: `RayCluster "${clusterName}": spec.rayVersion "${rayVersion}" does not match head image tag "${imageVersion}" — autoscaler may run a mismatched Ray version`,
|
|
51
|
+
entity: clusterName,
|
|
52
|
+
lexicon: "k8s",
|
|
53
|
+
});
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
return diagnostics;
|
|
59
|
+
},
|
|
60
|
+
};
|
package/src/plugin.test.ts
CHANGED
|
@@ -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
|
|
33
|
+
test("postSynthChecks() returns array of post-synth checks", () => {
|
|
34
34
|
const checks = k8sPlugin.postSynthChecks!();
|
|
35
35
|
expect(Array.isArray(checks)).toBe(true);
|
|
36
|
-
expect(checks.length).toBe(
|
|
36
|
+
expect(checks.length).toBe(26);
|
|
37
37
|
});
|
|
38
38
|
|
|
39
39
|
test("intrinsics() returns empty array", () => {
|
package/src/serializer.ts
CHANGED
|
@@ -91,6 +91,12 @@ const API_GROUP_VERSIONS: Record<string, string> = {
|
|
|
91
91
|
GKE: "cloud.google.com/v1",
|
|
92
92
|
NetworkingGKE: "networking.gke.io/v1",
|
|
93
93
|
NetworkingGKEBeta: "networking.gke.io/v1beta1",
|
|
94
|
+
// Common Kubernetes operator CRDs
|
|
95
|
+
CertManager: "cert-manager.io/v1",
|
|
96
|
+
ExternalSecrets: "external-secrets.io/v1",
|
|
97
|
+
Monitoring: "monitoring.coreos.com/v1",
|
|
98
|
+
// KubeRay operator CRDs
|
|
99
|
+
Ray: "ray.io/v1",
|
|
94
100
|
};
|
|
95
101
|
|
|
96
102
|
function deriveGVKFromType(entityType: string): { apiVersion: string; kind: string } | null {
|
|
@@ -206,7 +206,7 @@ import { ConfigConnectorContext } from "@intentius/chant-lexicon-k8s";
|
|
|
206
206
|
const { context } = ConfigConnectorContext({
|
|
207
207
|
googleServiceAccountEmail: "cc-sa@my-project.iam.gserviceaccount.com",
|
|
208
208
|
namespace: "config-connector",
|
|
209
|
-
stateIntoSpec: "
|
|
209
|
+
stateIntoSpec: "Absent",
|
|
210
210
|
});
|
|
211
211
|
```
|
|
212
212
|
|