@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.
- package/dist/api/classify.d.ts +55 -0
- package/dist/api/classify.d.ts.map +1 -0
- package/dist/api/connect.d.ts +58 -0
- package/dist/api/connect.d.ts.map +1 -0
- package/dist/api/fake-cluster.d.ts +55 -0
- package/dist/api/fake-cluster.d.ts.map +1 -0
- package/dist/api/operation-surface.d.ts +64 -0
- package/dist/api/operation-surface.d.ts.map +1 -0
- package/dist/codegen/generate-operations.d.ts +29 -0
- package/dist/codegen/generate-operations.d.ts.map +1 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- package/dist/config.d.ts +17 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/crd/parser.d.ts.map +1 -1
- package/dist/crd/types.d.ts +7 -0
- package/dist/crd/types.d.ts.map +1 -1
- package/dist/describe-resources.d.ts +41 -24
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +27 -1
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +2 -2
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +38 -2
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/op/activities/wait-for-ready.d.ts +30 -3
- package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
- package/dist/spec/parse.d.ts +42 -0
- package/dist/spec/parse.d.ts.map +1 -1
- package/package.json +5 -2
- package/src/api/classify.test.ts +133 -0
- package/src/api/classify.ts +131 -0
- package/src/api/connect.ts +104 -0
- package/src/api/fake-cluster.ts +218 -0
- package/src/api/operation-surface.test.ts +116 -0
- package/src/api/operation-surface.ts +129 -0
- package/src/codegen/generate-operations.ts +56 -0
- package/src/codegen/generate.ts +9 -0
- package/src/config.ts +17 -0
- package/src/crd/parser.ts +8 -0
- package/src/crd/types.ts +7 -0
- package/src/describe-resources.test.ts +396 -191
- package/src/describe-resources.ts +134 -118
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +63 -35
- package/src/generated/operations.json +2156 -0
- package/src/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +2 -1
- package/src/op/activities/kubectl.test.ts +148 -0
- package/src/op/activities/kubectl.ts +86 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/spec/parse.ts +93 -1
|
@@ -1,15 +1,14 @@
|
|
|
1
|
-
|
|
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
|
-
|
|
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:
|
|
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("
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
|
57
|
-
{
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
|
76
|
+
expect(result.resources.web).toMatchObject({
|
|
64
77
|
type: "K8s::Apps::Deployment",
|
|
65
78
|
physicalId: "uid-1",
|
|
66
79
|
status: "READY",
|
|
67
|
-
|
|
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("
|
|
77
|
-
|
|
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
|
-
|
|
80
|
-
|
|
81
|
-
|
|
104
|
+
expect(result.resources).toEqual({});
|
|
105
|
+
expect(result.unobserved ?? {}).toEqual({});
|
|
106
|
+
});
|
|
82
107
|
|
|
83
|
-
|
|
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(
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
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
|
-
|
|
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::
|
|
185
|
+
type: "K8s::NotAGroupChantKnows::Thing",
|
|
101
186
|
reason: "unsupported-kind",
|
|
102
187
|
});
|
|
103
|
-
|
|
104
|
-
|
|
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
|
|
110
|
-
//
|
|
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
|
-
["
|
|
113
|
-
[
|
|
114
|
-
[
|
|
115
|
-
])("
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
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(
|
|
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(
|
|
217
|
+
expect(result.unobserved?.web?.reason).toBe(expected);
|
|
125
218
|
});
|
|
126
219
|
|
|
127
|
-
test("
|
|
128
|
-
|
|
129
|
-
|
|
130
|
-
|
|
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(
|
|
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
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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("
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
}
|
|
167
|
-
|
|
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
|
|
171
|
-
{
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
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
|
|
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("
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
-
|
|
189
|
-
|
|
190
|
-
|
|
330
|
+
expect(result.resources.ns.status).toBe("Active");
|
|
331
|
+
expect(cluster.layer.paths()).toContain("/api/v1/namespaces/mynamespace");
|
|
332
|
+
});
|
|
191
333
|
|
|
192
|
-
|
|
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
|
|
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("
|
|
198
|
-
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
|
|
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
|
-
|
|
211
|
-
|
|
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(
|
|
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(
|
|
217
|
-
expect(
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
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(
|
|
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(
|
|
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
|
|
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
|
-
|
|
264
|
-
|
|
265
|
-
|
|
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:
|
|
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
|
-
//
|
|
273
|
-
expect(
|
|
440
|
+
// Refused before a single request left the process.
|
|
441
|
+
expect(cluster.layer.requests).toHaveLength(0);
|
|
274
442
|
});
|
|
275
443
|
|
|
276
|
-
test("unbound:
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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(
|
|
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
|
-
|
|
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
|
});
|