@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,299 @@
1
+ /**
2
+ * Kubernetes deep observation (#1076) — the k8s row of the deep-observe
3
+ * contract (#1014), and the epic's (#1073) thesis made real: chant compiled
4
+ * the manifest, so it knows which fields it authored; the API server records,
5
+ * per field, which manager last wrote it (`metadata.managedFields`, chant
6
+ * #1075/#1178). Intersecting those two makes Kubernetes drift *derived*
7
+ * rather than the hand-maintained `ignoreDifferences` list every Argo user
8
+ * keeps.
9
+ *
10
+ * ## Why this is not "the AWS reader, with `kubectl get` instead of Cloud
11
+ * Control"
12
+ *
13
+ * AWS, Azure and Temporal's rows all prune by a **static, entityType-keyed**
14
+ * table: an ARN always looks like an ARN, `provisioningState` is always
15
+ * server-populated, a namespace's retention default is always the same
16
+ * value. None of that needs the specific live object in hand — it is exactly
17
+ * what `./deep-observe-hooks.ts`'s `k8sDeepNormalizationHooks` is, and it
18
+ * covers Kubernetes' *equivalent* static noise (`status`,
19
+ * `metadata.{uid,resourceVersion,generation,creationTimestamp}`, a handful of
20
+ * server-defaulted pod-spec fields).
21
+ *
22
+ * managedFields is not that kind of rule. Whether `spec.replicas` is noise on
23
+ * *this* Deployment depends on whether *this* Deployment's
24
+ * `metadata.managedFields` says a controller owns it — a fact that differs
25
+ * between two Deployments of the same type, and that the declared tree never
26
+ * carries at all (chant's source has no `managedFields` key to normalize).
27
+ * `DeepNormalizationHooks.prune` cannot see the object it is walking, only a
28
+ * path and a value, so this cannot be expressed as a fixed hook the way the
29
+ * other three rows' entire contribution is. It has to be computed once per
30
+ * resource, here, and layered on top of the static rules before the tree is
31
+ * normalized.
32
+ *
33
+ * ## The contested-field rule, precisely
34
+ *
35
+ * For every path on the live tree, three questions, each independent:
36
+ *
37
+ * 1. **Does any chant field manager (`chant`, `chant:<stack>` — chant #1075,
38
+ * matched on the family so a stack rename does not stop recognizing its
39
+ * own history) own this path?** If so it is chant's business regardless of
40
+ * what else is true — always diffable, never pruned by this rule.
41
+ * 2. **Does some *other* manager own this path, and chant does not?** That is
42
+ * controller-managed noise — HPA rewriting `spec.replicas`, a mutating
43
+ * webhook defaulting a field, `kubectl-client-side-apply` from a human
44
+ * operator — *provided* nobody declared it (next question). Prune it.
45
+ * 3. **Does chant's own manifest *also* set this path?** — independent of
46
+ * who currently owns it live. This is the case the issue calls out by
47
+ * name: **a contested field chant declared is drift-relevant.** The
48
+ * reasoning: chant's source is a statement of intent regardless of
49
+ * whether a previous apply is what currently holds the field, and a
50
+ * foreign write that overrides a value chant's manifest asks for is
51
+ * exactly the thing `lifecycle diff --live` exists to surface — silencing
52
+ * it because *something else* currently owns the field would hide the one
53
+ * case where chant and the cluster disagree about a property chant is
54
+ * actively declaring. So: foreign-owned *and* declared is never pruned by
55
+ * this rule, whatever #2 would otherwise do.
56
+ *
57
+ * `counterpart: "unknown"` and why it does not help here: core's own
58
+ * counterpart tri-state (`side === "live" && counterpart === "absent"`, what
59
+ * {@link K8S_SERVICE_DEFAULTS} is gated on) answers "did the *declared tree*
60
+ * carry this path at all" — which is exactly question 3, but it is computed
61
+ * by *core*, from the raw declared and raw live trees, only on the **second**
62
+ * normalization pass (`packages/core/src/lifecycle/deep-observe.ts`'s
63
+ * `diffDeepObservation`). This reader's own call to
64
+ * {@link normalizeDeepProperties} runs first, with no `counterpartPaths`
65
+ * supplied — same as the other three rows — so `counterpart` is `"unknown"`
66
+ * for every node here, and a rule gated on `"absent"` would never fire during
67
+ * this read at all. That is fine for a table keyed only by entityType (AWS's
68
+ * service defaults are subtracted on core's later pass, not this one), but it
69
+ * is *not* fine for the managed-fields rule: if this reader left
70
+ * `spec.replicas` in the tree waiting for core's second pass to prune it,
71
+ * core would be normalizing with the *static* `k8sDeepNormalizationHooks`,
72
+ * which has no managedFields for this object in hand either (this reader
73
+ * already computed its ownership sets from data it read once and does not
74
+ * return). So question 3 is answered **here**, directly against the declared
75
+ * `props` this reader was handed — not via `counterpart` — and the result is
76
+ * baked into what this reader returns: a foreign-owned, undeclared field is
77
+ * gone from the tree before it ever reaches core, and a contested one is
78
+ * still there for the ordinary declared-vs-live comparison to catch.
79
+ *
80
+ * ## Resolving a managedFields entry against a live array
81
+ *
82
+ * `metadata.managedFields[].fieldsV1` encodes a list item three ways —
83
+ * `k:{"name":"web"}` (by key), `v:"blue"` (by value), `i:3` (by index) — and
84
+ * chant #1178's `managed-fields.ts` renders all three to a path *string*
85
+ * (`.spec.containers[name="web"]`) for display. Parsing that string back
86
+ * would be ambiguous in general (a label key may itself contain `.`, e.g.
87
+ * `app.kubernetes.io/name`, indistinguishable in the rendered form from
88
+ * nested fields), so this reader does not parse strings at all: it walks the
89
+ * *structured* `fieldsV1` tree directly (`walkOwnership`, below), matching
90
+ * each `k:`/`v:` entry against the actual live array to find the concrete
91
+ * index, and against the actual declared array (by the same key or value) to
92
+ * answer question 3 for that element specifically — a declared container
93
+ * named `app` at index 0 is still "declared" if a sidecar gets injected in
94
+ * front of it and shifts it to live index 1.
95
+ *
96
+ * ## What is deliberately not modeled
97
+ *
98
+ * A manager's `.` entry (SSA's "I own this element's presence") on a
99
+ * container that also sets no scalar sub-fields (unusual — a real apply
100
+ * setting a container almost always sets `image`/`name` too) would not confer
101
+ * ownership onto a sibling field nobody's `fieldsV1` entry lists explicitly.
102
+ * That is the conservative direction: a path absent from every manager's
103
+ * fields is treated as "no ownership information", which leaves it diffable
104
+ * rather than silently pruned. Reported drift that turns out to be more
105
+ * noise than expected is a tuning problem; drift silently dropped is not.
106
+ *
107
+ * ## `buildOwnershipSets` lives in core now
108
+ *
109
+ * The ownership walk itself (`walkOwnership`, resolving a `fieldsV1` tree
110
+ * against the live and declared trees) has nothing k8s-*lexicon*-specific
111
+ * about it — it is generic Kubernetes SSA machinery. Chant #1087 (GCP's row,
112
+ * reusing this one, since a Config Connector CR is a Kubernetes object too)
113
+ * moved it to `@intentius/chant/managed-fields` rather than have gcp depend
114
+ * on this lexicon's package, the same reason chant #1100's
115
+ * `resolveClusterTarget` lives in core. This module re-exports
116
+ * `buildOwnershipSets`/`OwnershipSets` from there so nothing here changes for
117
+ * an existing importer of this file.
118
+ */
119
+
120
+ import type { K8sObject } from "@intentius/chant-k8s-client";
121
+ import type {
122
+ DeepNormalizationHooks,
123
+ DeepObservationResult,
124
+ DeepResourceObservation,
125
+ UnobservedEntity,
126
+ } from "@intentius/chant/lexicon";
127
+ import { deepObservation, normalizeDeepProperties } from "@intentius/chant/deep-observation";
128
+ import { unobservedAll } from "@intentius/chant/observation";
129
+ import { hasOwnershipMarker, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
130
+ import { buildOwnershipSets, pruneByOwnership, type OwnershipSets } from "@intentius/chant/managed-fields";
131
+ import { defaultK8sConnector, type K8sConnector } from "./api/connect";
132
+ import {
133
+ classifyApiFailure,
134
+ isMissingClientPackage,
135
+ isWholeLexiconFailure,
136
+ MISSING_CLIENT_DETAIL,
137
+ } from "./api/classify";
138
+ import { operationFor } from "./api/operation-surface";
139
+ import { k8sDeepNormalizationHooks } from "./deep-observe-hooks";
140
+
141
+ // Re-exported so a dynamic importer of this module (plugin.ts's
142
+ // `observeResourcesDeep`, a test) can get the reader and its hooks from one
143
+ // place, the same shape AWS/Azure/Temporal's single deep-observe.ts offers.
144
+ // `plugin.ts`'s own `deepNormalizationHooks` field imports the hooks
145
+ // separately, directly from `./deep-observe-hooks` — that file has no
146
+ // dependency on `@intentius/chant-k8s-client`, so it is safe to import
147
+ // statically; this module is not (see the module doc).
148
+ export { k8sDeepNormalizationHooks, buildOwnershipSets, type OwnershipSets };
149
+
150
+ export interface K8sDeepObserveOptions {
151
+ environment: string;
152
+ buildOutput?: string;
153
+ entityNames: string[];
154
+ entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
155
+ stack?: string;
156
+ owned?: boolean;
157
+ /** Directory whose `chant.config.ts` carries the cluster binding. Defaults to cwd. */
158
+ cwd?: string;
159
+ }
160
+
161
+ /**
162
+ * The managed-fields prune, composed with the static rules, for one
163
+ * resource's normalization call. See the module doc for the three-question
164
+ * rule this encodes (`@intentius/chant/managed-fields`'s `pruneByOwnership`).
165
+ */
166
+ function perResourceHooks(sets: OwnershipSets): DeepNormalizationHooks {
167
+ return {
168
+ prune(node) {
169
+ if (k8sDeepNormalizationHooks.prune?.(node)) return true;
170
+ return pruneByOwnership(node, sets);
171
+ },
172
+ orderKey: k8sDeepNormalizationHooks.orderKey,
173
+ };
174
+ }
175
+
176
+ /** The live object minus the envelope fields that live outside `properties` on {@link DeepResourceObservation} (mirrors `type`/`physicalId`). */
177
+ function propertiesTreeOf(obj: K8sObject): Record<string, unknown> {
178
+ const { apiVersion: _apiVersion, kind: _kind, ...rest } = obj;
179
+ return rest;
180
+ }
181
+
182
+ /**
183
+ * Read the live property tree for each declared entity, pruning by
184
+ * `metadata.managedFields` (see the module doc). Reuses the same connector,
185
+ * discovery/caching client and tri-state failure classification as the thin
186
+ * read (`./describe-resources.ts`) — the binding check (chant #1100/#1155)
187
+ * still refuses before any resource is touched, and a connect failure still
188
+ * becomes NOT-OBSERVED for every declared entity rather than an empty result.
189
+ */
190
+ export async function observeResourcesDeepK8s(
191
+ options: K8sDeepObserveOptions,
192
+ connect: K8sConnector = defaultK8sConnector,
193
+ ): Promise<DeepObservationResult> {
194
+ const { managedFieldsOf, isChantFieldManager } = await import("@intentius/chant-k8s-client");
195
+
196
+ const declared = [...options.entities].map(([entityName, entity]) => ({
197
+ entityName,
198
+ entityType: entity.entityType,
199
+ props: entity.props,
200
+ }));
201
+
202
+ let client;
203
+ try {
204
+ ({ client } = await connect({ environment: options.environment, cwd: options.cwd }));
205
+ } catch (err) {
206
+ if (isMissingClientPackage(err)) {
207
+ return deepObservation(
208
+ {},
209
+ unobservedAll(
210
+ declared.map((d) => d.entityName),
211
+ "read-failed",
212
+ MISSING_CLIENT_DETAIL,
213
+ options.entities,
214
+ ),
215
+ );
216
+ }
217
+ if (isWholeLexiconFailure(err)) {
218
+ const outcome = classifyApiFailure(err);
219
+ return deepObservation(
220
+ {},
221
+ unobservedAll(
222
+ declared.map((d) => d.entityName),
223
+ outcome.kind === "unobserved" ? outcome.reason : "read-failed",
224
+ outcome.kind === "unobserved" ? outcome.detail : undefined,
225
+ options.entities,
226
+ ),
227
+ );
228
+ }
229
+ // A cluster-binding mismatch (chant #1100) — refuse loudly, same as the
230
+ // thin path; core turns the throw into NOT-OBSERVED for every entity.
231
+ throw err;
232
+ }
233
+
234
+ const resources: Record<string, DeepResourceObservation> = {};
235
+ const unobserved: Record<string, UnobservedEntity> = {};
236
+
237
+ await client.concurrently(declared, async ({ entityName, entityType, props }) => {
238
+ const operation = operationFor(entityType);
239
+ if (!operation) {
240
+ unobserved[entityName] = {
241
+ type: entityType,
242
+ reason: "unsupported-kind",
243
+ detail: `no generated operation surface for ${entityType} — run \`chant generate\` in the k8s lexicon, or declare the CRD as a codegen source`,
244
+ };
245
+ return;
246
+ }
247
+
248
+ const metadata = props.metadata as { name?: string; namespace?: string } | undefined;
249
+ const name = metadata?.name;
250
+ if (!name) {
251
+ unobserved[entityName] = {
252
+ type: entityType,
253
+ reason: "read-failed",
254
+ detail: "declared entity has no metadata.name to query by",
255
+ };
256
+ return;
257
+ }
258
+
259
+ try {
260
+ const obj = await client.read({
261
+ apiVersion: operation.apiVersion,
262
+ kind: operation.kind,
263
+ name,
264
+ ...(metadata?.namespace ? { namespace: metadata.namespace } : {}),
265
+ });
266
+
267
+ if (options.owned && !hasOwnershipMarker(obj.metadata?.labels, LABEL_OWNERSHIP_KEYS)) {
268
+ unobserved[entityName] = {
269
+ type: entityType,
270
+ reason: "filtered",
271
+ detail: "live object carries no chant ownership marker and --owned was requested",
272
+ };
273
+ return;
274
+ }
275
+
276
+ const liveRoot = propertiesTreeOf(obj);
277
+ const sets = buildOwnershipSets(managedFieldsOf(obj), liveRoot, props, isChantFieldManager);
278
+
279
+ resources[entityName] = {
280
+ type: entityType,
281
+ physicalId: obj.metadata?.uid,
282
+ properties: normalizeDeepProperties(liveRoot, {
283
+ entityType,
284
+ side: "live",
285
+ hooks: perResourceHooks(sets),
286
+ }),
287
+ };
288
+ } catch (err) {
289
+ const outcome = classifyApiFailure(err);
290
+ if (outcome.kind === "unobserved") {
291
+ unobserved[entityName] = { type: entityType, reason: outcome.reason, detail: outcome.detail };
292
+ }
293
+ // `absent` records nothing — the thin read already reports the
294
+ // non-existence; restating it here would turn one finding into two.
295
+ }
296
+ });
297
+
298
+ return deepObservation(resources, unobserved);
299
+ }