@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.
Files changed (103) hide show
  1. package/dist/api/classify.d.ts +10 -0
  2. package/dist/api/classify.d.ts.map +1 -1
  3. package/dist/api/owner-chain.d.ts +46 -0
  4. package/dist/api/owner-chain.d.ts.map +1 -0
  5. package/dist/api/sweep-types.d.ts +17 -0
  6. package/dist/api/sweep-types.d.ts.map +1 -0
  7. package/dist/deep-observe-hooks.d.ts +86 -0
  8. package/dist/deep-observe-hooks.d.ts.map +1 -0
  9. package/dist/deep-observe.d.ts +146 -0
  10. package/dist/deep-observe.d.ts.map +1 -0
  11. package/dist/describe-resources.d.ts +15 -0
  12. package/dist/describe-resources.d.ts.map +1 -1
  13. package/dist/export-resources.d.ts +6 -2
  14. package/dist/export-resources.d.ts.map +1 -1
  15. package/dist/integrity.json +2 -2
  16. package/dist/kube/apply.d.ts +26 -0
  17. package/dist/kube/apply.d.ts.map +1 -0
  18. package/dist/kube/connect.d.ts +30 -0
  19. package/dist/kube/connect.d.ts.map +1 -0
  20. package/dist/kube/delete.d.ts +27 -0
  21. package/dist/kube/delete.d.ts.map +1 -0
  22. package/dist/kube/describe.d.ts +19 -0
  23. package/dist/kube/describe.d.ts.map +1 -0
  24. package/dist/kube/events.d.ts +34 -0
  25. package/dist/kube/events.d.ts.map +1 -0
  26. package/dist/kube/flags.d.ts +40 -0
  27. package/dist/kube/flags.d.ts.map +1 -0
  28. package/dist/kube/get.d.ts +21 -0
  29. package/dist/kube/get.d.ts.map +1 -0
  30. package/dist/kube/group.d.ts +19 -0
  31. package/dist/kube/group.d.ts.map +1 -0
  32. package/dist/kube/logs.d.ts +16 -0
  33. package/dist/kube/logs.d.ts.map +1 -0
  34. package/dist/kube/project.d.ts +59 -0
  35. package/dist/kube/project.d.ts.map +1 -0
  36. package/dist/kube/render.d.ts +75 -0
  37. package/dist/kube/render.d.ts.map +1 -0
  38. package/dist/kube/source.d.ts +19 -0
  39. package/dist/kube/source.d.ts.map +1 -0
  40. package/dist/kube/target.d.ts +52 -0
  41. package/dist/kube/target.d.ts.map +1 -0
  42. package/dist/kube/testing.d.ts +21 -0
  43. package/dist/kube/testing.d.ts.map +1 -0
  44. package/dist/kube/top.d.ts +23 -0
  45. package/dist/kube/top.d.ts.map +1 -0
  46. package/dist/kube/verdict.d.ts +45 -0
  47. package/dist/kube/verdict.d.ts.map +1 -0
  48. package/dist/kube/wait.d.ts +23 -0
  49. package/dist/kube/wait.d.ts.map +1 -0
  50. package/dist/manifest.json +1 -1
  51. package/dist/op/activities/index.d.ts +6 -3
  52. package/dist/op/activities/index.d.ts.map +1 -1
  53. package/dist/op/activities/kubectl.d.ts +91 -9
  54. package/dist/op/activities/kubectl.d.ts.map +1 -1
  55. package/dist/plugin.d.ts.map +1 -1
  56. package/package.json +3 -3
  57. package/src/api/classify.ts +13 -0
  58. package/src/api/owner-chain.test.ts +171 -0
  59. package/src/api/owner-chain.ts +144 -0
  60. package/src/api/sweep-types.ts +36 -0
  61. package/src/deep-observe-hooks.ts +125 -0
  62. package/src/deep-observe.test.ts +668 -0
  63. package/src/deep-observe.ts +299 -0
  64. package/src/describe-resources.test.ts +224 -1
  65. package/src/describe-resources.ts +86 -1
  66. package/src/export-resources.ts +6 -22
  67. package/src/kube/apply.test.ts +105 -0
  68. package/src/kube/apply.ts +101 -0
  69. package/src/kube/connect.ts +54 -0
  70. package/src/kube/delete.test.ts +123 -0
  71. package/src/kube/delete.ts +174 -0
  72. package/src/kube/describe.test.ts +106 -0
  73. package/src/kube/describe.ts +195 -0
  74. package/src/kube/events.test.ts +115 -0
  75. package/src/kube/events.ts +160 -0
  76. package/src/kube/flags.test.ts +65 -0
  77. package/src/kube/flags.ts +114 -0
  78. package/src/kube/get.test.ts +195 -0
  79. package/src/kube/get.ts +190 -0
  80. package/src/kube/group.test.ts +33 -0
  81. package/src/kube/group.ts +125 -0
  82. package/src/kube/logs.test.ts +126 -0
  83. package/src/kube/logs.ts +96 -0
  84. package/src/kube/project.test.ts +57 -0
  85. package/src/kube/project.ts +106 -0
  86. package/src/kube/render.test.ts +142 -0
  87. package/src/kube/render.ts +252 -0
  88. package/src/kube/source.test.ts +138 -0
  89. package/src/kube/source.ts +139 -0
  90. package/src/kube/target.test.ts +67 -0
  91. package/src/kube/target.ts +89 -0
  92. package/src/kube/testing.ts +42 -0
  93. package/src/kube/top.test.ts +149 -0
  94. package/src/kube/top.ts +120 -0
  95. package/src/kube/verdict.test.ts +48 -0
  96. package/src/kube/verdict.ts +77 -0
  97. package/src/kube/wait.test.ts +125 -0
  98. package/src/kube/wait.ts +160 -0
  99. package/src/op/activities/index.ts +6 -3
  100. package/src/op/activities/kubectl.test.ts +298 -26
  101. package/src/op/activities/kubectl.ts +313 -20
  102. package/src/plugin.test.ts +58 -1
  103. 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
+ }