@intentius/chant-k8s-client 0.31.0

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/package.json ADDED
@@ -0,0 +1,51 @@
1
+ {
2
+ "name": "@intentius/chant-k8s-client",
3
+ "version": "0.31.0",
4
+ "description": "Typed Kubernetes API client for chant — the read/write path of the k8s lexicon, kept out of the build path",
5
+ "license": "Apache-2.0",
6
+ "homepage": "https://intentius.io/chant",
7
+ "repository": {
8
+ "type": "git",
9
+ "url": "https://github.com/INTENTIUS/chant.git",
10
+ "directory": "packages/k8s-client"
11
+ },
12
+ "bugs": {
13
+ "url": "https://github.com/INTENTIUS/chant/issues"
14
+ },
15
+ "keywords": [
16
+ "kubernetes",
17
+ "k8s",
18
+ "chant",
19
+ "infrastructure-as-code"
20
+ ],
21
+ "type": "module",
22
+ "files": [
23
+ "src/",
24
+ "dist/"
25
+ ],
26
+ "publishConfig": {
27
+ "access": "public"
28
+ },
29
+ "exports": {
30
+ ".": {
31
+ "development": "./src/index.ts",
32
+ "types": "./dist/index.d.ts",
33
+ "default": "./src/index.ts"
34
+ },
35
+ "./testing": {
36
+ "development": "./src/testing.ts",
37
+ "types": "./dist/testing.d.ts",
38
+ "default": "./src/testing.ts"
39
+ }
40
+ },
41
+ "scripts": {
42
+ "build": "tsc -p tsconfig.build.json && tsc-alias -p tsconfig.build.json && find dist -type f \\( -name \"*.js\" -o -name \"*.js.map\" \\) -delete",
43
+ "prepack": "npm run build"
44
+ },
45
+ "dependencies": {
46
+ "@kubernetes/client-node": "^1.4.0"
47
+ },
48
+ "devDependencies": {
49
+ "typescript": "^5.9.3"
50
+ }
51
+ }
@@ -0,0 +1,407 @@
1
+ /**
2
+ * The client, exercised against the real `@kubernetes/client-node` with its
3
+ * HTTP send replaced (chant #1074).
4
+ *
5
+ * Nothing here reads an ambient kubeconfig: every case passes a literal one,
6
+ * so the developer's real `~/.kube/config` and `KUBECONFIG` are never
7
+ * consulted and no request can leave the process. What *does* run for real is
8
+ * everything above the send: kubeconfig parsing, context selection, the
9
+ * credential policy, the auth path that writes `Authorization`, discovery, and
10
+ * URL construction.
11
+ */
12
+
13
+ import { describe, test, expect } from "vitest";
14
+ import { createK8sClient } from "./client";
15
+ import { K8sApiError, K8sTransportError, ExecCredentialNotAllowedError, KubeConfigError, UnknownResourceError } from "./errors";
16
+ import { apiResourceList, fakeKubeconfig, fakeRequestLayer, statusBody } from "./testing";
17
+ import type { RecordedRequest } from "./testing";
18
+
19
+ const CORE_V1 = apiResourceList("v1", [
20
+ { name: "pods", kind: "Pod", singularName: "pod", shortNames: ["po"] },
21
+ { name: "pods/status", kind: "Pod" },
22
+ { name: "services", kind: "Service", singularName: "service", shortNames: ["svc"] },
23
+ { name: "namespaces", kind: "Namespace", namespaced: false },
24
+ { name: "configmaps", kind: "ConfigMap", shortNames: ["cm"] },
25
+ ]);
26
+
27
+ const APPS_V1 = apiResourceList("apps/v1", [
28
+ { name: "deployments", kind: "Deployment", singularName: "deployment", shortNames: ["deploy"] },
29
+ { name: "statefulsets", kind: "StatefulSet" },
30
+ ]);
31
+
32
+ const RAY_V1 = apiResourceList("ray.io/v1", [
33
+ { name: "rayclusters", kind: "RayCluster", singularName: "raycluster" },
34
+ { name: "rayjobs", kind: "RayJob" },
35
+ ]);
36
+
37
+ const CERT_V1 = apiResourceList("cert-manager.io/v1", [
38
+ { name: "certificates", kind: "Certificate", singularName: "certificate", shortNames: ["cert"] },
39
+ ]);
40
+
41
+ const ROOT_DISCOVERY: Record<string, unknown> = {
42
+ "/api": { kind: "APIVersions", versions: ["v1"] },
43
+ "/apis": {
44
+ kind: "APIGroupList",
45
+ groups: [
46
+ { name: "apps", preferredVersion: { groupVersion: "apps/v1", version: "v1" }, versions: [{ groupVersion: "apps/v1" }] },
47
+ { name: "ray.io", preferredVersion: { groupVersion: "ray.io/v1", version: "v1" }, versions: [{ groupVersion: "ray.io/v1" }] },
48
+ {
49
+ name: "cert-manager.io",
50
+ preferredVersion: { groupVersion: "cert-manager.io/v1", version: "v1" },
51
+ versions: [{ groupVersion: "cert-manager.io/v1" }],
52
+ },
53
+ ],
54
+ },
55
+ "/api/v1": CORE_V1,
56
+ "/apis/apps/v1": APPS_V1,
57
+ "/apis/ray.io/v1": RAY_V1,
58
+ "/apis/cert-manager.io/v1": CERT_V1,
59
+ };
60
+
61
+ function deployment(name: string, namespace = "prod"): Record<string, unknown> {
62
+ return {
63
+ apiVersion: "apps/v1",
64
+ kind: "Deployment",
65
+ metadata: { name, namespace, uid: `uid-${name}`, resourceVersion: "42", labels: { app: name } },
66
+ status: { replicas: 3, readyReplicas: 3 },
67
+ };
68
+ }
69
+
70
+ /** A cluster that answers discovery plus whatever `objects` maps by path. */
71
+ function cluster(objects: Record<string, unknown> = {}, override?: (req: RecordedRequest) => unknown) {
72
+ return fakeRequestLayer((req) => {
73
+ const custom = override?.(req);
74
+ if (custom !== undefined) return custom as { status?: number; body?: unknown };
75
+ if (req.path in ROOT_DISCOVERY) return { body: ROOT_DISCOVERY[req.path] };
76
+ if (req.path in objects) return { body: objects[req.path] };
77
+ return { status: 404, body: statusBody(404, "NotFound", `${req.path} not found`) };
78
+ });
79
+ }
80
+
81
+ async function client(layer: ReturnType<typeof cluster>, options: Record<string, unknown> = {}) {
82
+ return createK8sClient({ kubeconfig: fakeKubeconfig(), requestLayer: layer, ...options });
83
+ }
84
+
85
+ describe("createK8sClient — kubeconfig and credential policy", () => {
86
+ test("resolves the bound context explicitly, and records it as bound", async () => {
87
+ const layer = cluster();
88
+ const c = await createK8sClient({
89
+ kubeconfig: fakeKubeconfig({
90
+ contexts: [
91
+ { name: "staging-eks", cluster: "staging", user: "staging-user" },
92
+ { name: "prod-eks", cluster: "prod", user: "prod-user", namespace: "prod" },
93
+ ],
94
+ currentContext: "staging-eks",
95
+ }),
96
+ context: "prod-eks",
97
+ contextSource: "bound",
98
+ requestLayer: layer,
99
+ });
100
+
101
+ expect(c.provenance.context).toBe("prod-eks");
102
+ expect(c.provenance.contextSource).toBe("bound");
103
+ expect(c.provenance.kubeconfigSource).toBe("explicit-string");
104
+ expect(c.defaultNamespace).toBe("prod");
105
+ });
106
+
107
+ test("a context the kubeconfig does not have refuses by name, before any request", async () => {
108
+ const layer = cluster();
109
+ await expect(
110
+ createK8sClient({
111
+ kubeconfig: fakeKubeconfig({ contexts: [{ name: "dev" }] }),
112
+ context: "prod-eks",
113
+ requestLayer: layer,
114
+ }),
115
+ ).rejects.toThrow(KubeConfigError);
116
+ expect(layer.requests).toHaveLength(0);
117
+ });
118
+
119
+ test("an exec credential plugin off the allowlist refuses before it can run", async () => {
120
+ const layer = cluster();
121
+ await expect(
122
+ createK8sClient({
123
+ kubeconfig: fakeKubeconfig({ exec: { command: "/opt/evil/harvest-creds" } }),
124
+ requestLayer: layer,
125
+ }),
126
+ ).rejects.toThrow(ExecCredentialNotAllowedError);
127
+ expect(layer.requests).toHaveLength(0);
128
+ });
129
+
130
+ test("the three managed-cluster plugins are allowed by default, and recorded as provenance", async () => {
131
+ for (const command of ["aws", "gke-gcloud-auth-plugin", "kubelogin"]) {
132
+ const c = await createK8sClient({
133
+ kubeconfig: fakeKubeconfig({ exec: { command, args: ["--version"] } }),
134
+ requestLayer: cluster(),
135
+ });
136
+ expect(c.provenance.credential).toBe("exec-plugin");
137
+ expect(c.provenance.execCommand).toBe(command);
138
+ }
139
+ });
140
+
141
+ test("a static token authorizes every request through client-node's own auth path", async () => {
142
+ const layer = cluster({ "/apis/apps/v1/namespaces/prod/deployments/web": deployment("web") });
143
+ const c = await createK8sClient({
144
+ kubeconfig: fakeKubeconfig({ token: "sekret-token" }),
145
+ requestLayer: layer,
146
+ });
147
+ await c.read({ apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" });
148
+
149
+ expect(layer.requests.length).toBeGreaterThan(0);
150
+ for (const req of layer.requests) {
151
+ expect(req.headers.Authorization).toBe("Bearer sekret-token");
152
+ }
153
+ expect(c.provenance.credential).toBe("token");
154
+ });
155
+ });
156
+
157
+ describe("resource resolution through the cluster's own discovery", () => {
158
+ test("a CRD resolves with no hand-maintained mapping anywhere", async () => {
159
+ const c = await client(cluster());
160
+ const info = await c.resolve({ apiVersion: "ray.io/v1", kind: "RayCluster" });
161
+ expect(info).toMatchObject({ name: "rayclusters", namespaced: true, group: "ray.io", version: "v1" });
162
+ });
163
+
164
+ test("a cluster-scoped kind is reported as such, and its path carries no namespace", async () => {
165
+ const layer = cluster({ "/api/v1/namespaces/ns-a": { apiVersion: "v1", kind: "Namespace", metadata: { name: "ns-a" } } });
166
+ const c = await client(layer);
167
+ const info = await c.resolve({ apiVersion: "v1", kind: "Namespace" });
168
+ expect(info?.namespaced).toBe(false);
169
+
170
+ await c.read({ apiVersion: "v1", kind: "Namespace", name: "ns-a" });
171
+ expect(layer.paths()).toContain("/api/v1/namespaces/ns-a");
172
+ expect(layer.paths().some((p) => p.includes("/namespaces/default/namespaces"))).toBe(false);
173
+ });
174
+
175
+ test("subresources are never mistaken for resources", async () => {
176
+ const c = await client(cluster());
177
+ const info = await c.resolve({ apiVersion: "v1", kind: "Pod" });
178
+ expect(info?.name).toBe("pods");
179
+ });
180
+
181
+ test("a kind the cluster does not serve resolves to undefined rather than throwing", async () => {
182
+ const c = await client(cluster());
183
+ expect(await c.resolve({ apiVersion: "apps/v1", kind: "Widget" })).toBeUndefined();
184
+ // A whole group/version the cluster has never heard of, likewise.
185
+ expect(await c.resolve({ apiVersion: "widgets.example.com/v1", kind: "Widget" })).toBeUndefined();
186
+ });
187
+
188
+ test("discovery is fetched once per apiVersion no matter how many entities need it", async () => {
189
+ const objects: Record<string, unknown> = {};
190
+ for (let i = 0; i < 25; i++) objects[`/apis/apps/v1/namespaces/prod/deployments/web-${i}`] = deployment(`web-${i}`);
191
+ const layer = cluster(objects);
192
+ const c = await client(layer);
193
+
194
+ await c.concurrently(
195
+ Array.from({ length: 25 }, (_, i) => i),
196
+ (i) => c.read({ apiVersion: "apps/v1", kind: "Deployment", name: `web-${i}`, namespace: "prod" }),
197
+ );
198
+
199
+ expect(layer.paths().filter((p) => p === "/apis/apps/v1")).toHaveLength(1);
200
+ expect(c.discoveryCacheKeys()).toEqual(["apps/v1"]);
201
+ });
202
+
203
+ test("kubectl-style resource strings resolve the way kubectl resolves them", async () => {
204
+ const c = await client(cluster());
205
+ // plural.group — how waitForReady's callers have always spelled CRDs
206
+ expect((await c.resolve({ resource: "raycluster.ray.io" }))?.name).toBe("rayclusters");
207
+ // bare plural, searched across every served group-version
208
+ expect((await c.resolve({ resource: "certificates" }))?.apiVersion).toBe("cert-manager.io/v1");
209
+ // short name
210
+ expect((await c.resolve({ resource: "deploy" }))?.name).toBe("deployments");
211
+ // kind
212
+ expect((await c.resolve({ resource: "StatefulSet" }))?.name).toBe("statefulsets");
213
+ // explicit group argument
214
+ expect((await c.resolve({ resource: "deployments", group: "apps" }))?.apiVersion).toBe("apps/v1");
215
+ // nothing matching
216
+ expect(await c.resolve({ resource: "widgets" })).toBeUndefined();
217
+ });
218
+ });
219
+
220
+ describe("reads", () => {
221
+ test("builds the namespaced object path from discovery and returns the raw object", async () => {
222
+ const layer = cluster({ "/apis/apps/v1/namespaces/prod/deployments/web": deployment("web") });
223
+ const c = await client(layer);
224
+ const obj = await c.read({ apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" });
225
+
226
+ expect(obj.metadata?.uid).toBe("uid-web");
227
+ // Raw, not coerced into a model: nothing was dropped and nothing became a Date.
228
+ expect(obj.metadata?.resourceVersion).toBe("42");
229
+ expect(obj.status).toEqual({ replicas: 3, readyReplicas: 3 });
230
+ expect(layer.paths()).toEqual(["/apis/apps/v1", "/apis/apps/v1/namespaces/prod/deployments/web"]);
231
+ });
232
+
233
+ test("an object with no namespace falls back to the context's namespace", async () => {
234
+ const layer = cluster({ "/apis/apps/v1/namespaces/team-a/deployments/web": deployment("web", "team-a") });
235
+ const c = await createK8sClient({
236
+ kubeconfig: fakeKubeconfig({ contexts: [{ name: "ctx", namespace: "team-a" }] }),
237
+ requestLayer: layer,
238
+ });
239
+ await c.read({ apiVersion: "apps/v1", kind: "Deployment", name: "web" });
240
+ expect(layer.paths()).toContain("/apis/apps/v1/namespaces/team-a/deployments/web");
241
+ });
242
+
243
+ test("a 404 arrives as a typed error, not a parsed stderr line", async () => {
244
+ const c = await client(cluster());
245
+ const err = await c
246
+ .read({ apiVersion: "apps/v1", kind: "Deployment", name: "gone", namespace: "prod" })
247
+ .catch((e: unknown) => e);
248
+
249
+ expect(err).toBeInstanceOf(K8sApiError);
250
+ expect((err as K8sApiError).statusCode).toBe(404);
251
+ expect((err as K8sApiError).reason).toBe("NotFound");
252
+ expect((err as K8sApiError).notFound).toBe(true);
253
+ expect((err as K8sApiError).forbidden).toBe(false);
254
+ });
255
+
256
+ test.each([
257
+ [401, "Unauthorized", "unauthorized"],
258
+ [403, "Forbidden", "forbidden"],
259
+ [409, "Conflict", "conflict"],
260
+ ])("HTTP %i / %s classifies structurally", async (code, reason, flag) => {
261
+ const layer = cluster({}, (req) =>
262
+ req.path.endsWith("/deployments/web") ? { status: code, body: statusBody(code, reason, "nope") } : undefined,
263
+ );
264
+ const c = await client(layer);
265
+ const err = (await c
266
+ .read({ apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" })
267
+ .catch((e: unknown) => e)) as K8sApiError;
268
+
269
+ expect(err).toBeInstanceOf(K8sApiError);
270
+ expect(err.statusCode).toBe(code);
271
+ expect(err.reason).toBe(reason);
272
+ expect(err[flag as "unauthorized" | "forbidden" | "conflict"]).toBe(true);
273
+ });
274
+
275
+ test("a kind the cluster does not serve is an UnknownResourceError, distinct from a 404", async () => {
276
+ const c = await client(cluster());
277
+ await expect(
278
+ c.read({ apiVersion: "widgets.example.com/v1", kind: "Widget", name: "w", namespace: "prod" }),
279
+ ).rejects.toThrow(UnknownResourceError);
280
+ });
281
+
282
+ test("a transport failure is a K8sTransportError carrying its cause", async () => {
283
+ const layer = fakeRequestLayer(() => {
284
+ throw new Error("connect ECONNREFUSED 127.0.0.1:6443");
285
+ });
286
+ const c = await client(layer as unknown as ReturnType<typeof cluster>);
287
+ const err = (await c
288
+ .read({ apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" })
289
+ .catch((e: unknown) => e)) as K8sTransportError;
290
+
291
+ expect(err).toBeInstanceOf(K8sTransportError);
292
+ expect(err.message).toContain("ECONNREFUSED");
293
+ });
294
+
295
+ test("readIfPresent turns a 404 into undefined and leaves other failures alone", async () => {
296
+ const layer = cluster({ "/apis/apps/v1/namespaces/prod/deployments/web": deployment("web") }, (req) =>
297
+ req.path.endsWith("/deployments/denied") ? { status: 403, body: statusBody(403, "Forbidden", "rbac") } : undefined,
298
+ );
299
+ const c = await client(layer);
300
+ expect(await c.readIfPresent({ apiVersion: "apps/v1", kind: "Deployment", name: "web", namespace: "prod" })).toBeTruthy();
301
+ expect(await c.readIfPresent({ apiVersion: "apps/v1", kind: "Deployment", name: "gone", namespace: "prod" })).toBeUndefined();
302
+ await expect(
303
+ c.readIfPresent({ apiVersion: "apps/v1", kind: "Deployment", name: "denied", namespace: "prod" }),
304
+ ).rejects.toThrow(K8sApiError);
305
+ });
306
+ });
307
+
308
+ describe("concurrency", () => {
309
+ test("100 reads are not 100 serial round trips, and never exceed the ceiling", async () => {
310
+ let inFlight = 0;
311
+ let peak = 0;
312
+ const objects: Record<string, unknown> = {};
313
+ for (let i = 0; i < 100; i++) objects[`/apis/apps/v1/namespaces/prod/deployments/web-${i}`] = deployment(`web-${i}`);
314
+
315
+ const layer = fakeRequestLayer(async (req) => {
316
+ inFlight++;
317
+ peak = Math.max(peak, inFlight);
318
+ await new Promise((r) => setTimeout(r, 1));
319
+ inFlight--;
320
+ if (req.path in ROOT_DISCOVERY) return { body: ROOT_DISCOVERY[req.path] };
321
+ if (req.path in objects) return { body: objects[req.path] };
322
+ return { status: 404, body: statusBody(404, "NotFound", "no") };
323
+ });
324
+
325
+ const c = await createK8sClient({ kubeconfig: fakeKubeconfig(), requestLayer: layer, concurrency: 8 });
326
+ const results = await c.concurrently(
327
+ Array.from({ length: 100 }, (_, i) => i),
328
+ (i) => c.read({ apiVersion: "apps/v1", kind: "Deployment", name: `web-${i}`, namespace: "prod" }),
329
+ );
330
+
331
+ expect(results).toHaveLength(100);
332
+ expect(results[0].metadata?.name).toBe("web-0");
333
+ expect(results[99].metadata?.name).toBe("web-99");
334
+ expect(peak).toBeGreaterThan(1);
335
+ expect(peak).toBeLessThanOrEqual(8);
336
+ });
337
+ });
338
+
339
+ describe("list", () => {
340
+ test("lists across all namespaces and follows continue tokens", async () => {
341
+ const layer = fakeRequestLayer((req) => {
342
+ if (req.path in ROOT_DISCOVERY) return { body: ROOT_DISCOVERY[req.path] };
343
+ if (req.path === "/apis/apps/v1/deployments") {
344
+ return req.query.continue === "page2"
345
+ ? { body: { items: [deployment("b")], metadata: {} } }
346
+ : { body: { items: [deployment("a")], metadata: { continue: "page2" } } };
347
+ }
348
+ return { status: 404, body: statusBody(404, "NotFound", "no") };
349
+ });
350
+ const c = await client(layer as unknown as ReturnType<typeof cluster>);
351
+ const items = await c.list({ apiVersion: "apps/v1", kind: "Deployment" });
352
+ expect(items.map((i) => i.metadata?.name)).toEqual(["a", "b"]);
353
+ });
354
+
355
+ test("a namespace narrows the path", async () => {
356
+ const layer = cluster({ "/apis/apps/v1/namespaces/prod/deployments": { items: [deployment("a")] } });
357
+ const c = await client(layer);
358
+ await c.list({ apiVersion: "apps/v1", kind: "Deployment" }, { namespace: "prod" });
359
+ expect(layer.paths()).toContain("/apis/apps/v1/namespaces/prod/deployments");
360
+ });
361
+ });
362
+
363
+ describe("apply", () => {
364
+ test("server-side applies with chant as the field manager", async () => {
365
+ const layer = cluster({}, (req) =>
366
+ req.path === "/apis/apps/v1/namespaces/prod/deployments/web" && req.method === "PATCH"
367
+ ? { body: deployment("web") }
368
+ : undefined,
369
+ );
370
+ const c = await client(layer);
371
+ await c.apply(deployment("web") as never);
372
+
373
+ const patch = layer.requests.find((r) => r.method === "PATCH")!;
374
+ expect(patch.path).toBe("/apis/apps/v1/namespaces/prod/deployments/web");
375
+ expect(patch.headers["Content-Type"]).toBe("application/apply-patch+yaml");
376
+ expect(patch.query).toMatchObject({ fieldManager: "chant", force: "false" });
377
+ expect(JSON.parse(String(patch.body)).metadata.name).toBe("web");
378
+ });
379
+
380
+ test("force and dryRun are query parameters, not a different code path", async () => {
381
+ const layer = cluster({}, (req) => (req.method === "PATCH" ? { body: deployment("web") } : undefined));
382
+ const c = await client(layer);
383
+ await c.apply(deployment("web") as never, { force: true, dryRun: true, fieldManager: "chant-op" });
384
+ const patch = layer.requests.find((r) => r.method === "PATCH")!;
385
+ expect(patch.query).toMatchObject({ fieldManager: "chant-op", force: "true", dryRun: "All" });
386
+ });
387
+
388
+ test("a field-ownership conflict arrives as a typed 409", async () => {
389
+ const layer = cluster({}, (req) =>
390
+ req.method === "PATCH"
391
+ ? { status: 409, body: statusBody(409, "Conflict", 'Apply failed with 1 conflict: conflict with "kubectl"') }
392
+ : undefined,
393
+ );
394
+ const c = await client(layer);
395
+ const err = (await c.apply(deployment("web") as never).catch((e: unknown) => e)) as K8sApiError;
396
+ expect(err.conflict).toBe(true);
397
+ expect(err.apiMessage).toContain("conflict with");
398
+ });
399
+
400
+ test("an object missing apiVersion/kind/name is refused before any request", async () => {
401
+ const layer = cluster();
402
+ const c = await client(layer);
403
+ await expect(c.apply({ kind: "Deployment", metadata: { name: "x" } })).rejects.toThrow(KubeConfigError);
404
+ await expect(c.apply({ apiVersion: "apps/v1", kind: "Deployment", metadata: {} })).rejects.toThrow(KubeConfigError);
405
+ expect(layer.requests).toHaveLength(0);
406
+ });
407
+ });