@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.
- package/dist/api/classify.d.ts +65 -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/api/owner-chain.d.ts +46 -0
- package/dist/api/owner-chain.d.ts.map +1 -0
- package/dist/api/sweep-types.d.ts +17 -0
- package/dist/api/sweep-types.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/deep-observe-hooks.d.ts +86 -0
- package/dist/deep-observe-hooks.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +146 -0
- package/dist/deep-observe.d.ts.map +1 -0
- package/dist/describe-resources.d.ts +58 -26
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +31 -1
- package/dist/export-resources.d.ts.map +1 -1
- package/dist/integrity.json +2 -2
- package/dist/kube/apply.d.ts +26 -0
- package/dist/kube/apply.d.ts.map +1 -0
- package/dist/kube/connect.d.ts +30 -0
- package/dist/kube/connect.d.ts.map +1 -0
- package/dist/kube/delete.d.ts +27 -0
- package/dist/kube/delete.d.ts.map +1 -0
- package/dist/kube/describe.d.ts +19 -0
- package/dist/kube/describe.d.ts.map +1 -0
- package/dist/kube/events.d.ts +34 -0
- package/dist/kube/events.d.ts.map +1 -0
- package/dist/kube/flags.d.ts +40 -0
- package/dist/kube/flags.d.ts.map +1 -0
- package/dist/kube/get.d.ts +21 -0
- package/dist/kube/get.d.ts.map +1 -0
- package/dist/kube/group.d.ts +19 -0
- package/dist/kube/group.d.ts.map +1 -0
- package/dist/kube/logs.d.ts +16 -0
- package/dist/kube/logs.d.ts.map +1 -0
- package/dist/kube/project.d.ts +59 -0
- package/dist/kube/project.d.ts.map +1 -0
- package/dist/kube/render.d.ts +75 -0
- package/dist/kube/render.d.ts.map +1 -0
- package/dist/kube/source.d.ts +19 -0
- package/dist/kube/source.d.ts.map +1 -0
- package/dist/kube/target.d.ts +52 -0
- package/dist/kube/target.d.ts.map +1 -0
- package/dist/kube/testing.d.ts +21 -0
- package/dist/kube/testing.d.ts.map +1 -0
- package/dist/kube/top.d.ts +23 -0
- package/dist/kube/top.d.ts.map +1 -0
- package/dist/kube/verdict.d.ts +45 -0
- package/dist/kube/verdict.d.ts.map +1 -0
- package/dist/kube/wait.d.ts +23 -0
- package/dist/kube/wait.d.ts.map +1 -0
- package/dist/manifest.json +1 -1
- package/dist/op/activities/index.d.ts +7 -4
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +120 -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/plugin.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 +144 -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/api/owner-chain.test.ts +171 -0
- package/src/api/owner-chain.ts +144 -0
- package/src/api/sweep-types.ts +36 -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/deep-observe-hooks.ts +125 -0
- package/src/deep-observe.test.ts +668 -0
- package/src/deep-observe.ts +299 -0
- package/src/describe-resources.test.ts +619 -191
- package/src/describe-resources.ts +218 -117
- package/src/export-resources-io.test.ts +76 -51
- package/src/export-resources.ts +47 -35
- package/src/generated/operations.json +2156 -0
- package/src/kube/apply.test.ts +105 -0
- package/src/kube/apply.ts +101 -0
- package/src/kube/connect.ts +54 -0
- package/src/kube/delete.test.ts +123 -0
- package/src/kube/delete.ts +174 -0
- package/src/kube/describe.test.ts +106 -0
- package/src/kube/describe.ts +195 -0
- package/src/kube/events.test.ts +115 -0
- package/src/kube/events.ts +160 -0
- package/src/kube/flags.test.ts +65 -0
- package/src/kube/flags.ts +114 -0
- package/src/kube/get.test.ts +195 -0
- package/src/kube/get.ts +190 -0
- package/src/kube/group.test.ts +33 -0
- package/src/kube/group.ts +125 -0
- package/src/kube/logs.test.ts +126 -0
- package/src/kube/logs.ts +96 -0
- package/src/kube/project.test.ts +57 -0
- package/src/kube/project.ts +106 -0
- package/src/kube/render.test.ts +142 -0
- package/src/kube/render.ts +252 -0
- package/src/kube/source.test.ts +138 -0
- package/src/kube/source.ts +139 -0
- package/src/kube/target.test.ts +67 -0
- package/src/kube/target.ts +89 -0
- package/src/kube/testing.ts +42 -0
- package/src/kube/top.test.ts +149 -0
- package/src/kube/top.ts +120 -0
- package/src/kube/verdict.test.ts +48 -0
- package/src/kube/verdict.ts +77 -0
- package/src/kube/wait.test.ts +125 -0
- package/src/kube/wait.ts +160 -0
- package/src/lifecycle-integration.test.ts +132 -92
- package/src/op/activities/index.ts +7 -3
- package/src/op/activities/kubectl.test.ts +420 -0
- package/src/op/activities/kubectl.ts +379 -13
- package/src/op/activities/wait-for-ready.test.ts +94 -0
- package/src/op/activities/wait-for-ready.ts +66 -15
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
- 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
|
+
});
|