@intentius/chant-lexicon-k8s 0.30.0 → 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.
Files changed (54) hide show
  1. package/dist/api/classify.d.ts +55 -0
  2. package/dist/api/classify.d.ts.map +1 -0
  3. package/dist/api/connect.d.ts +58 -0
  4. package/dist/api/connect.d.ts.map +1 -0
  5. package/dist/api/fake-cluster.d.ts +55 -0
  6. package/dist/api/fake-cluster.d.ts.map +1 -0
  7. package/dist/api/operation-surface.d.ts +64 -0
  8. package/dist/api/operation-surface.d.ts.map +1 -0
  9. package/dist/codegen/generate-operations.d.ts +29 -0
  10. package/dist/codegen/generate-operations.d.ts.map +1 -0
  11. package/dist/codegen/generate.d.ts.map +1 -1
  12. package/dist/config.d.ts +17 -0
  13. package/dist/config.d.ts.map +1 -1
  14. package/dist/crd/parser.d.ts.map +1 -1
  15. package/dist/crd/types.d.ts +7 -0
  16. package/dist/crd/types.d.ts.map +1 -1
  17. package/dist/describe-resources.d.ts +41 -24
  18. package/dist/describe-resources.d.ts.map +1 -1
  19. package/dist/export-resources.d.ts +27 -1
  20. package/dist/export-resources.d.ts.map +1 -1
  21. package/dist/integrity.json +2 -2
  22. package/dist/manifest.json +1 -1
  23. package/dist/op/activities/index.d.ts +2 -2
  24. package/dist/op/activities/index.d.ts.map +1 -1
  25. package/dist/op/activities/kubectl.d.ts +38 -2
  26. package/dist/op/activities/kubectl.d.ts.map +1 -1
  27. package/dist/op/activities/wait-for-ready.d.ts +30 -3
  28. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  29. package/dist/spec/parse.d.ts +42 -0
  30. package/dist/spec/parse.d.ts.map +1 -1
  31. package/package.json +5 -2
  32. package/src/api/classify.test.ts +133 -0
  33. package/src/api/classify.ts +131 -0
  34. package/src/api/connect.ts +104 -0
  35. package/src/api/fake-cluster.ts +218 -0
  36. package/src/api/operation-surface.test.ts +116 -0
  37. package/src/api/operation-surface.ts +129 -0
  38. package/src/codegen/generate-operations.ts +56 -0
  39. package/src/codegen/generate.ts +9 -0
  40. package/src/config.ts +17 -0
  41. package/src/crd/parser.ts +8 -0
  42. package/src/crd/types.ts +7 -0
  43. package/src/describe-resources.test.ts +396 -191
  44. package/src/describe-resources.ts +134 -118
  45. package/src/export-resources-io.test.ts +76 -51
  46. package/src/export-resources.ts +63 -35
  47. package/src/generated/operations.json +2156 -0
  48. package/src/lifecycle-integration.test.ts +132 -92
  49. package/src/op/activities/index.ts +2 -1
  50. package/src/op/activities/kubectl.test.ts +148 -0
  51. package/src/op/activities/kubectl.ts +86 -13
  52. package/src/op/activities/wait-for-ready.test.ts +94 -0
  53. package/src/op/activities/wait-for-ready.ts +66 -15
  54. package/src/spec/parse.ts +93 -1
@@ -1,15 +1,14 @@
1
- import { describe, test, expect, vi, beforeEach } from "vitest";
1
+ /**
2
+ * `describeResources` over the typed API client (chant #1074).
3
+ *
4
+ * Every case drives the real client against a literal kubeconfig with the
5
+ * transport faked, so no ambient kubeconfig is read and no cluster is
6
+ * contacted. The tri-state contract (chant #1089) and the cluster binding
7
+ * (chant #1100/#1155) are asserted through the new path, since both had to
8
+ * survive the move off `kubectl` unchanged.
9
+ */
2
10
 
3
- const execMock = vi.fn();
4
- vi.mock("node:child_process", async () => {
5
- const actual = await vi.importActual<typeof import("node:child_process")>("node:child_process");
6
- return { ...actual, exec: (cmd: string, cb: (err: Error | null, out: { stdout: string; stderr: string }) => void) => {
7
- Promise.resolve(execMock(cmd)).then(
8
- (out) => cb(null, out),
9
- (err) => cb(err as Error, { stdout: "", stderr: "" }),
10
- );
11
- } };
12
- });
11
+ import { describe, test, expect, vi, beforeEach } from "vitest";
13
12
 
14
13
  const loadChantConfigMock = vi.fn();
15
14
  vi.mock("@intentius/chant/config", () => ({
@@ -17,283 +16,489 @@ vi.mock("@intentius/chant/config", () => ({
17
16
  }));
18
17
 
19
18
  const { describeResources } = await import("./describe-resources");
19
+ const { fakeCluster, objectKey } = await import("./api/fake-cluster");
20
+ const { defaultK8sConnector } = await import("./api/connect");
21
+ const { fakeKubeconfig, statusBody } = await import("@intentius/chant-k8s-client/testing");
22
+
23
+ type Entity = { name: string; entityType: string; props: Record<string, unknown> };
20
24
 
21
- function makeEntities(records: Array<{ name: string; entityType: string; props: Record<string, unknown> }>) {
25
+ function makeEntities(records: Entity[]) {
22
26
  return new Map(records.map((r) => [r.name, { entityType: r.entityType, props: r.props }]));
23
27
  }
24
28
 
29
+ const web = {
30
+ apiVersion: "apps/v1",
31
+ kind: "Deployment",
32
+ metadata: {
33
+ name: "web",
34
+ namespace: "prod",
35
+ uid: "uid-1",
36
+ creationTimestamp: "2026-05-01T00:00:00Z",
37
+ resourceVersion: "7",
38
+ labels: { app: "web" },
39
+ },
40
+ status: { readyReplicas: 3, replicas: 3 },
41
+ };
42
+
43
+ const webSvc = {
44
+ apiVersion: "v1",
45
+ kind: "Service",
46
+ metadata: { name: "web-svc", namespace: "prod", uid: "uid-2", creationTimestamp: "2026-05-01T00:00:00Z" },
47
+ };
48
+
25
49
  describe("k8s describeResources", () => {
26
50
  beforeEach(() => {
27
- execMock.mockReset();
28
51
  loadChantConfigMock.mockReset();
29
- // No binding declared by default — matches every test below except the
30
- // dedicated cluster-binding tests, which override this per case.
31
52
  loadChantConfigMock.mockResolvedValue({ config: {} });
32
53
  });
33
54
 
34
- test("queries kubectl for each declared K8s entity and maps response to ResourceMetadata", async () => {
35
- execMock.mockImplementation((cmd: string) => {
36
- if (cmd.includes("deployment.apps web")) {
37
- return {
38
- stdout: JSON.stringify({
39
- metadata: { name: "web", namespace: "prod", uid: "uid-1", creationTimestamp: "2026-05-01T00:00:00Z", labels: { app: "web" } },
40
- status: { readyReplicas: 3, replicas: 3 },
41
- }),
42
- stderr: "",
43
- };
44
- }
45
- if (cmd.includes("service web-svc")) {
46
- return {
47
- stdout: JSON.stringify({
48
- metadata: { name: "web-svc", namespace: "prod", uid: "uid-2", creationTimestamp: "2026-05-01T00:00:00Z" },
49
- }),
50
- stderr: "",
51
- };
52
- }
53
- throw new Error(`unexpected cmd: ${cmd}`);
55
+ test("reads each declared entity through the API and maps it to ResourceMetadata", async () => {
56
+ const cluster = fakeCluster({
57
+ objects: {
58
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: web,
59
+ [objectKey("v1", "Service", "web-svc", "prod")]: webSvc,
60
+ },
54
61
  });
55
62
 
56
- const entities = makeEntities([
57
- { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
58
- { name: "webSvc", entityType: "K8s::Core::Service", props: { metadata: { name: "web-svc", namespace: "prod" } } },
59
- ]);
60
-
61
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web", "webSvc"], entities });
63
+ const result = await describeResources(
64
+ {
65
+ environment: "prod",
66
+ buildOutput: "",
67
+ entityNames: ["web", "webSvc"],
68
+ entities: makeEntities([
69
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
70
+ { name: "webSvc", entityType: "K8s::Core::Service", props: { metadata: { name: "web-svc", namespace: "prod" } } },
71
+ ]),
72
+ },
73
+ cluster.connector,
74
+ );
62
75
 
63
- expect(result.resources["web"]).toMatchObject({
76
+ expect(result.resources.web).toMatchObject({
64
77
  type: "K8s::Apps::Deployment",
65
78
  physicalId: "uid-1",
66
79
  status: "READY",
67
- attributes: expect.objectContaining({ namespace: "prod", labels: { app: "web" } }),
68
- });
69
- expect(result.resources["webSvc"]).toMatchObject({
70
- type: "K8s::Core::Service",
71
- physicalId: "uid-2",
72
- status: "PRESENT",
80
+ lastUpdated: "2026-05-01T00:00:00Z",
81
+ attributes: expect.objectContaining({ namespace: "prod", labels: { app: "web" }, resourceVersion: "7" }),
73
82
  });
83
+ expect(result.resources.webSvc).toMatchObject({ type: "K8s::Core::Service", physicalId: "uid-2", status: "PRESENT" });
84
+
85
+ // The paths came from the cluster's discovery, not from a table.
86
+ expect(cluster.layer.paths()).toContain("/apis/apps/v1/namespaces/prod/deployments/web");
87
+ expect(cluster.layer.paths()).toContain("/api/v1/namespaces/prod/services/web-svc");
74
88
  });
75
89
 
76
- test("kubectl-not-found leaves entity out of the result (so state diff reports as missing)", async () => {
77
- execMock.mockImplementation(() => { throw new Error('Error from server (NotFound): deployments.apps "missing" not found'); });
90
+ test("a NotFound leaves the entity out of both maps — an absence, the only shape that becomes a create", async () => {
91
+ const cluster = fakeCluster();
92
+ const result = await describeResources(
93
+ {
94
+ environment: "prod",
95
+ buildOutput: "",
96
+ entityNames: ["missing"],
97
+ entities: makeEntities([
98
+ { name: "missing", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "missing", namespace: "prod" } } },
99
+ ]),
100
+ },
101
+ cluster.connector,
102
+ );
78
103
 
79
- const entities = makeEntities([
80
- { name: "missing", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "missing", namespace: "prod" } } },
81
- ]);
104
+ expect(result.resources).toEqual({});
105
+ expect(result.unobserved ?? {}).toEqual({});
106
+ });
82
107
 
83
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["missing"], entities });
108
+ // The headline of chant #1074: the twenty-entry map is gone, so a CRD is an
109
+ // ordinary read rather than a permanent hole.
110
+ test("a CRD is observed like anything else — no KUBECTL_RESOURCE entry required", async () => {
111
+ const rayCluster = {
112
+ apiVersion: "ray.io/v1",
113
+ kind: "RayCluster",
114
+ metadata: { name: "ml", namespace: "ray", uid: "uid-ray", creationTimestamp: "2026-05-01T00:00:00Z" },
115
+ status: { phase: "ready" },
116
+ };
117
+ const cluster = fakeCluster({ objects: { [objectKey("ray.io/v1", "RayCluster", "ml", "ray")]: rayCluster } });
118
+
119
+ const result = await describeResources(
120
+ {
121
+ environment: "prod",
122
+ buildOutput: "",
123
+ entityNames: ["ml"],
124
+ entities: makeEntities([
125
+ { name: "ml", entityType: "K8s::Ray::RayCluster", props: { metadata: { name: "ml", namespace: "ray" } } },
126
+ ]),
127
+ },
128
+ cluster.connector,
129
+ );
84
130
 
85
- // A real NotFound is an absence: neither present nor unobserved.
86
- expect(result.resources).toEqual({});
87
131
  expect(result.unobserved ?? {}).toEqual({});
132
+ expect(result.resources.ml).toMatchObject({ type: "K8s::Ray::RayCluster", physicalId: "uid-ray", status: "ready" });
133
+ expect(cluster.layer.paths()).toContain("/apis/ray.io/v1/namespaces/ray/rayclusters/ml");
88
134
  });
89
135
 
90
- test("entity types without kubectl mapping are reported unobserved, not absent (#1089)", async () => {
91
- const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
92
- const entities = makeEntities([
93
- { name: "exotic", entityType: "K8s::Custom::CRD::SomeOperator", props: { metadata: { name: "x" } } },
94
- ]);
136
+ test.each([
137
+ ["K8s::Argo::Application", "argoproj.io/v1alpha1", "Application", "applications"],
138
+ ["K8s::CertManager::Certificate", "cert-manager.io/v1", "Certificate", "certificates"],
139
+ ["K8s::Rbac::ClusterRole", "rbac.authorization.k8s.io/v1", "ClusterRole", "clusterroles"],
140
+ ["K8s::Batch::CronJob", "batch/v1", "CronJob", "cronjobs"],
141
+ ["K8s::Networking::Ingress", "networking.k8s.io/v1", "Ingress", "ingresses"],
142
+ ])("%s addresses %s %s at /%s", async (entityType, apiVersion, kind, plural) => {
143
+ const clusterScoped = kind === "ClusterRole";
144
+ const object = {
145
+ apiVersion,
146
+ kind,
147
+ metadata: { name: "x", ...(clusterScoped ? {} : { namespace: "prod" }), uid: `uid-${kind}` },
148
+ };
149
+ const cluster = fakeCluster({
150
+ objects: { [objectKey(apiVersion, kind, "x", clusterScoped ? undefined : "prod")]: object },
151
+ });
152
+
153
+ const result = await describeResources(
154
+ {
155
+ environment: "prod",
156
+ buildOutput: "",
157
+ entityNames: ["x"],
158
+ entities: makeEntities([
159
+ { name: "x", entityType, props: { metadata: { name: "x", ...(clusterScoped ? {} : { namespace: "prod" }) } } },
160
+ ]),
161
+ },
162
+ cluster.connector,
163
+ );
95
164
 
96
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["exotic"], entities });
165
+ expect(result.resources.x?.physicalId).toBe(`uid-${kind}`);
166
+ expect(cluster.layer.paths().some((p) => p.endsWith(`/${plural}/x`))).toBe(true);
167
+ });
168
+
169
+ test("an entity type chant has no address for at all is unobserved, never absent", async () => {
170
+ const cluster = fakeCluster();
171
+ const result = await describeResources(
172
+ {
173
+ environment: "prod",
174
+ buildOutput: "",
175
+ entityNames: ["exotic"],
176
+ entities: makeEntities([
177
+ { name: "exotic", entityType: "K8s::NotAGroupChantKnows::Thing", props: { metadata: { name: "x" } } },
178
+ ]),
179
+ },
180
+ cluster.connector,
181
+ );
97
182
 
98
183
  expect(result.resources).toEqual({});
99
184
  expect(result.unobserved?.exotic).toMatchObject({
100
- type: "K8s::Custom::CRD::SomeOperator",
185
+ type: "K8s::NotAGroupChantKnows::Thing",
101
186
  reason: "unsupported-kind",
102
187
  });
103
- expect(execMock).not.toHaveBeenCalled();
104
- const mappingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("kubectl mapping"));
105
- expect(mappingWarning?.[0]).toContain("K8s::Custom::CRD::SomeOperator");
106
- warnSpy.mockRestore();
188
+ // Nothing was asked of the cluster for it.
189
+ expect(cluster.layer.paths().some((p) => p.includes("Thing"))).toBe(false);
107
190
  });
108
191
 
109
- // #1089 — a kubectl failure that is not a NotFound proves nothing about the
110
- // object's existence, and must not reach the change set as an absence.
192
+ // chant #1089 — a failure that is not a NotFound proves nothing about
193
+ // existence, and must not reach the change set as an absence.
111
194
  test.each([
112
- ["error: You must be logged in to the server (Unauthorized)", "no-credentials"],
113
- ["The connection to the server 127.0.0.1:6443 was refused - did you specify the right host or port?", "no-binding"],
114
- ["error: unexpected EOF while parsing", "read-failed"],
115
- ])("kubectl failure %j is reported unobserved (%s)", async (stderr, reason) => {
116
- execMock.mockImplementation(() => { throw Object.assign(new Error("kubectl failed"), { stderr }); });
117
- const entities = makeEntities([
118
- { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
119
- ]);
195
+ [401, "Unauthorized", "no-credentials"],
196
+ [403, "Forbidden", "no-credentials"],
197
+ [500, "InternalError", "read-failed"],
198
+ ])("HTTP %i is reported unobserved (%s → %s)", async (code, reason, expected) => {
199
+ const cluster = fakeCluster({
200
+ respond: (req) =>
201
+ req.path.endsWith("/deployments/web") ? { status: code, body: statusBody(code, reason, "nope") } : undefined,
202
+ });
120
203
 
121
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
204
+ const result = await describeResources(
205
+ {
206
+ environment: "prod",
207
+ buildOutput: "",
208
+ entityNames: ["web"],
209
+ entities: makeEntities([
210
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
211
+ ]),
212
+ },
213
+ cluster.connector,
214
+ );
122
215
 
123
216
  expect(result.resources).toEqual({});
124
- expect(result.unobserved?.web?.reason).toBe(reason);
217
+ expect(result.unobserved?.web?.reason).toBe(expected);
125
218
  });
126
219
 
127
- test("a CRD kind the server does not serve is a real absence (nothing of that kind can exist)", async () => {
128
- execMock.mockImplementation(() => {
129
- throw Object.assign(new Error("kubectl failed"), {
130
- stderr: 'error: the server doesn\'t have a resource type "widgets"',
131
- });
220
+ test("an unreachable API server is no-binding, not an empty cluster", async () => {
221
+ const cluster = fakeCluster({
222
+ respond: () => {
223
+ throw new Error("connect ECONNREFUSED 127.0.0.1:6443");
224
+ },
132
225
  });
133
- const entities = makeEntities([
134
- { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
135
- ]);
136
226
 
137
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
227
+ const result = await describeResources(
228
+ {
229
+ environment: "prod",
230
+ buildOutput: "",
231
+ entityNames: ["web"],
232
+ entities: makeEntities([
233
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
234
+ ]),
235
+ },
236
+ cluster.connector,
237
+ );
238
+
239
+ expect(result.resources).toEqual({});
240
+ expect(result.unobserved?.web?.reason).toBe("no-binding");
241
+ });
242
+
243
+ test("a kind the cluster does not serve is a real absence (nothing of that kind can exist)", async () => {
244
+ // A cluster that serves only core v1 — so apps/v1 discovery 404s.
245
+ const cluster = fakeCluster({ serves: ["K8s::Core::Service"] });
246
+ const result = await describeResources(
247
+ {
248
+ environment: "prod",
249
+ buildOutput: "",
250
+ entityNames: ["web"],
251
+ entities: makeEntities([
252
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
253
+ ]),
254
+ },
255
+ cluster.connector,
256
+ );
138
257
 
139
258
  expect(result.resources).toEqual({});
140
259
  expect(result.unobserved ?? {}).toEqual({});
141
260
  });
142
261
 
143
262
  test("--owned withholds an unmarked live object as `filtered`, never as absent (#1089)", async () => {
144
- execMock.mockResolvedValue({
145
- stdout: JSON.stringify({
146
- metadata: { name: "web", namespace: "prod", uid: "uid-1", creationTimestamp: "t", labels: { app: "web" } },
147
- status: { readyReplicas: 1, replicas: 1 },
148
- }),
149
- stderr: "",
150
- });
151
- const entities = makeEntities([
152
- { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
153
- ]);
154
-
155
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities, owned: true });
263
+ const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: web } });
264
+ const result = await describeResources(
265
+ {
266
+ environment: "prod",
267
+ buildOutput: "",
268
+ entityNames: ["web"],
269
+ entities: makeEntities([
270
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
271
+ ]),
272
+ owned: true,
273
+ },
274
+ cluster.connector,
275
+ );
156
276
 
157
277
  expect(result.resources).toEqual({});
158
278
  expect(result.unobserved?.web?.reason).toBe("filtered");
159
279
  });
160
280
 
161
- test("Deployment with replicas != readyReplicas reports PROGRESSING", async () => {
162
- execMock.mockResolvedValue({
163
- stdout: JSON.stringify({
164
- metadata: { name: "web", namespace: "prod", uid: "uid", creationTimestamp: "t" },
165
- status: { readyReplicas: 1, replicas: 3 },
166
- }),
167
- stderr: "",
281
+ test("status derivation is unchanged: phase, ready/total, or PRESENT", async () => {
282
+ const progressing = { ...web, status: { readyReplicas: 1, replicas: 3 } };
283
+ const pod = {
284
+ apiVersion: "v1",
285
+ kind: "Pod",
286
+ metadata: { name: "p", namespace: "prod", uid: "uid-p" },
287
+ status: { phase: "Running" },
288
+ };
289
+ const cluster = fakeCluster({
290
+ objects: {
291
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: progressing,
292
+ [objectKey("v1", "Pod", "p", "prod")]: pod,
293
+ [objectKey("v1", "Service", "web-svc", "prod")]: webSvc,
294
+ },
168
295
  });
169
296
 
170
- const entities = makeEntities([
171
- { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
172
- ]);
173
-
174
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities });
297
+ const result = await describeResources(
298
+ {
299
+ environment: "prod",
300
+ buildOutput: "",
301
+ entityNames: ["web", "p", "webSvc"],
302
+ entities: makeEntities([
303
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
304
+ { name: "p", entityType: "K8s::Core::Pod", props: { metadata: { name: "p", namespace: "prod" } } },
305
+ { name: "webSvc", entityType: "K8s::Core::Service", props: { metadata: { name: "web-svc", namespace: "prod" } } },
306
+ ]),
307
+ },
308
+ cluster.connector,
309
+ );
175
310
 
176
- expect(result.resources["web"].status).toBe("PROGRESSING(1/3)");
311
+ expect(result.resources.web.status).toBe("PROGRESSING(1/3)");
312
+ expect(result.resources.p.status).toBe("Running");
313
+ expect(result.resources.webSvc.status).toBe("PRESENT");
177
314
  });
178
315
 
179
- test("Pod uses status.phase as the status", async () => {
180
- execMock.mockResolvedValue({
181
- stdout: JSON.stringify({
182
- metadata: { name: "p", namespace: "prod", uid: "uid", creationTimestamp: "t" },
183
- status: { phase: "Running" },
184
- }),
185
- stderr: "",
186
- });
316
+ test("a cluster-scoped resource is addressed without a namespace segment", async () => {
317
+ const ns = { apiVersion: "v1", kind: "Namespace", metadata: { name: "mynamespace", uid: "uid-ns" }, status: { phase: "Active" } };
318
+ const cluster = fakeCluster({ objects: { [objectKey("v1", "Namespace", "mynamespace")]: ns } });
319
+
320
+ const result = await describeResources(
321
+ {
322
+ environment: "prod",
323
+ buildOutput: "",
324
+ entityNames: ["ns"],
325
+ entities: makeEntities([{ name: "ns", entityType: "K8s::Core::Namespace", props: { metadata: { name: "mynamespace" } } }]),
326
+ },
327
+ cluster.connector,
328
+ );
187
329
 
188
- const entities = makeEntities([
189
- { name: "p", entityType: "K8s::Core::Pod", props: { metadata: { name: "p", namespace: "prod" } } },
190
- ]);
330
+ expect(result.resources.ns.status).toBe("Active");
331
+ expect(cluster.layer.paths()).toContain("/api/v1/namespaces/mynamespace");
332
+ });
191
333
 
192
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["p"], entities });
334
+ test("entity without metadata.name is unobserved nothing was queried", async () => {
335
+ const cluster = fakeCluster();
336
+ const result = await describeResources(
337
+ {
338
+ environment: "prod",
339
+ buildOutput: "",
340
+ entityNames: ["broken"],
341
+ entities: makeEntities([{ name: "broken", entityType: "K8s::Apps::Deployment", props: {} }]),
342
+ },
343
+ cluster.connector,
344
+ );
193
345
 
194
- expect(result.resources["p"].status).toBe("Running");
346
+ expect(result.resources).toEqual({});
347
+ expect(result.unobserved?.broken?.reason).toBe("read-failed");
348
+ expect(cluster.layer.paths().some((p) => p.includes("/deployments/"))).toBe(false);
195
349
  });
196
350
 
197
- test("namespace-less resource omits the -n flag", async () => {
198
- let receivedCmd = "";
199
- execMock.mockImplementation((cmd: string) => {
200
- receivedCmd = cmd;
201
- return {
202
- stdout: JSON.stringify({
203
- metadata: { name: "mynamespace", uid: "uid", creationTimestamp: "t" },
204
- status: { phase: "Active" },
205
- }),
206
- stderr: "",
351
+ test("reads run concurrently 40 entities are not 40 serial round trips", async () => {
352
+ const objects: Record<string, (typeof web)> = {};
353
+ const entities: Entity[] = [];
354
+ for (let i = 0; i < 40; i++) {
355
+ objects[objectKey("apps/v1", "Deployment", `web-${i}`, "prod")] = {
356
+ ...web,
357
+ metadata: { ...web.metadata, name: `web-${i}`, uid: `uid-${i}` },
207
358
  };
208
- });
359
+ entities.push({
360
+ name: `web-${i}`,
361
+ entityType: "K8s::Apps::Deployment",
362
+ props: { metadata: { name: `web-${i}`, namespace: "prod" } },
363
+ });
364
+ }
209
365
 
210
- const entities = makeEntities([
211
- { name: "ns", entityType: "K8s::Core::Namespace", props: { metadata: { name: "mynamespace" } } },
212
- ]);
366
+ let inFlight = 0;
367
+ let peak = 0;
368
+ const cluster = fakeCluster({
369
+ objects,
370
+ respond: () => {
371
+ inFlight++;
372
+ peak = Math.max(peak, inFlight);
373
+ queueMicrotask(() => inFlight--);
374
+ return undefined;
375
+ },
376
+ });
213
377
 
214
- await describeResources({ environment: "prod", buildOutput: "", entityNames: ["ns"], entities });
378
+ const result = await describeResources(
379
+ { environment: "prod", buildOutput: "", entityNames: entities.map((e) => e.name), entities: makeEntities(entities) },
380
+ cluster.connector,
381
+ );
215
382
 
216
- expect(receivedCmd).not.toContain("-n");
217
- expect(receivedCmd).toContain("namespace mynamespace");
383
+ expect(Object.keys(result.resources)).toHaveLength(40);
384
+ expect(peak).toBeGreaterThan(1);
385
+ // One discovery request for apps/v1 serves all forty reads.
386
+ expect(cluster.layer.paths().filter((p) => p === "/apis/apps/v1")).toHaveLength(1);
218
387
  });
219
388
 
220
- test("entity without metadata.name is unobserved nothing was queried", async () => {
221
- const entities = makeEntities([
222
- { name: "broken", entityType: "K8s::Apps::Deployment", props: {} },
223
- ]);
224
-
225
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["broken"], entities });
226
-
227
- expect(result.resources).toEqual({});
228
- expect(result.unobserved?.broken?.reason).toBe("read-failed");
229
- expect(execMock).not.toHaveBeenCalled();
230
- });
389
+ // chant #1100/#1155 the binding carries over to the typed client
390
+ // unchanged. These drive the REAL connector, so `resolveClusterTarget` runs
391
+ // for real; only the kubeconfig and the transport are supplied.
392
+ describe("cluster binding (chant #1100, through the typed client)", () => {
393
+ const twoContexts = fakeKubeconfig({
394
+ contexts: [
395
+ { name: "prod-eks", cluster: "prod", user: "prod-user" },
396
+ { name: "staging-eks", cluster: "staging", user: "staging-user" },
397
+ ],
398
+ currentContext: "prod-eks",
399
+ });
231
400
 
232
- // chant #1100 — environment→cluster binding: bound-and-matching,
233
- // bound-and-mismatched (loud refusal), and unbound (unchanged) paths.
234
- describe("cluster binding (chant #1100)", () => {
235
- function deploymentEntities() {
401
+ function entities() {
236
402
  return makeEntities([
237
403
  { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
238
404
  ]);
239
405
  }
240
406
 
241
- const deploymentStdout = JSON.stringify({
242
- metadata: { name: "web", namespace: "prod", uid: "uid-1", creationTimestamp: "2026-05-01T00:00:00Z" },
243
- status: { readyReplicas: 1, replicas: 1 },
244
- });
245
-
246
- test("bound and ambient context matches: observes explicitly via --context", async () => {
407
+ test("bound and ambient context matches: observes explicitly against the bound context", async () => {
247
408
  loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-eks" } } } } });
248
- let receivedCmd = "";
249
- execMock.mockImplementation((cmd: string) => {
250
- if (cmd.includes("current-context")) return { stdout: "prod-eks\n", stderr: "" };
251
- receivedCmd = cmd;
252
- return { stdout: deploymentStdout, stderr: "" };
409
+ const cluster = fakeCluster({
410
+ kubeconfig: twoContexts,
411
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: web },
253
412
  });
254
413
 
255
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities: deploymentEntities() });
414
+ const result = await describeResources(
415
+ { environment: "prod", buildOutput: "", entityNames: ["web"], entities: entities() },
416
+ // The real connector, with only the kubeconfig and transport supplied.
417
+ (o) => defaultK8sConnector({ ...o, client: { kubeconfig: twoContexts, requestLayer: cluster.layer } }),
418
+ );
256
419
 
257
- expect(receivedCmd).toContain("--context prod-eks");
258
- expect(result.resources["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
420
+ expect(result.resources.web).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
259
421
  });
260
422
 
261
- test("bound and ambient context mismatches: refuses loudly instead of observing the wrong cluster", async () => {
423
+ test("bound and ambient context mismatches: refuses loudly instead of reading the wrong cluster", async () => {
262
424
  loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "prod-eks" } } } } });
263
- execMock.mockImplementation((cmd: string) => {
264
- if (cmd.includes("current-context")) return { stdout: "staging-eks\n", stderr: "" };
265
- throw new Error(`unexpected cmd (should have refused before any kubectl get): ${cmd}`);
425
+ const ambientIsStaging = fakeKubeconfig({
426
+ contexts: [
427
+ { name: "prod-eks", cluster: "prod", user: "prod-user" },
428
+ { name: "staging-eks", cluster: "staging", user: "staging-user" },
429
+ ],
430
+ currentContext: "staging-eks",
266
431
  });
432
+ const cluster = fakeCluster({ kubeconfig: ambientIsStaging });
267
433
 
268
434
  await expect(
269
- describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities: deploymentEntities() }),
435
+ describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities: entities() }, (o) =>
436
+ defaultK8sConnector({ ...o, client: { kubeconfig: ambientIsStaging, requestLayer: cluster.layer } }),
437
+ ),
270
438
  ).rejects.toThrow(/environment "prod".*"prod-eks".*"staging-eks"/s);
271
439
 
272
- // Only the ambient-context probe ran no per-entity kubectl get was attempted.
273
- expect(execMock).toHaveBeenCalledTimes(1);
440
+ // Refused before a single request left the process.
441
+ expect(cluster.layer.requests).toHaveLength(0);
274
442
  });
275
443
 
276
- test("unbound: ambient context is used unchanged, but the fallback is visible (not silent)", async () => {
444
+ test("unbound: the kubeconfig's own context is used, and the fallback is visible", async () => {
277
445
  loadChantConfigMock.mockResolvedValue({ config: {} });
278
446
  const warnSpy = vi.spyOn(console, "warn").mockImplementation(() => {});
279
- let receivedCmd = "";
280
- execMock.mockImplementation((cmd: string) => {
281
- receivedCmd = cmd;
282
- return { stdout: deploymentStdout, stderr: "" };
447
+ const cluster = fakeCluster({
448
+ kubeconfig: twoContexts,
449
+ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: web },
283
450
  });
284
451
 
285
- const result = await describeResources({ environment: "prod", buildOutput: "", entityNames: ["web"], entities: deploymentEntities() });
452
+ const result = await describeResources(
453
+ { environment: "prod", buildOutput: "", entityNames: ["web"], entities: entities() },
454
+ (o) => defaultK8sConnector({ ...o, client: { kubeconfig: twoContexts, requestLayer: cluster.layer } }),
455
+ );
286
456
 
287
- // Unchanged: no ambient-context probe, no --context flag, same result shape as today.
288
- expect(receivedCmd).not.toContain("--context");
289
- expect(receivedCmd).not.toContain("current-context");
290
- expect(result.resources["web"]).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1", status: "READY" });
291
-
292
- // Visible, not silent: a warning names the environment and the missing binding.
457
+ expect(result.resources.web).toMatchObject({ physicalId: "uid-1", status: "READY" });
293
458
  const bindingWarning = warnSpy.mock.calls.find((c) => String(c[0]).includes("no cluster binding"));
294
459
  expect(bindingWarning?.[0]).toContain('environment "prod"');
295
460
  expect(bindingWarning?.[0]).toContain("k8s.profiles.prod.context");
296
461
  warnSpy.mockRestore();
297
462
  });
463
+
464
+ test("a bound context the kubeconfig does not have refuses with the context name", async () => {
465
+ loadChantConfigMock.mockResolvedValue({ config: { k8s: { profiles: { prod: { context: "gone-eks" } } } } });
466
+ const oneContext = fakeKubeconfig({ contexts: [{ name: "prod-eks" }], currentContext: "prod-eks" });
467
+ const cluster = fakeCluster({ kubeconfig: oneContext });
468
+
469
+ const result = await describeResources(
470
+ { environment: "prod", buildOutput: "", entityNames: ["web"], entities: entities() },
471
+ (o) => defaultK8sConnector({ ...o, client: { kubeconfig: oneContext, requestLayer: cluster.layer } }),
472
+ ).catch(() => undefined);
473
+
474
+ // resolveClusterTarget refuses on the mismatch first; either way no read happened.
475
+ expect(result?.resources ?? {}).toEqual({});
476
+ expect(cluster.layer.requests).toHaveLength(0);
477
+ });
478
+ });
479
+
480
+ // The optional dependency is missing: chant reports holes with an actionable
481
+ // reason rather than an empty cluster (which would classify as N creates).
482
+ test("a missing client package is a hole with an install hint, never an absence", async () => {
483
+ const result = await describeResources(
484
+ {
485
+ environment: "prod",
486
+ buildOutput: "",
487
+ entityNames: ["web"],
488
+ entities: makeEntities([
489
+ { name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
490
+ ]),
491
+ },
492
+ async () => {
493
+ throw Object.assign(new Error("Cannot find package '@intentius/chant-k8s-client'"), {
494
+ code: "ERR_MODULE_NOT_FOUND",
495
+ });
496
+ },
497
+ );
498
+
499
+ expect(result.resources).toEqual({});
500
+ expect(result.unobserved?.web?.reason).toBe("read-failed");
501
+ expect(result.unobserved?.web?.detail).toContain("npm i @intentius/chant-k8s-client");
502
+ expect(result.unobserved?.web?.type).toBe("K8s::Apps::Deployment");
298
503
  });
299
504
  });