@intentius/chant-lexicon-k8s 0.30.0 → 0.32.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 (137) hide show
  1. package/dist/api/classify.d.ts +65 -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/api/owner-chain.d.ts +46 -0
  10. package/dist/api/owner-chain.d.ts.map +1 -0
  11. package/dist/api/sweep-types.d.ts +17 -0
  12. package/dist/api/sweep-types.d.ts.map +1 -0
  13. package/dist/codegen/generate-operations.d.ts +29 -0
  14. package/dist/codegen/generate-operations.d.ts.map +1 -0
  15. package/dist/codegen/generate.d.ts.map +1 -1
  16. package/dist/config.d.ts +17 -0
  17. package/dist/config.d.ts.map +1 -1
  18. package/dist/crd/parser.d.ts.map +1 -1
  19. package/dist/crd/types.d.ts +7 -0
  20. package/dist/crd/types.d.ts.map +1 -1
  21. package/dist/deep-observe-hooks.d.ts +86 -0
  22. package/dist/deep-observe-hooks.d.ts.map +1 -0
  23. package/dist/deep-observe.d.ts +146 -0
  24. package/dist/deep-observe.d.ts.map +1 -0
  25. package/dist/describe-resources.d.ts +58 -26
  26. package/dist/describe-resources.d.ts.map +1 -1
  27. package/dist/export-resources.d.ts +31 -1
  28. package/dist/export-resources.d.ts.map +1 -1
  29. package/dist/integrity.json +2 -2
  30. package/dist/kube/apply.d.ts +26 -0
  31. package/dist/kube/apply.d.ts.map +1 -0
  32. package/dist/kube/connect.d.ts +30 -0
  33. package/dist/kube/connect.d.ts.map +1 -0
  34. package/dist/kube/delete.d.ts +27 -0
  35. package/dist/kube/delete.d.ts.map +1 -0
  36. package/dist/kube/describe.d.ts +19 -0
  37. package/dist/kube/describe.d.ts.map +1 -0
  38. package/dist/kube/events.d.ts +34 -0
  39. package/dist/kube/events.d.ts.map +1 -0
  40. package/dist/kube/flags.d.ts +40 -0
  41. package/dist/kube/flags.d.ts.map +1 -0
  42. package/dist/kube/get.d.ts +21 -0
  43. package/dist/kube/get.d.ts.map +1 -0
  44. package/dist/kube/group.d.ts +19 -0
  45. package/dist/kube/group.d.ts.map +1 -0
  46. package/dist/kube/logs.d.ts +16 -0
  47. package/dist/kube/logs.d.ts.map +1 -0
  48. package/dist/kube/project.d.ts +59 -0
  49. package/dist/kube/project.d.ts.map +1 -0
  50. package/dist/kube/render.d.ts +75 -0
  51. package/dist/kube/render.d.ts.map +1 -0
  52. package/dist/kube/source.d.ts +19 -0
  53. package/dist/kube/source.d.ts.map +1 -0
  54. package/dist/kube/target.d.ts +52 -0
  55. package/dist/kube/target.d.ts.map +1 -0
  56. package/dist/kube/testing.d.ts +21 -0
  57. package/dist/kube/testing.d.ts.map +1 -0
  58. package/dist/kube/top.d.ts +23 -0
  59. package/dist/kube/top.d.ts.map +1 -0
  60. package/dist/kube/verdict.d.ts +45 -0
  61. package/dist/kube/verdict.d.ts.map +1 -0
  62. package/dist/kube/wait.d.ts +23 -0
  63. package/dist/kube/wait.d.ts.map +1 -0
  64. package/dist/manifest.json +1 -1
  65. package/dist/op/activities/index.d.ts +7 -4
  66. package/dist/op/activities/index.d.ts.map +1 -1
  67. package/dist/op/activities/kubectl.d.ts +120 -2
  68. package/dist/op/activities/kubectl.d.ts.map +1 -1
  69. package/dist/op/activities/wait-for-ready.d.ts +30 -3
  70. package/dist/op/activities/wait-for-ready.d.ts.map +1 -1
  71. package/dist/plugin.d.ts.map +1 -1
  72. package/dist/spec/parse.d.ts +42 -0
  73. package/dist/spec/parse.d.ts.map +1 -1
  74. package/package.json +5 -2
  75. package/src/api/classify.test.ts +133 -0
  76. package/src/api/classify.ts +144 -0
  77. package/src/api/connect.ts +104 -0
  78. package/src/api/fake-cluster.ts +218 -0
  79. package/src/api/operation-surface.test.ts +116 -0
  80. package/src/api/operation-surface.ts +129 -0
  81. package/src/api/owner-chain.test.ts +171 -0
  82. package/src/api/owner-chain.ts +144 -0
  83. package/src/api/sweep-types.ts +36 -0
  84. package/src/codegen/generate-operations.ts +56 -0
  85. package/src/codegen/generate.ts +9 -0
  86. package/src/config.ts +17 -0
  87. package/src/crd/parser.ts +8 -0
  88. package/src/crd/types.ts +7 -0
  89. package/src/deep-observe-hooks.ts +125 -0
  90. package/src/deep-observe.test.ts +668 -0
  91. package/src/deep-observe.ts +299 -0
  92. package/src/describe-resources.test.ts +619 -191
  93. package/src/describe-resources.ts +218 -117
  94. package/src/export-resources-io.test.ts +76 -51
  95. package/src/export-resources.ts +47 -35
  96. package/src/generated/operations.json +2156 -0
  97. package/src/kube/apply.test.ts +105 -0
  98. package/src/kube/apply.ts +101 -0
  99. package/src/kube/connect.ts +54 -0
  100. package/src/kube/delete.test.ts +123 -0
  101. package/src/kube/delete.ts +174 -0
  102. package/src/kube/describe.test.ts +106 -0
  103. package/src/kube/describe.ts +195 -0
  104. package/src/kube/events.test.ts +115 -0
  105. package/src/kube/events.ts +160 -0
  106. package/src/kube/flags.test.ts +65 -0
  107. package/src/kube/flags.ts +114 -0
  108. package/src/kube/get.test.ts +195 -0
  109. package/src/kube/get.ts +190 -0
  110. package/src/kube/group.test.ts +33 -0
  111. package/src/kube/group.ts +125 -0
  112. package/src/kube/logs.test.ts +126 -0
  113. package/src/kube/logs.ts +96 -0
  114. package/src/kube/project.test.ts +57 -0
  115. package/src/kube/project.ts +106 -0
  116. package/src/kube/render.test.ts +142 -0
  117. package/src/kube/render.ts +252 -0
  118. package/src/kube/source.test.ts +138 -0
  119. package/src/kube/source.ts +139 -0
  120. package/src/kube/target.test.ts +67 -0
  121. package/src/kube/target.ts +89 -0
  122. package/src/kube/testing.ts +42 -0
  123. package/src/kube/top.test.ts +149 -0
  124. package/src/kube/top.ts +120 -0
  125. package/src/kube/verdict.test.ts +48 -0
  126. package/src/kube/verdict.ts +77 -0
  127. package/src/kube/wait.test.ts +125 -0
  128. package/src/kube/wait.ts +160 -0
  129. package/src/lifecycle-integration.test.ts +132 -92
  130. package/src/op/activities/index.ts +7 -3
  131. package/src/op/activities/kubectl.test.ts +420 -0
  132. package/src/op/activities/kubectl.ts +379 -13
  133. package/src/op/activities/wait-for-ready.test.ts +94 -0
  134. package/src/op/activities/wait-for-ready.ts +66 -15
  135. package/src/plugin.test.ts +58 -1
  136. package/src/plugin.ts +29 -0
  137. package/src/spec/parse.ts +93 -1
@@ -0,0 +1,668 @@
1
+ /**
2
+ * Kubernetes deep observation (#1076) — the k8s row of the deep-observe
3
+ * contract (#1014), and its managed-fields-specific pruning.
4
+ *
5
+ * Every case here drives the real reader (`observeResourcesDeepK8s`) against
6
+ * `./api/fake-cluster.ts` — a real `@intentius/chant-k8s-client` with only
7
+ * `@kubernetes/client-node`'s HTTP send replaced, the same harness
8
+ * `describe-resources.test.ts` uses for the thin read. No ambient kubeconfig
9
+ * is read and no cluster is contacted.
10
+ *
11
+ * The end-to-end acceptance test drives `observeResourcesDeepK8s`'s real
12
+ * output through core's real `diffDeepObservation`, with `k8sPlugin`'s real,
13
+ * exported `deepNormalizationHooks` — the same three pieces
14
+ * `lexicons/aws/src/deep-observe.test.ts` exercises through
15
+ * `deepDiffForLexicon(awsPlugin, …)`. This suite calls
16
+ * `observeResourcesDeepK8s(options, cluster.connector)` directly rather than
17
+ * routing through `k8sPlugin.observeResourcesDeep`, for the same reason
18
+ * `describe-resources.test.ts` calls `describeResources(options,
19
+ * cluster.connector)` directly: the connector is how *this lexicon's* tests
20
+ * substitute a fake cluster (chant #1074), and `k8sPlugin.observeResourcesDeep`
21
+ * is a one-line dynamic-import forward with no branching logic of its own —
22
+ * covered by `examples/k8s-client-boundary.test.ts` and the wiring check
23
+ * below, not by re-deriving a fake-cluster path through it here.
24
+ */
25
+
26
+ import { describe, test, expect } from "vitest";
27
+
28
+ const { k8sPlugin } = await import("./plugin");
29
+ const {
30
+ observeResourcesDeepK8s,
31
+ buildOwnershipSets,
32
+ } = await import("./deep-observe");
33
+ const { k8sDeepNormalizationHooks } = await import("./deep-observe-hooks");
34
+ const { fakeCluster, objectKey } = await import("./api/fake-cluster");
35
+ const { statusBody } = await import("@intentius/chant-k8s-client/testing");
36
+ const { isChantFieldManager } = await import("@intentius/chant-k8s-client");
37
+ const { diffDeepObservation, observeDeep } = await import("@intentius/chant/lifecycle/deep-observe");
38
+ const { normalizeDeepObservation, normalizeDeepProperties } = await import("@intentius/chant/deep-observation");
39
+
40
+ type Entity = { name: string; entityType: string; props: Record<string, unknown> };
41
+ function makeEntities(records: Entity[]): Map<string, { entityType: string; props: Record<string, unknown> }> {
42
+ return new Map(records.map((r) => [r.name, { entityType: r.entityType, props: r.props }]));
43
+ }
44
+
45
+ describe("k8sPlugin wiring (#1076)", () => {
46
+ test("the plugin exposes the deep-observe contract, and the hooks are the shared static instance", () => {
47
+ expect(typeof k8sPlugin.observeResourcesDeep).toBe("function");
48
+ expect(k8sPlugin.deepNormalizationHooks).toBe(k8sDeepNormalizationHooks);
49
+ });
50
+ });
51
+
52
+ describe("k8sDeepNormalizationHooks — the static rules", () => {
53
+ test("prunes server-populated metadata and status unconditionally", () => {
54
+ const out = normalizeDeepProperties(
55
+ {
56
+ status: { readyReplicas: 3 },
57
+ metadata: {
58
+ name: "web",
59
+ uid: "u-1",
60
+ resourceVersion: "7",
61
+ generation: 3,
62
+ creationTimestamp: "2026-01-01T00:00:00Z",
63
+ managedFields: [{ manager: "chant" }],
64
+ labels: { app: "web" },
65
+ },
66
+ },
67
+ { entityType: "K8s::Apps::Deployment", side: "live", hooks: k8sDeepNormalizationHooks },
68
+ );
69
+ expect(out).toEqual({ metadata: { name: "web", labels: { app: "web" } } });
70
+ });
71
+
72
+ test("subtracts a Kubernetes default only where source is silent about the property", () => {
73
+ const strategyDefault = { type: "RollingUpdate", rollingUpdate: { maxSurge: "25%", maxUnavailable: "25%" } };
74
+
75
+ const declaredSilent = normalizeDeepProperties(
76
+ { spec: { strategy: strategyDefault, replicas: 3 } },
77
+ {
78
+ entityType: "K8s::Apps::Deployment",
79
+ side: "live",
80
+ hooks: k8sDeepNormalizationHooks,
81
+ counterpartPaths: new Set(["spec", "spec.replicas"]),
82
+ },
83
+ );
84
+ // The whole wrapper drops — no dangling `strategy: {}` left behind.
85
+ expect(declaredSilent).toEqual({ spec: { replicas: 3 } });
86
+
87
+ const declaredExplicit = normalizeDeepProperties(
88
+ { spec: { strategy: strategyDefault, replicas: 3 } },
89
+ {
90
+ entityType: "K8s::Apps::Deployment",
91
+ side: "live",
92
+ hooks: k8sDeepNormalizationHooks,
93
+ counterpartPaths: new Set(["spec", "spec.strategy", "spec.replicas"]),
94
+ },
95
+ );
96
+ expect(declaredExplicit).toEqual({ spec: { strategy: strategyDefault, replicas: 3 } });
97
+ });
98
+
99
+ test("a one-sided pass never subtracts defaults — the reader has no declared tree yet", () => {
100
+ const out = normalizeDeepProperties(
101
+ { spec: { strategy: { type: "RollingUpdate", rollingUpdate: { maxSurge: "25%", maxUnavailable: "25%" } } } },
102
+ { entityType: "K8s::Apps::Deployment", side: "live", hooks: k8sDeepNormalizationHooks },
103
+ );
104
+ expect(out).toEqual({ spec: { strategy: { type: "RollingUpdate", rollingUpdate: { maxSurge: "25%", maxUnavailable: "25%" } } } });
105
+ });
106
+
107
+ test("orders containers, env and volumes by name", () => {
108
+ const out = normalizeDeepProperties(
109
+ {
110
+ containers: [{ name: "sidecar" }, { name: "app" }],
111
+ env: [{ name: "Z" }, { name: "A" }],
112
+ volumes: [{ name: "cache" }, { name: "config" }],
113
+ },
114
+ { entityType: "K8s::Apps::Deployment", side: "live", hooks: k8sDeepNormalizationHooks },
115
+ );
116
+ expect((out.containers as Array<{ name: string }>).map((c) => c.name)).toEqual(["app", "sidecar"]);
117
+ expect((out.env as Array<{ name: string }>).map((e) => e.name)).toEqual(["A", "Z"]);
118
+ expect((out.volumes as Array<{ name: string }>).map((v) => v.name)).toEqual(["cache", "config"]);
119
+ });
120
+
121
+ test("orders container ports by containerPort+protocol and service ports by port+protocol", () => {
122
+ const containerPorts = normalizeDeepProperties(
123
+ { ports: [{ containerPort: 9090, protocol: "TCP" }, { containerPort: 8080, protocol: "TCP" }] },
124
+ { entityType: "K8s::Apps::Deployment", side: "live", hooks: k8sDeepNormalizationHooks },
125
+ );
126
+ expect((containerPorts.ports as Array<{ containerPort: number }>).map((p) => p.containerPort)).toEqual([8080, 9090]);
127
+
128
+ const servicePorts = normalizeDeepProperties(
129
+ { ports: [{ port: 443, protocol: "TCP" }, { port: 80, protocol: "TCP" }] },
130
+ { entityType: "K8s::Core::Service", side: "live", hooks: k8sDeepNormalizationHooks },
131
+ );
132
+ expect((servicePorts.ports as Array<{ port: number }>).map((p) => p.port)).toEqual([80, 443]);
133
+ });
134
+ });
135
+
136
+ describe("buildOwnershipSets — resolving managedFields against live and declared trees (#1076)", () => {
137
+ test("a scalar owned by chant is chant-owned regardless of the declared tree", () => {
138
+ const live = { spec: { replicas: 3 } };
139
+ const declared = {};
140
+ const sets = buildOwnershipSets(
141
+ [{ manager: "chant:web", operation: "Apply", fieldsV1: { "f:spec": { "f:replicas": {} } } }],
142
+ live,
143
+ declared,
144
+ isChantFieldManager,
145
+ );
146
+ expect(sets.chantOwned.has("spec.replicas")).toBe(true);
147
+ expect(sets.foreignOwned.has("spec.replicas")).toBe(false);
148
+ });
149
+
150
+ test("a scalar owned by a foreign manager and undeclared is foreign-owned, not contested", () => {
151
+ const live = { spec: { replicas: 7 } };
152
+ const declared = { spec: {} };
153
+ const sets = buildOwnershipSets(
154
+ [{ manager: "kube-controller-manager", operation: "Update", fieldsV1: { "f:spec": { "f:replicas": {} } } }],
155
+ live,
156
+ declared,
157
+ isChantFieldManager,
158
+ );
159
+ expect(sets.foreignOwned.has("spec.replicas")).toBe(true);
160
+ expect(sets.foreignContested.has("spec.replicas")).toBe(false);
161
+ });
162
+
163
+ test("a scalar owned by a foreign manager AND declared is contested", () => {
164
+ const live = { spec: { replicas: 9 } };
165
+ const declared = { spec: { replicas: 5 } };
166
+ const sets = buildOwnershipSets(
167
+ [{ manager: "kubectl-client-side-apply", operation: "Update", fieldsV1: { "f:spec": { "f:replicas": {} } } }],
168
+ live,
169
+ declared,
170
+ isChantFieldManager,
171
+ );
172
+ expect(sets.foreignOwned.has("spec.replicas")).toBe(true);
173
+ expect(sets.foreignContested.has("spec.replicas")).toBe(true);
174
+ });
175
+
176
+ test("a keyed list item resolves to its live index, independent of position in the declared array", () => {
177
+ const live = { spec: { containers: [{ name: "istio-proxy" }, { name: "app" }] } };
178
+ const declared = { spec: { containers: [{ name: "app" }] } };
179
+ const sets = buildOwnershipSets(
180
+ [
181
+ {
182
+ manager: "istio-sidecar-injector",
183
+ operation: "Update",
184
+ fieldsV1: { "f:spec": { "f:containers": { 'k:{"name":"istio-proxy"}': { ".": {}, "f:name": {} } } } },
185
+ },
186
+ ],
187
+ live,
188
+ declared,
189
+ isChantFieldManager,
190
+ );
191
+ // Live index 0, not declared index 0 — "istio-proxy" does not exist in
192
+ // declared source at all, so it is undeclared, never contested.
193
+ expect(sets.foreignOwned.has("spec.containers[0]")).toBe(true);
194
+ expect(sets.foreignContested.has("spec.containers[0]")).toBe(false);
195
+ });
196
+
197
+ test("a keyed list item that IS declared, at a different live index, is contested at its live index", () => {
198
+ const live = { spec: { containers: [{ name: "sidecar" }, { name: "app", image: "app:2.0" }] } };
199
+ const declared = { spec: { containers: [{ name: "app", image: "app:1.0" }] } };
200
+ const sets = buildOwnershipSets(
201
+ [
202
+ {
203
+ manager: "kubectl-edit",
204
+ operation: "Update",
205
+ fieldsV1: { "f:spec": { "f:containers": { 'k:{"name":"app"}': { "f:image": {} } } } },
206
+ },
207
+ ],
208
+ live,
209
+ declared,
210
+ isChantFieldManager,
211
+ );
212
+ // "app" is live index 1 here (a sidecar was injected in front of it), and
213
+ // it is declared — contested at the index it actually resolved to.
214
+ expect(sets.foreignOwned.has("spec.containers[1].image")).toBe(true);
215
+ expect(sets.foreignContested.has("spec.containers[1].image")).toBe(true);
216
+ });
217
+
218
+ test("a subresource entry (status) is excluded — a controller writing status is not contesting the spec", () => {
219
+ const live = { status: { readyReplicas: 3 } };
220
+ const sets = buildOwnershipSets(
221
+ [{ manager: "kube-controller-manager", operation: "Update", subresource: "status", fieldsV1: { "f:status": { "f:readyReplicas": {} } } }],
222
+ live,
223
+ {},
224
+ isChantFieldManager,
225
+ );
226
+ expect(sets.foreignOwned.size).toBe(0);
227
+ });
228
+
229
+ test("an entry with no manager name is skipped", () => {
230
+ const sets = buildOwnershipSets(
231
+ [{ operation: "Update", fieldsV1: { "f:spec": {} } }],
232
+ { spec: {} },
233
+ {},
234
+ isChantFieldManager,
235
+ );
236
+ expect(sets.chantOwned.size).toBe(0);
237
+ expect(sets.foreignOwned.size).toBe(0);
238
+ });
239
+ });
240
+
241
+ describe("observeResourcesDeepK8s — reading through the typed client (#1076)", () => {
242
+ test("a hand-edited field chant owns surfaces with the field path; a type with no operation surface is unsupported-kind", async () => {
243
+ const web = {
244
+ apiVersion: "apps/v1",
245
+ kind: "Deployment",
246
+ metadata: {
247
+ name: "web",
248
+ namespace: "prod",
249
+ uid: "uid-web",
250
+ resourceVersion: "9",
251
+ labels: { app: "web", tier: "frontend" },
252
+ managedFields: [
253
+ {
254
+ manager: "chant:web",
255
+ operation: "Apply",
256
+ fieldsV1: { "f:metadata": { "f:labels": { "f:app": {}, "f:tier": {} } } },
257
+ },
258
+ ],
259
+ },
260
+ status: {},
261
+ };
262
+ const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: web } });
263
+
264
+ const result = normalizeDeepObservation(
265
+ await observeResourcesDeepK8s(
266
+ {
267
+ environment: "prod",
268
+ entityNames: ["web", "unknownKind"],
269
+ entities: makeEntities([
270
+ {
271
+ name: "web",
272
+ entityType: "K8s::Apps::Deployment",
273
+ props: { metadata: { name: "web", namespace: "prod", labels: { app: "web", tier: "backend" } } },
274
+ },
275
+ { name: "unknownKind", entityType: "K8s::Totally::Unknown", props: { metadata: { name: "x" } } },
276
+ ]),
277
+ },
278
+ cluster.connector,
279
+ ),
280
+ );
281
+
282
+ expect(result.resources.web.properties).toMatchObject({ metadata: { labels: { app: "web", tier: "frontend" } } });
283
+ expect(result.resources.web.properties).not.toHaveProperty("status");
284
+ expect(result.unobserved.unknownKind.reason).toBe("unsupported-kind");
285
+ });
286
+
287
+ test("a read failure is a hole with a reason, never silence", async () => {
288
+ const cluster = fakeCluster({
289
+ respond: (req) => (req.path.endsWith("/deployments/broken") ? { status: 403, body: statusBody(403, "Forbidden", "nope") } : undefined),
290
+ });
291
+ const result = normalizeDeepObservation(
292
+ await observeResourcesDeepK8s(
293
+ {
294
+ environment: "prod",
295
+ entityNames: ["broken"],
296
+ entities: makeEntities([
297
+ { name: "broken", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "broken", namespace: "prod" } } },
298
+ ]),
299
+ },
300
+ cluster.connector,
301
+ ),
302
+ );
303
+ expect(result.resources).toEqual({});
304
+ expect(result.unobserved.broken.reason).toBe("no-credentials");
305
+ });
306
+
307
+ test("--owned withholds an unmarked object as filtered, not absent", async () => {
308
+ const theirs = {
309
+ apiVersion: "v1",
310
+ kind: "Service",
311
+ metadata: { name: "theirs", namespace: "prod", uid: "uid-theirs" },
312
+ };
313
+ const cluster = fakeCluster({ objects: { [objectKey("v1", "Service", "theirs", "prod")]: theirs } });
314
+ const result = normalizeDeepObservation(
315
+ await observeResourcesDeepK8s(
316
+ {
317
+ environment: "prod",
318
+ entityNames: ["theirs"],
319
+ owned: true,
320
+ entities: makeEntities([
321
+ { name: "theirs", entityType: "K8s::Core::Service", props: { metadata: { name: "theirs", namespace: "prod" } } },
322
+ ]),
323
+ },
324
+ cluster.connector,
325
+ ),
326
+ );
327
+ expect(result.resources).toEqual({});
328
+ expect(result.unobserved.theirs.reason).toBe("filtered");
329
+ });
330
+ });
331
+
332
+ /**
333
+ * The acceptance test for #1076: declared source, a live tree with
334
+ * controller-managed noise (HPA-owned replicas, an Istio-injected sidecar),
335
+ * a hand-edited field chant owns, a contested field chant declares that a
336
+ * foreign manager currently holds, and an accepted baseline — driven through
337
+ * the real reader and core's real `diffDeepObservation`, with `k8sPlugin`'s
338
+ * real static hooks.
339
+ */
340
+ describe("end to end: managed-fields-derived drift (#1076)", () => {
341
+ const declared = makeEntities([
342
+ // "web": HPA owns replicas (undeclared → pruned), Istio injects a sidecar
343
+ // (undeclared → pruned), and someone changed a label chant owns.
344
+ {
345
+ name: "web",
346
+ entityType: "K8s::Apps::Deployment",
347
+ props: {
348
+ metadata: {
349
+ name: "web",
350
+ namespace: "prod",
351
+ labels: { app: "web", tier: "backend" },
352
+ annotations: { "build-id": "42" },
353
+ },
354
+ spec: {
355
+ selector: { matchLabels: { app: "web" } },
356
+ template: {
357
+ metadata: { labels: { app: "web" } },
358
+ spec: { containers: [{ name: "app", image: "web:1.0" }] },
359
+ },
360
+ },
361
+ },
362
+ },
363
+ // "worker": chant declares replicas: 5, but a foreign manager currently
364
+ // holds spec.replicas (transferred by a direct kubectl scale) at 9 — a
365
+ // contested field, and drift-relevant precisely because chant declared it.
366
+ {
367
+ name: "worker",
368
+ entityType: "K8s::Apps::Deployment",
369
+ props: {
370
+ metadata: { name: "worker", namespace: "prod", labels: { app: "worker" } },
371
+ spec: {
372
+ replicas: 5,
373
+ selector: { matchLabels: { app: "worker" } },
374
+ template: { metadata: { labels: { app: "worker" } }, spec: { containers: [{ name: "app", image: "worker:1.0" }] } },
375
+ },
376
+ },
377
+ },
378
+ // No operation surface at all for this made-up type.
379
+ { name: "cache", entityType: "K8s::Totally::Unknown", props: { metadata: { name: "cache" } } },
380
+ // The read itself fails.
381
+ { name: "broken", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "broken", namespace: "prod" } } },
382
+ ]);
383
+
384
+ const webLive = {
385
+ apiVersion: "apps/v1",
386
+ kind: "Deployment",
387
+ metadata: {
388
+ name: "web",
389
+ namespace: "prod",
390
+ uid: "uid-web",
391
+ resourceVersion: "42",
392
+ generation: 7,
393
+ creationTimestamp: "2026-01-01T00:00:00Z",
394
+ // GENUINE DRIFT: chant owns this label, and the live value moved.
395
+ labels: { app: "web", tier: "frontend" },
396
+ // ACCEPTED: chant owns this annotation; the platform's baseline accepts "43".
397
+ annotations: { "build-id": "43" },
398
+ managedFields: [
399
+ {
400
+ manager: "chant:web",
401
+ operation: "Apply",
402
+ apiVersion: "apps/v1",
403
+ time: "2026-01-01T00:00:00Z",
404
+ fieldsV1: {
405
+ "f:metadata": {
406
+ "f:labels": { "f:app": {}, "f:tier": {} },
407
+ "f:annotations": { "f:build-id": {} },
408
+ },
409
+ "f:spec": {
410
+ "f:selector": {},
411
+ "f:template": {
412
+ "f:spec": { "f:containers": { 'k:{"name":"app"}': { ".": {}, "f:name": {}, "f:image": {} } } },
413
+ },
414
+ },
415
+ },
416
+ },
417
+ {
418
+ // NOISE: HPA owns replicas, and chant never declared it.
419
+ manager: "kube-controller-manager",
420
+ operation: "Update",
421
+ apiVersion: "apps/v1",
422
+ time: "2026-01-02T00:00:00Z",
423
+ fieldsV1: { "f:spec": { "f:replicas": {} } },
424
+ },
425
+ {
426
+ // NOISE: a mutating webhook injected a whole sidecar container.
427
+ manager: "istio-sidecar-injector",
428
+ operation: "Update",
429
+ apiVersion: "apps/v1",
430
+ time: "2026-01-01T00:05:00Z",
431
+ fieldsV1: {
432
+ "f:spec": {
433
+ "f:template": {
434
+ "f:spec": { "f:containers": { 'k:{"name":"istio-proxy"}': { ".": {}, "f:name": {}, "f:image": {} } } },
435
+ },
436
+ },
437
+ },
438
+ },
439
+ {
440
+ // NOISE: status is a subresource write, excluded by default.
441
+ manager: "kube-controller-manager",
442
+ operation: "Update",
443
+ subresource: "status",
444
+ fieldsV1: { "f:status": { "f:readyReplicas": {} } },
445
+ },
446
+ ],
447
+ },
448
+ spec: {
449
+ replicas: 7,
450
+ selector: { matchLabels: { app: "web" } },
451
+ template: {
452
+ metadata: { labels: { app: "web" } },
453
+ spec: {
454
+ containers: [
455
+ { name: "app", image: "web:1.0" },
456
+ { name: "istio-proxy", image: "istio/proxyv2:1.20" },
457
+ ],
458
+ },
459
+ },
460
+ },
461
+ status: { readyReplicas: 7, replicas: 7 },
462
+ };
463
+
464
+ const workerLive = {
465
+ apiVersion: "apps/v1",
466
+ kind: "Deployment",
467
+ metadata: {
468
+ name: "worker",
469
+ namespace: "prod",
470
+ uid: "uid-worker",
471
+ resourceVersion: "9",
472
+ labels: { app: "worker" },
473
+ managedFields: [
474
+ {
475
+ manager: "chant:web",
476
+ operation: "Apply",
477
+ apiVersion: "apps/v1",
478
+ fieldsV1: {
479
+ "f:metadata": { "f:labels": { "f:app": {} } },
480
+ "f:spec": {
481
+ "f:selector": {},
482
+ "f:template": {
483
+ "f:spec": { "f:containers": { 'k:{"name":"app"}': { ".": {}, "f:name": {}, "f:image": {} } } },
484
+ },
485
+ },
486
+ },
487
+ },
488
+ {
489
+ // Ownership of replicas transferred here via a direct scale —
490
+ // chant's own apply entry above no longer lists it.
491
+ manager: "kubectl-client-side-apply",
492
+ operation: "Update",
493
+ apiVersion: "apps/v1",
494
+ fieldsV1: { "f:spec": { "f:replicas": {} } },
495
+ },
496
+ ],
497
+ },
498
+ spec: {
499
+ replicas: 9,
500
+ selector: { matchLabels: { app: "worker" } },
501
+ template: { metadata: { labels: { app: "worker" } }, spec: { containers: [{ name: "app", image: "worker:1.0" }] } },
502
+ },
503
+ };
504
+
505
+ const cluster = () =>
506
+ fakeCluster({
507
+ objects: {
508
+ [objectKey("apps/v1", "Deployment", "web", "prod")]: webLive,
509
+ [objectKey("apps/v1", "Deployment", "worker", "prod")]: workerLive,
510
+ },
511
+ respond: (req) => (req.path.endsWith("/deployments/broken") ? { status: 500, body: statusBody(500, "InternalError", "backend unavailable") } : undefined),
512
+ });
513
+
514
+ const baseline = {
515
+ web: {
516
+ type: "K8s::Apps::Deployment",
517
+ accepted: [{ path: "metadata.annotations.build-id", value: "43" }],
518
+ },
519
+ };
520
+
521
+ test("exactly the genuine + contested drift surfaces; controller noise and the accepted annotation do not", async () => {
522
+ const live = normalizeDeepObservation(
523
+ await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
524
+ );
525
+ const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks, baseline);
526
+
527
+ expect(result.drifted).toEqual([
528
+ {
529
+ name: "web",
530
+ type: "K8s::Apps::Deployment",
531
+ changes: [{ path: "metadata.labels.tier", kind: "changed", declared: "backend", live: "frontend" }],
532
+ },
533
+ {
534
+ name: "worker",
535
+ type: "K8s::Apps::Deployment",
536
+ changes: [{ path: "spec.replicas", kind: "changed", declared: 5, live: 9 }],
537
+ },
538
+ ]);
539
+
540
+ expect(result.accepted).toEqual([
541
+ {
542
+ name: "web",
543
+ type: "K8s::Apps::Deployment",
544
+ changes: [{ path: "metadata.annotations.build-id", kind: "changed", declared: "42", live: "43", baseline: "43" }],
545
+ },
546
+ ]);
547
+
548
+ // The HPA-owned replicas and the Istio sidecar never appear at all — not
549
+ // as drift, not as "undeclared" noise. Pruned before the diff ever runs.
550
+ const webDriftPaths = result.drifted.find((d) => d.name === "web")?.changes.map((c) => c.path) ?? [];
551
+ expect(webDriftPaths).not.toContain("spec.replicas");
552
+ expect(JSON.stringify(result)).not.toContain("istio-proxy");
553
+
554
+ expect(result.unobserved).toEqual([
555
+ { name: "broken", type: "K8s::Apps::Deployment", reason: "read-failed", detail: expect.stringContaining("500") },
556
+ {
557
+ name: "cache",
558
+ type: "K8s::Totally::Unknown",
559
+ reason: "unsupported-kind",
560
+ detail: expect.stringContaining("no generated operation surface"),
561
+ },
562
+ ]);
563
+ });
564
+
565
+ test("without the baseline the annotation is drift too; accepting it is what silences it", async () => {
566
+ const live = normalizeDeepObservation(
567
+ await observeResourcesDeepK8s({ environment: "prod", entityNames: [...declared.keys()], entities: declared }, cluster().connector),
568
+ );
569
+ const result = diffDeepObservation(declared, live, k8sDeepNormalizationHooks);
570
+ const web = result.drifted.find((d) => d.name === "web");
571
+ expect(web?.changes.map((c) => c.path).sort()).toEqual(["metadata.annotations.build-id", "metadata.labels.tier"]);
572
+ expect(result.accepted).toEqual([]);
573
+ });
574
+
575
+ test("a whole-lexicon failure (no client) is a hole for every declared entity, never a clean report", async () => {
576
+ // The reader's contract (matching describe-resources.ts) is to *throw* for
577
+ // an unrecognized connect failure, not to catch it — core's own
578
+ // `observeDeep` orchestration is what turns the throw into NOT-OBSERVED
579
+ // for every entity, so this drives that real composition rather than
580
+ // asserting the reader swallows something it deliberately does not.
581
+ const failingConnect = async () => {
582
+ throw new Error("boom");
583
+ };
584
+ const failingPlugin = {
585
+ observeResourcesDeep: (opts: Parameters<typeof observeResourcesDeepK8s>[0]) =>
586
+ observeResourcesDeepK8s(opts, failingConnect as unknown as Parameters<typeof observeResourcesDeepK8s>[1]),
587
+ };
588
+ const live = await observeDeep(failingPlugin as Parameters<typeof observeDeep>[0], {
589
+ environment: "prod",
590
+ buildOutput: "",
591
+ entities: declared,
592
+ });
593
+ expect(live.resources).toEqual({});
594
+ expect(new Set(Object.values(live.unobserved).map((u) => u.reason))).toEqual(new Set(["read-failed"]));
595
+ expect(Object.keys(live.unobserved).sort()).toEqual(["broken", "cache", "web", "worker"]);
596
+ });
597
+ });
598
+
599
+ /**
600
+ * The managed-fields-specific case named in #1076's acceptance: the same
601
+ * live mutation on the same field is drift when chant owns it, and silence
602
+ * when a controller owns it and source is silent.
603
+ */
604
+ describe("the managed-fields twist: ownership decides drift vs silence for the same mutation (#1076)", () => {
605
+ const liveWith = (owner: "chant" | "controller") => ({
606
+ apiVersion: "apps/v1",
607
+ kind: "Deployment",
608
+ metadata: {
609
+ name: "app",
610
+ namespace: "prod",
611
+ uid: "uid-app",
612
+ managedFields:
613
+ owner === "chant"
614
+ ? [{ manager: "chant:web", operation: "Apply", fieldsV1: { "f:spec": { "f:template": { "f:spec": { "f:containers": { 'k:{"name":"app"}': { "f:image": {} } } } } } } }]
615
+ : [{ manager: "vertical-pod-autoscaler", operation: "Update", fieldsV1: { "f:spec": { "f:template": { "f:spec": { "f:containers": { 'k:{"name":"app"}': { "f:image": {} } } } } } } }],
616
+ },
617
+ spec: { template: { spec: { containers: [{ name: "app", image: "app:2.0" }] } } },
618
+ });
619
+
620
+ const declaredWith = (declareImage: boolean) =>
621
+ makeEntities([
622
+ {
623
+ name: "app",
624
+ entityType: "K8s::Apps::Deployment",
625
+ props: {
626
+ metadata: { name: "app", namespace: "prod" },
627
+ spec: {
628
+ template: {
629
+ spec: {
630
+ containers: declareImage ? [{ name: "app", image: "app:1.0" }] : [{ name: "app" }],
631
+ },
632
+ },
633
+ },
634
+ },
635
+ },
636
+ ]);
637
+
638
+ test("chant owns the field: the same mutated value is drift", async () => {
639
+ const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "app", "prod")]: liveWith("chant") } });
640
+ const entities = declaredWith(true);
641
+ const live = normalizeDeepObservation(
642
+ await observeResourcesDeepK8s({ environment: "prod", entityNames: ["app"], entities }, cluster.connector),
643
+ );
644
+ const result = diffDeepObservation(entities, live, k8sDeepNormalizationHooks);
645
+ // Addressed by key, not position (the k8s twist on core's set-addressing,
646
+ // #1014) — containers are a set canonicalized by `name`, per this
647
+ // lexicon's own `orderKey` hook.
648
+ expect(result.drifted).toEqual([
649
+ {
650
+ name: "app",
651
+ type: "K8s::Apps::Deployment",
652
+ changes: [{ path: "spec.template.spec.containers[#app].image", kind: "changed", declared: "app:1.0", live: "app:2.0" }],
653
+ },
654
+ ]);
655
+ });
656
+
657
+ test("a controller owns the field and source is silent: the same mutated value is silence, not drift and not undeclared noise", async () => {
658
+ const cluster = fakeCluster({ objects: { [objectKey("apps/v1", "Deployment", "app", "prod")]: liveWith("controller") } });
659
+ const entities = declaredWith(false);
660
+ const live = normalizeDeepObservation(
661
+ await observeResourcesDeepK8s({ environment: "prod", entityNames: ["app"], entities }, cluster.connector),
662
+ );
663
+ const result = diffDeepObservation(entities, live, k8sDeepNormalizationHooks);
664
+ expect(result.drifted).toEqual([]);
665
+ expect(result.unchanged).toEqual(["app"]);
666
+ expect(JSON.stringify(result)).not.toContain("app:2.0");
667
+ });
668
+ });