@intentius/chant-lexicon-k8s 0.31.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 +10 -0
- package/dist/api/classify.d.ts.map +1 -1
- 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/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 +15 -0
- package/dist/describe-resources.d.ts.map +1 -1
- package/dist/export-resources.d.ts +6 -2
- 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 +6 -3
- package/dist/op/activities/index.d.ts.map +1 -1
- package/dist/op/activities/kubectl.d.ts +91 -9
- package/dist/op/activities/kubectl.d.ts.map +1 -1
- package/dist/plugin.d.ts.map +1 -1
- package/package.json +3 -3
- package/src/api/classify.ts +13 -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/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 +224 -1
- package/src/describe-resources.ts +86 -1
- package/src/export-resources.ts +6 -22
- 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/op/activities/index.ts +6 -3
- package/src/op/activities/kubectl.test.ts +298 -26
- package/src/op/activities/kubectl.ts +313 -20
- package/src/plugin.test.ts +58 -1
- package/src/plugin.ts +29 -0
|
@@ -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
|
+
}
|
|
@@ -16,7 +16,7 @@ vi.mock("@intentius/chant/config", () => ({
|
|
|
16
16
|
}));
|
|
17
17
|
|
|
18
18
|
const { describeResources } = await import("./describe-resources");
|
|
19
|
-
const { fakeCluster, objectKey } = await import("./api/fake-cluster");
|
|
19
|
+
const { fakeCluster, objectKey, ownedObject } = await import("./api/fake-cluster");
|
|
20
20
|
const { defaultK8sConnector } = await import("./api/connect");
|
|
21
21
|
const { fakeKubeconfig, statusBody } = await import("@intentius/chant-k8s-client/testing");
|
|
22
22
|
|
|
@@ -501,4 +501,227 @@ describe("k8s describeResources", () => {
|
|
|
501
501
|
expect(result.unobserved?.web?.detail).toContain("npm i @intentius/chant-k8s-client");
|
|
502
502
|
expect(result.unobserved?.web?.type).toBe("K8s::Apps::Deployment");
|
|
503
503
|
});
|
|
504
|
+
|
|
505
|
+
// ── Runtime children: owner-reference chain classification (chant #1077) ──
|
|
506
|
+
|
|
507
|
+
describe("runtime children (#1077)", () => {
|
|
508
|
+
function declaredDeployment() {
|
|
509
|
+
return makeEntities([
|
|
510
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
|
|
511
|
+
]);
|
|
512
|
+
}
|
|
513
|
+
|
|
514
|
+
test("a Pod owned (via an undeclared ReplicaSet) by a declared Deployment reports ownerChain: declared", async () => {
|
|
515
|
+
const replicaSet = {
|
|
516
|
+
apiVersion: "apps/v1",
|
|
517
|
+
kind: "ReplicaSet",
|
|
518
|
+
metadata: {
|
|
519
|
+
name: "web-7d9f8c9c8",
|
|
520
|
+
namespace: "prod",
|
|
521
|
+
uid: "rs-uid",
|
|
522
|
+
ownerReferences: [{ apiVersion: "apps/v1", kind: "Deployment", name: "web", uid: "uid-1", controller: true }],
|
|
523
|
+
},
|
|
524
|
+
};
|
|
525
|
+
const runtimePod = {
|
|
526
|
+
apiVersion: "v1",
|
|
527
|
+
kind: "Pod",
|
|
528
|
+
metadata: {
|
|
529
|
+
name: "web-7d9f8c9c8-abcde",
|
|
530
|
+
namespace: "prod",
|
|
531
|
+
uid: "pod-uid",
|
|
532
|
+
ownerReferences: [{ apiVersion: "apps/v1", kind: "ReplicaSet", name: "web-7d9f8c9c8", uid: "rs-uid", controller: true }],
|
|
533
|
+
},
|
|
534
|
+
status: { phase: "Running" },
|
|
535
|
+
};
|
|
536
|
+
const cluster = fakeCluster({
|
|
537
|
+
objects: {
|
|
538
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: web,
|
|
539
|
+
[objectKey("apps/v1", "ReplicaSet", "web-7d9f8c9c8", "prod")]: replicaSet,
|
|
540
|
+
[objectKey("v1", "Pod", "web-7d9f8c9c8-abcde", "prod")]: runtimePod,
|
|
541
|
+
},
|
|
542
|
+
});
|
|
543
|
+
|
|
544
|
+
const result = await describeResources(
|
|
545
|
+
{ environment: "prod", buildOutput: "", entityNames: ["web"], entities: declaredDeployment() },
|
|
546
|
+
cluster.connector,
|
|
547
|
+
);
|
|
548
|
+
|
|
549
|
+
const child = result.resources["prod/web-7d9f8c9c8-abcde"];
|
|
550
|
+
expect(child).toMatchObject({
|
|
551
|
+
type: "K8s::Core::Pod",
|
|
552
|
+
physicalId: "pod-uid",
|
|
553
|
+
status: "Running",
|
|
554
|
+
ownerChain: { root: "declared", entity: "web" },
|
|
555
|
+
});
|
|
556
|
+
// Reads the ReplicaSet to walk past it — one extra request beyond the
|
|
557
|
+
// Deployment read and the Pod list.
|
|
558
|
+
expect(cluster.layer.paths()).toContain("/apis/apps/v1/namespaces/prod/replicasets/web-7d9f8c9c8");
|
|
559
|
+
});
|
|
560
|
+
|
|
561
|
+
test("a genuinely unowned Pod in the same namespace reports ownerChain: unowned (still an orphan downstream)", async () => {
|
|
562
|
+
const standalonePod = {
|
|
563
|
+
apiVersion: "v1",
|
|
564
|
+
kind: "Pod",
|
|
565
|
+
metadata: { name: "standalone", namespace: "prod", uid: "standalone-uid" },
|
|
566
|
+
status: { phase: "Running" },
|
|
567
|
+
};
|
|
568
|
+
const cluster = fakeCluster({
|
|
569
|
+
objects: {
|
|
570
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: web,
|
|
571
|
+
[objectKey("v1", "Pod", "standalone", "prod")]: standalonePod,
|
|
572
|
+
},
|
|
573
|
+
});
|
|
574
|
+
|
|
575
|
+
const result = await describeResources(
|
|
576
|
+
{ environment: "prod", buildOutput: "", entityNames: ["web"], entities: declaredDeployment() },
|
|
577
|
+
cluster.connector,
|
|
578
|
+
);
|
|
579
|
+
|
|
580
|
+
expect(result.resources["prod/standalone"]).toMatchObject({
|
|
581
|
+
type: "K8s::Core::Pod",
|
|
582
|
+
ownerChain: { root: "unowned" },
|
|
583
|
+
});
|
|
584
|
+
});
|
|
585
|
+
|
|
586
|
+
test("a Pod owned by something never declared reports ownerChain: foreign", async () => {
|
|
587
|
+
const otherRs = {
|
|
588
|
+
apiVersion: "apps/v1",
|
|
589
|
+
kind: "ReplicaSet",
|
|
590
|
+
metadata: { name: "other-rs", namespace: "prod", uid: "other-rs-uid" }, // no further owner
|
|
591
|
+
};
|
|
592
|
+
const otherPod = {
|
|
593
|
+
apiVersion: "v1",
|
|
594
|
+
kind: "Pod",
|
|
595
|
+
metadata: {
|
|
596
|
+
name: "other-pod",
|
|
597
|
+
namespace: "prod",
|
|
598
|
+
uid: "other-pod-uid",
|
|
599
|
+
ownerReferences: [{ apiVersion: "apps/v1", kind: "ReplicaSet", name: "other-rs", uid: "other-rs-uid", controller: true }],
|
|
600
|
+
},
|
|
601
|
+
};
|
|
602
|
+
const cluster = fakeCluster({
|
|
603
|
+
objects: {
|
|
604
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: web,
|
|
605
|
+
[objectKey("apps/v1", "ReplicaSet", "other-rs", "prod")]: otherRs,
|
|
606
|
+
[objectKey("v1", "Pod", "other-pod", "prod")]: otherPod,
|
|
607
|
+
},
|
|
608
|
+
});
|
|
609
|
+
|
|
610
|
+
const result = await describeResources(
|
|
611
|
+
{ environment: "prod", buildOutput: "", entityNames: ["web"], entities: declaredDeployment() },
|
|
612
|
+
cluster.connector,
|
|
613
|
+
);
|
|
614
|
+
|
|
615
|
+
expect(result.resources["prod/other-pod"]).toMatchObject({ ownerChain: { root: "foreign" } });
|
|
616
|
+
});
|
|
617
|
+
|
|
618
|
+
test("--owned withholds an unrelated Pod, but keeps a runtime child of a declared (owned) entity", async () => {
|
|
619
|
+
// The declared Deployment must itself carry chant's marker for `--owned`
|
|
620
|
+
// to surface it at all — otherwise it is filtered before a Pod scan ever
|
|
621
|
+
// has a declared uid to compare against.
|
|
622
|
+
const ownedWeb = ownedObject("apps/v1", "Deployment", "web", "prod", { status: { readyReplicas: 3, replicas: 3 } });
|
|
623
|
+
const standalonePod = {
|
|
624
|
+
apiVersion: "v1",
|
|
625
|
+
kind: "Pod",
|
|
626
|
+
metadata: { name: "standalone", namespace: "prod", uid: "standalone-uid" },
|
|
627
|
+
};
|
|
628
|
+
const runtimePod = {
|
|
629
|
+
apiVersion: "v1",
|
|
630
|
+
kind: "Pod",
|
|
631
|
+
metadata: {
|
|
632
|
+
name: "web-abc",
|
|
633
|
+
namespace: "prod",
|
|
634
|
+
uid: "pod-uid",
|
|
635
|
+
ownerReferences: [{ apiVersion: "apps/v1", kind: "Deployment", name: "web", uid: "uid-web", controller: true }],
|
|
636
|
+
},
|
|
637
|
+
};
|
|
638
|
+
const cluster = fakeCluster({
|
|
639
|
+
objects: {
|
|
640
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: ownedWeb,
|
|
641
|
+
[objectKey("v1", "Pod", "standalone", "prod")]: standalonePod,
|
|
642
|
+
[objectKey("v1", "Pod", "web-abc", "prod")]: runtimePod,
|
|
643
|
+
},
|
|
644
|
+
});
|
|
645
|
+
|
|
646
|
+
const result = await describeResources(
|
|
647
|
+
{
|
|
648
|
+
environment: "prod",
|
|
649
|
+
buildOutput: "",
|
|
650
|
+
entityNames: ["web"],
|
|
651
|
+
entities: declaredDeployment(),
|
|
652
|
+
owned: true,
|
|
653
|
+
},
|
|
654
|
+
cluster.connector,
|
|
655
|
+
);
|
|
656
|
+
|
|
657
|
+
expect(result.resources.web).toBeDefined(); // the marker let it through
|
|
658
|
+
expect(result.resources["prod/standalone"]).toBeUndefined();
|
|
659
|
+
expect(result.resources["prod/web-abc"]).toMatchObject({ ownerChain: { root: "declared", entity: "web" } });
|
|
660
|
+
});
|
|
661
|
+
|
|
662
|
+
test("a Pod that is itself declared is never duplicated as a runtime child", async () => {
|
|
663
|
+
const declaredPod = {
|
|
664
|
+
apiVersion: "v1",
|
|
665
|
+
kind: "Pod",
|
|
666
|
+
metadata: { name: "standalone-pod", namespace: "prod", uid: "uid-declared-pod" },
|
|
667
|
+
status: { phase: "Running" },
|
|
668
|
+
};
|
|
669
|
+
const cluster = fakeCluster({
|
|
670
|
+
objects: {
|
|
671
|
+
[objectKey("apps/v1", "Deployment", "web", "prod")]: web,
|
|
672
|
+
[objectKey("v1", "Pod", "standalone-pod", "prod")]: declaredPod,
|
|
673
|
+
},
|
|
674
|
+
});
|
|
675
|
+
|
|
676
|
+
const result = await describeResources(
|
|
677
|
+
{
|
|
678
|
+
environment: "prod",
|
|
679
|
+
buildOutput: "",
|
|
680
|
+
entityNames: ["web", "standalonePod"],
|
|
681
|
+
entities: makeEntities([
|
|
682
|
+
{ name: "web", entityType: "K8s::Apps::Deployment", props: { metadata: { name: "web", namespace: "prod" } } },
|
|
683
|
+
{ name: "standalonePod", entityType: "K8s::Core::Pod", props: { metadata: { name: "standalone-pod", namespace: "prod" } } },
|
|
684
|
+
]),
|
|
685
|
+
},
|
|
686
|
+
cluster.connector,
|
|
687
|
+
);
|
|
688
|
+
|
|
689
|
+
expect(result.resources.standalonePod).toBeDefined();
|
|
690
|
+
expect(result.resources["prod/standalone-pod"]).toBeUndefined();
|
|
691
|
+
expect(Object.keys(result.resources)).toHaveLength(2); // web + standalonePod, no synthetic duplicate
|
|
692
|
+
});
|
|
693
|
+
|
|
694
|
+
test("a Pod-list failure for a namespace is best-effort — declared entities still resolve", async () => {
|
|
695
|
+
const cluster = fakeCluster({
|
|
696
|
+
objects: { [objectKey("apps/v1", "Deployment", "web", "prod")]: web },
|
|
697
|
+
respond: (req) => (req.path.endsWith("/pods") ? { status: 403, body: statusBody(403, "Forbidden", "no pods list") } : undefined),
|
|
698
|
+
});
|
|
699
|
+
|
|
700
|
+
const result = await describeResources(
|
|
701
|
+
{ environment: "prod", buildOutput: "", entityNames: ["web"], entities: declaredDeployment() },
|
|
702
|
+
cluster.connector,
|
|
703
|
+
);
|
|
704
|
+
|
|
705
|
+
expect(result.resources.web).toMatchObject({ type: "K8s::Apps::Deployment", physicalId: "uid-1" });
|
|
706
|
+
expect(Object.keys(result.resources)).toEqual(["web"]);
|
|
707
|
+
});
|
|
708
|
+
|
|
709
|
+
test("a lexicon-supported entity type with no namespaced entities resolved does no Pod scan at all", async () => {
|
|
710
|
+
const ns = { apiVersion: "v1", kind: "Namespace", metadata: { name: "mynamespace", uid: "uid-ns" }, status: { phase: "Active" } };
|
|
711
|
+
const cluster = fakeCluster({ objects: { [objectKey("v1", "Namespace", "mynamespace")]: ns } });
|
|
712
|
+
|
|
713
|
+
const result = await describeResources(
|
|
714
|
+
{
|
|
715
|
+
environment: "prod",
|
|
716
|
+
buildOutput: "",
|
|
717
|
+
entityNames: ["ns"],
|
|
718
|
+
entities: makeEntities([{ name: "ns", entityType: "K8s::Core::Namespace", props: { metadata: { name: "mynamespace" } } }]),
|
|
719
|
+
},
|
|
720
|
+
cluster.connector,
|
|
721
|
+
);
|
|
722
|
+
|
|
723
|
+
expect(Object.keys(result.resources)).toEqual(["ns"]);
|
|
724
|
+
expect(cluster.layer.paths().some((p) => p.endsWith("/pods"))).toBe(false);
|
|
725
|
+
});
|
|
726
|
+
});
|
|
504
727
|
});
|
|
@@ -35,12 +35,27 @@
|
|
|
35
35
|
* NOT-OBSERVED with a reason. A declared `k8s.profiles.<env>.context` that
|
|
36
36
|
* disagrees with the ambient one still refuses before a single resource is
|
|
37
37
|
* touched, via the same `resolveClusterTarget` the GCP lexicon shares.
|
|
38
|
+
*
|
|
39
|
+
* ## Runtime children (chant #1077)
|
|
40
|
+
*
|
|
41
|
+
* A Pod a declared Deployment's controller created is live and undeclared —
|
|
42
|
+
* exactly the shape `lifecycle diff --live` has always classified `orphan`.
|
|
43
|
+
* On Kubernetes that is wrong: it is expected runtime, not drift, and will be
|
|
44
|
+
* recreated the moment it is deleted. After the declared-entity reads above
|
|
45
|
+
* resolve, this scans Pods in the namespaces they actually live in and walks
|
|
46
|
+
* each one's `ownerReferences` chain (`./api/owner-chain.ts`) against the
|
|
47
|
+
* entities this very call resolved. A chain reaching one of them is reported
|
|
48
|
+
* with `ownerChain: { root: "declared", ... }`; core's diff/change-set engine
|
|
49
|
+
* reads that as `runtime` instead of `orphan`. Deliberately scoped to Pods —
|
|
50
|
+
* the concrete case the issue and its acceptance criteria name — and to the
|
|
51
|
+
* namespaces this observation already touched, not a cluster-wide sweep;
|
|
52
|
+
* widening to other controller-spawned kinds is the same walk repeated.
|
|
38
53
|
*/
|
|
39
54
|
|
|
40
55
|
import type { ObservationResult, ResourceMetadata, UnobservedEntity } from "@intentius/chant/lexicon";
|
|
41
56
|
import { observation, unobservedAll } from "@intentius/chant/observation";
|
|
42
57
|
import { hasOwnershipMarker, classifyOwnership, LABEL_OWNERSHIP_KEYS } from "@intentius/chant/ownership";
|
|
43
|
-
import type { K8sObject } from "@intentius/chant-k8s-client";
|
|
58
|
+
import type { K8sClient, K8sObject } from "@intentius/chant-k8s-client";
|
|
44
59
|
import { defaultK8sConnector, type K8sConnector } from "./api/connect";
|
|
45
60
|
import {
|
|
46
61
|
classifyApiFailure,
|
|
@@ -49,6 +64,7 @@ import {
|
|
|
49
64
|
MISSING_CLIENT_DETAIL,
|
|
50
65
|
} from "./api/classify";
|
|
51
66
|
import { operationFor } from "./api/operation-surface";
|
|
67
|
+
import { resolveK8sOwnerChain } from "./api/owner-chain";
|
|
52
68
|
|
|
53
69
|
function pruneUndefined<T extends Record<string, unknown>>(obj: T): Record<string, unknown> {
|
|
54
70
|
const out: Record<string, unknown> = {};
|
|
@@ -207,5 +223,74 @@ export async function describeResources(
|
|
|
207
223
|
}
|
|
208
224
|
});
|
|
209
225
|
|
|
226
|
+
await addRuntimeChildren(client, resources, options.owned);
|
|
227
|
+
|
|
210
228
|
return observation(resources, unobserved);
|
|
211
229
|
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Scan Pods in the namespaces of entities this call just resolved, and
|
|
233
|
+
* classify each one's owner-reference chain (#1077). Mutates `resources` in
|
|
234
|
+
* place, adding an entry per Pod whose chain was worth reporting.
|
|
235
|
+
*
|
|
236
|
+
* Best-effort and additive: a namespace this scan cannot list (RBAC denial,
|
|
237
|
+
* a transient error) is simply skipped rather than failing the observation —
|
|
238
|
+
* Pods are not declared entities, so there is no NOT-OBSERVED axis for them
|
|
239
|
+
* to report against, the same way an out-of-band AWS child resource has none
|
|
240
|
+
* either.
|
|
241
|
+
*/
|
|
242
|
+
async function addRuntimeChildren(
|
|
243
|
+
client: K8sClient,
|
|
244
|
+
resources: Record<string, ResourceMetadata>,
|
|
245
|
+
owned: boolean | undefined,
|
|
246
|
+
): Promise<void> {
|
|
247
|
+
const declaredByUid = new Map<string, string>();
|
|
248
|
+
for (const [entityName, meta] of Object.entries(resources)) {
|
|
249
|
+
if (meta.physicalId) declaredByUid.set(meta.physicalId, entityName);
|
|
250
|
+
}
|
|
251
|
+
|
|
252
|
+
const namespaces = new Set<string>();
|
|
253
|
+
for (const meta of Object.values(resources)) {
|
|
254
|
+
const namespace = (meta.attributes as { namespace?: string } | undefined)?.namespace;
|
|
255
|
+
if (namespace) namespaces.add(namespace);
|
|
256
|
+
}
|
|
257
|
+
if (namespaces.size === 0) return;
|
|
258
|
+
|
|
259
|
+
await client.concurrently([...namespaces], async (namespace) => {
|
|
260
|
+
let pods: K8sObject[];
|
|
261
|
+
try {
|
|
262
|
+
pods = await client.list({ apiVersion: "v1", kind: "Pod" }, { namespace });
|
|
263
|
+
} catch {
|
|
264
|
+
return; // best-effort — see the function doc
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
await client.concurrently(pods, async (pod) => {
|
|
268
|
+
const uid = pod.metadata?.uid;
|
|
269
|
+
const name = pod.metadata?.name;
|
|
270
|
+
if (!uid || !name || declaredByUid.has(uid)) return; // declared directly, or unaddressable
|
|
271
|
+
|
|
272
|
+
const ownerChain = await resolveK8sOwnerChain(pod, { declaredByUid, reader: client, namespace });
|
|
273
|
+
|
|
274
|
+
// `--owned`: withhold a Pod that is neither a runtime child of a
|
|
275
|
+
// declared entity nor carrying chant's own marker — the same rule the
|
|
276
|
+
// declared-entity read above applies, extended to the undeclared axis
|
|
277
|
+
// this scan introduces.
|
|
278
|
+
const marker = hasOwnershipMarker(pod.metadata?.labels, LABEL_OWNERSHIP_KEYS);
|
|
279
|
+
if (owned && ownerChain.root !== "declared" && !marker) return;
|
|
280
|
+
|
|
281
|
+
resources[`${namespace}/${name}`] = {
|
|
282
|
+
type: "K8s::Core::Pod",
|
|
283
|
+
physicalId: uid,
|
|
284
|
+
status: statusFromObject(pod),
|
|
285
|
+
lastUpdated: pod.metadata?.creationTimestamp,
|
|
286
|
+
ownership: classifyOwnership(pod.metadata?.labels, LABEL_OWNERSHIP_KEYS),
|
|
287
|
+
ownerChain,
|
|
288
|
+
attributes: pruneUndefined({
|
|
289
|
+
namespace,
|
|
290
|
+
labels: pod.metadata?.labels,
|
|
291
|
+
resourceVersion: pod.metadata?.resourceVersion,
|
|
292
|
+
}),
|
|
293
|
+
};
|
|
294
|
+
});
|
|
295
|
+
});
|
|
296
|
+
}
|