@intentius/chant 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 (45) hide show
  1. package/dist/cli/command-group.d.ts +134 -0
  2. package/dist/cli/command-group.d.ts.map +1 -0
  3. package/dist/cli/conflict-check.d.ts +1 -1
  4. package/dist/cli/conflict-check.d.ts.map +1 -1
  5. package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
  6. package/dist/cli/main.d.ts.map +1 -1
  7. package/dist/codegen/generate.d.ts +16 -0
  8. package/dist/codegen/generate.d.ts.map +1 -1
  9. package/dist/graph-ir.d.ts +17 -3
  10. package/dist/graph-ir.d.ts.map +1 -1
  11. package/dist/index.d.ts +1 -0
  12. package/dist/index.d.ts.map +1 -1
  13. package/dist/kubectl-context.d.ts +18 -1
  14. package/dist/kubectl-context.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +40 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/change-set.d.ts +15 -7
  18. package/dist/lifecycle/change-set.d.ts.map +1 -1
  19. package/dist/lifecycle/live-diff.d.ts +25 -1
  20. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  21. package/dist/managed-fields.d.ts +118 -0
  22. package/dist/managed-fields.d.ts.map +1 -0
  23. package/dist/owner-chain.d.ts +99 -0
  24. package/dist/owner-chain.d.ts.map +1 -0
  25. package/package.json +1 -1
  26. package/src/cli/command-group.test.ts +208 -0
  27. package/src/cli/command-group.ts +199 -0
  28. package/src/cli/conflict-check.test.ts +36 -1
  29. package/src/cli/conflict-check.ts +22 -1
  30. package/src/cli/handlers/lifecycle.ts +5 -0
  31. package/src/cli/main.ts +107 -27
  32. package/src/codegen/generate.ts +25 -0
  33. package/src/graph-ir-live.test.ts +40 -0
  34. package/src/graph-ir.ts +32 -7
  35. package/src/index.ts +1 -0
  36. package/src/kubectl-context.ts +22 -2
  37. package/src/lexicon.ts +44 -0
  38. package/src/lifecycle/change-set.test.ts +100 -0
  39. package/src/lifecycle/change-set.ts +39 -10
  40. package/src/lifecycle/live-diff.test.ts +88 -0
  41. package/src/lifecycle/live-diff.ts +55 -8
  42. package/src/managed-fields.test.ts +179 -0
  43. package/src/managed-fields.ts +328 -0
  44. package/src/owner-chain.test.ts +97 -0
  45. package/src/owner-chain.ts +128 -0
@@ -0,0 +1,328 @@
1
+ /**
2
+ * Kubernetes-object-shape utilities shared by every lexicon whose live model
3
+ * is a Kubernetes API object — the k8s lexicon itself (chant #1076) and GCP's
4
+ * Config Connector custom resources (chant #1087).
5
+ *
6
+ * A Config Connector custom resource *is* a Kubernetes object: it carries the
7
+ * same envelope (`status`, `metadata.{uid,resourceVersion,generation,
8
+ * creationTimestamp,managedFields,selfLink}`), the same SSA `fieldsV1`
9
+ * encoding for `metadata.managedFields`, and some CNRM kinds even embed
10
+ * genuinely k8s-shaped substructures (Cloud Run's `RunService` wraps a
11
+ * Knative pod spec with `containers`/`env`/`ports`, keyed the same way a
12
+ * Deployment's are). None of that is specific to chant's k8s *lexicon* — it
13
+ * is a fact about the Kubernetes API that any reader of a Kubernetes-shaped
14
+ * object needs, regardless of which lexicon is doing the reading.
15
+ *
16
+ * This module lives in core rather than in the k8s lexicon for the same
17
+ * reason `./kubectl-context.ts`'s `resolveClusterTarget` does (chant #1100):
18
+ * GCP's observation needs it too, without taking a dependency on the k8s
19
+ * lexicon package. Nothing here is keyed by chant's own k8s entityType
20
+ * catalog (`K8s::Apps::Deployment`, …) or by any lexicon's service-default
21
+ * table — that stays lexicon-specific, layered on top of what's here
22
+ * (`lexicons/k8s/src/deep-observe-hooks.ts`'s `K8S_SERVICE_DEFAULTS`,
23
+ * `lexicons/gcp/src/deep-observe.ts`'s CNRM-specific annotation noise).
24
+ */
25
+
26
+ import type { DeepArrayElement, DeepNode } from "./deep-observation";
27
+
28
+ // ── The generic Kubernetes object envelope ──────────────────────────────────
29
+
30
+ /**
31
+ * Paths every Kubernetes API object carries regardless of kind, matched on
32
+ * the exact index-erased pattern (there is exactly one `status`, one
33
+ * `metadata.managedFields`, per object — no per-type variation the way AWS's
34
+ * `Arn`/`RoleId` repeat at every nesting depth).
35
+ *
36
+ * - `status` — the whole subtree is server-computed; no declarative source
37
+ * (chant's k8s manifests, chant's Config Connector CRs) ever authors it.
38
+ * - `metadata.uid`/`resourceVersion`/`generation`/`creationTimestamp` — minted
39
+ * and incremented by the API server, never authored.
40
+ * - `metadata.managedFields` — the bookkeeping the ownership walk below reads
41
+ * to decide everything else. Left in the tree it would report as permanent
42
+ * drift (a timestamp changes on every write) and would recurse into the
43
+ * encoded `fieldsV1` structure as if it were ordinary properties.
44
+ * - `metadata.selfLink` — deprecated API-server bookkeeping some clusters
45
+ * still echo; never a declared field.
46
+ */
47
+ export const K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS: ReadonlySet<string> = new Set([
48
+ "status",
49
+ "metadata.uid",
50
+ "metadata.resourceVersion",
51
+ "metadata.generation",
52
+ "metadata.creationTimestamp",
53
+ "metadata.managedFields",
54
+ "metadata.selfLink",
55
+ ]);
56
+
57
+ function isRecord(value: unknown): value is Record<string, unknown> {
58
+ return typeof value === "object" && value !== null && !Array.isArray(value);
59
+ }
60
+
61
+ /** Stable JSON with sorted keys — the fallback ordering key for a set-like array without a natural identity field. */
62
+ function canonicalJson(value: unknown): string {
63
+ return (
64
+ JSON.stringify(value, (_k, v: unknown) =>
65
+ v && typeof v === "object" && !Array.isArray(v)
66
+ ? Object.fromEntries(Object.entries(v as Record<string, unknown>).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
67
+ : v,
68
+ ) ?? ""
69
+ );
70
+ }
71
+
72
+ /** The final segment of an index-erased pattern (`spec.template.spec.containers[].env[].name` → `name`'s *container*, i.e. `env`). */
73
+ function lastSegment(pattern: string): string {
74
+ const withoutIndex = pattern.replace(/\[\]$/, "");
75
+ const dot = withoutIndex.lastIndexOf(".");
76
+ return dot === -1 ? withoutIndex : withoutIndex.slice(dot + 1);
77
+ }
78
+
79
+ /**
80
+ * Kubernetes' own well-known list-map-key conventions for the substructures
81
+ * that recur across kinds and across lexicons: containers/initContainers/
82
+ * ephemeralContainers and `env`/`volumes` keyed by `name` — the same field
83
+ * Kubernetes' strategic-merge-patch and SSA's `list-map-keys` key on for
84
+ * these lists — and container ports keyed by `containerPort`+`protocol`,
85
+ * Service ports keyed by `port`+`protocol` (Kubernetes' own SSA
86
+ * `list-map-keys` for each). Both port shapes are handled under one `ports`
87
+ * branch by checking which field is present.
88
+ *
89
+ * Entity-type-agnostic on purpose: whether an array named `containers`
90
+ * belongs to a `K8s::Apps::Deployment` or to a GCP `RunService`'s embedded
91
+ * pod spec, the identity Kubernetes assigns each element is the same.
92
+ */
93
+ export function k8sListMapOrderKey(element: DeepArrayElement): string | undefined {
94
+ const name = lastSegment(element.pattern);
95
+ const el = element.element;
96
+
97
+ if (name === "containers" || name === "initContainers" || name === "ephemeralContainers") {
98
+ return isRecord(el) && typeof el.name === "string" ? el.name : canonicalJson(el);
99
+ }
100
+ if (name === "env" || name === "volumes") {
101
+ return isRecord(el) && typeof el.name === "string" ? el.name : canonicalJson(el);
102
+ }
103
+ if (name === "ports") {
104
+ if (isRecord(el)) {
105
+ const protocol = typeof el.protocol === "string" ? el.protocol : "TCP";
106
+ // Zero-padded so the sort key orders numerically, not lexicographically
107
+ // ("443" would otherwise sort before "80") — cosmetic, since either
108
+ // order canonicalizes the two sides identically, but a stable,
109
+ // human-sensible order is free to have here.
110
+ if (typeof el.containerPort === "number") return `${String(el.containerPort).padStart(5, "0")}/${protocol}`;
111
+ if (typeof el.port === "number") return `${String(el.port).padStart(5, "0")}/${protocol}`;
112
+ }
113
+ return canonicalJson(el);
114
+ }
115
+
116
+ return undefined;
117
+ }
118
+
119
+ // ── managedFields ownership ─────────────────────────────────────────────────
120
+
121
+ /**
122
+ * The structural shape of one `metadata.managedFields` entry this module
123
+ * needs. Matches `@intentius/chant-k8s-client`'s `ManagedFieldsEntry`
124
+ * (chant #1075) field-for-field, but is declared independently here rather
125
+ * than imported from that package: core must stay reachable from any
126
+ * lexicon's build path, and the k8s client package is deliberately *not*
127
+ * reachable from one (chant #1074's structural boundary,
128
+ * `examples/k8s-client-boundary.test.ts`). A caller that already has a real
129
+ * `ManagedFieldsEntry[]` (the k8s lexicon) passes it straight through —
130
+ * TypeScript's structural typing accepts it with no cast.
131
+ */
132
+ export interface ManagedFieldsEntryLike {
133
+ manager?: string;
134
+ operation?: string;
135
+ subresource?: string;
136
+ fieldsV1?: Record<string, unknown>;
137
+ }
138
+
139
+ /** One live object's managed-fields ownership, resolved to chant dot-paths. */
140
+ export interface OwnershipSets {
141
+ /** Paths any chant field manager owns on this object. */
142
+ chantOwned: ReadonlySet<string>;
143
+ /** Paths owned by a manager that is not chant. */
144
+ foreignOwned: ReadonlySet<string>;
145
+ /** The subset of `foreignOwned` where the declared manifest also sets the path — drift-relevant despite foreign ownership. */
146
+ foreignContested: ReadonlySet<string>;
147
+ }
148
+
149
+ function sameJson(a: unknown, b: unknown): boolean {
150
+ return JSON.stringify(a) === JSON.stringify(b);
151
+ }
152
+
153
+ function findKeyedIndex(array: readonly unknown[], keyFields: Record<string, unknown>): number {
154
+ return array.findIndex(
155
+ (el) => isRecord(el) && Object.entries(keyFields).every(([k, v]) => sameJson(el[k], v)),
156
+ );
157
+ }
158
+
159
+ function findValueIndex(array: readonly unknown[], value: unknown): number {
160
+ return array.findIndex((el) => sameJson(el, value));
161
+ }
162
+
163
+ function joinField(parent: string, name: string): string {
164
+ return parent ? `${parent}.${name}` : name;
165
+ }
166
+
167
+ function joinIndex(parent: string, index: number): string {
168
+ return `${parent}[${index}]`;
169
+ }
170
+
171
+ /**
172
+ * Walk one manager's `fieldsV1` tree in lockstep with the live object and the
173
+ * declared props, threading the *live* dot-path (chant's own path syntax —
174
+ * no leading dot, real array indices) as it goes. `owned` collects every path
175
+ * this manager's entry reaches on the live tree; `contested` collects the
176
+ * subset where the declared tree also has a value at the equivalent
177
+ * position, resolved by the same key/value match rather than by index.
178
+ *
179
+ * Only `f:`/`i:`/`v:`/`k:`/`.` are understood — the same five forms
180
+ * `@intentius/chant-k8s-client`'s `managed-fields.ts`'s `renderSegment`
181
+ * renders — and an unrecognized prefix is skipped, consistent with that
182
+ * module's own behavior, rather than guessed at.
183
+ */
184
+ function walkOwnership(
185
+ fieldsNode: unknown,
186
+ liveNode: unknown,
187
+ declaredNode: unknown,
188
+ path: string,
189
+ owned: Set<string>,
190
+ contested: Set<string>,
191
+ ): void {
192
+ if (fieldsNode === null || typeof fieldsNode !== "object" || Array.isArray(fieldsNode)) return;
193
+
194
+ for (const [key, child] of Object.entries(fieldsNode as Record<string, unknown>)) {
195
+ if (key === ".") {
196
+ if (path !== "") {
197
+ owned.add(path);
198
+ if (declaredNode !== undefined) contested.add(path);
199
+ }
200
+ continue;
201
+ }
202
+
203
+ if (key.startsWith("f:")) {
204
+ const name = key.slice(2);
205
+ if (!isRecord(liveNode) || !(name in liveNode)) continue;
206
+ const childLive = liveNode[name];
207
+ const childDeclared = isRecord(declaredNode) ? declaredNode[name] : undefined;
208
+ const childPath = joinField(path, name);
209
+ owned.add(childPath);
210
+ if (childDeclared !== undefined) contested.add(childPath);
211
+ walkOwnership(child, childLive, childDeclared, childPath, owned, contested);
212
+ continue;
213
+ }
214
+
215
+ if (key.startsWith("i:")) {
216
+ const idx = Number(key.slice(2));
217
+ if (!Array.isArray(liveNode) || !Number.isInteger(idx) || idx < 0 || idx >= liveNode.length) continue;
218
+ const childLive: unknown = liveNode[idx];
219
+ const childDeclared = Array.isArray(declaredNode) ? declaredNode[idx] : undefined;
220
+ const childPath = joinIndex(path, idx);
221
+ owned.add(childPath);
222
+ if (childDeclared !== undefined) contested.add(childPath);
223
+ walkOwnership(child, childLive, childDeclared, childPath, owned, contested);
224
+ continue;
225
+ }
226
+
227
+ if (key.startsWith("v:") || key.startsWith("k:")) {
228
+ if (!Array.isArray(liveNode)) continue;
229
+ let decoded: unknown;
230
+ try {
231
+ decoded = JSON.parse(key.slice(2));
232
+ } catch {
233
+ continue; // not decodable JSON — skip rather than mangle, like renderSegment.
234
+ }
235
+
236
+ const liveIdx =
237
+ key.startsWith("v:")
238
+ ? findValueIndex(liveNode, decoded)
239
+ : isRecord(decoded)
240
+ ? findKeyedIndex(liveNode, decoded)
241
+ : -1;
242
+ if (liveIdx === -1) continue;
243
+
244
+ let childDeclared: unknown;
245
+ if (Array.isArray(declaredNode)) {
246
+ const declaredIdx = key.startsWith("v:")
247
+ ? findValueIndex(declaredNode, decoded)
248
+ : isRecord(decoded)
249
+ ? findKeyedIndex(declaredNode, decoded)
250
+ : -1;
251
+ childDeclared = declaredIdx === -1 ? undefined : declaredNode[declaredIdx];
252
+ }
253
+
254
+ const childLive: unknown = liveNode[liveIdx];
255
+ const childPath = joinIndex(path, liveIdx);
256
+ owned.add(childPath);
257
+ if (childDeclared !== undefined) contested.add(childPath);
258
+ walkOwnership(child, childLive, childDeclared, childPath, owned, contested);
259
+ continue;
260
+ }
261
+ // An unrecognized prefix (a future fieldsV1 encoding) — skip.
262
+ }
263
+ }
264
+
265
+ /**
266
+ * Build the three ownership sets for one live object. `entries` is
267
+ * `metadata.managedFields`, already decoded (`@intentius/chant-k8s-client`'s
268
+ * `managedFieldsOf` for the k8s lexicon; a plain `JSON.parse` of `kubectl get
269
+ * -o json` for gcp); `isChantManager` classifies each entry's manager name
270
+ * — matched on the `chant`/`chant:<stack>` family per chant #1075, but the
271
+ * matcher itself is supplied by the caller rather than fixed here, because
272
+ * what counts as "chant" is not the same fact on every lexicon's apply path
273
+ * (see gcp's `deep-observe.ts` module doc for why that matters there).
274
+ *
275
+ * Subresource entries (`status`, `scale`) are excluded: a controller writing
276
+ * a Deployment's `status` is not competing for the spec chant declared, the
277
+ * same reasoning `@intentius/chant-k8s-client`'s `fieldsOwnedBy` default
278
+ * already encodes.
279
+ */
280
+ export function buildOwnershipSets(
281
+ entries: readonly ManagedFieldsEntryLike[],
282
+ liveRoot: Record<string, unknown>,
283
+ declaredRoot: Record<string, unknown>,
284
+ isChantManager: (manager: string | undefined) => boolean,
285
+ ): OwnershipSets {
286
+ const chantOwned = new Set<string>();
287
+ const foreignOwned = new Set<string>();
288
+ const foreignContested = new Set<string>();
289
+
290
+ for (const entry of entries) {
291
+ if (typeof entry.manager !== "string" || entry.manager.length === 0) continue;
292
+ if (entry.subresource !== undefined) continue;
293
+
294
+ if (isChantManager(entry.manager)) {
295
+ // Chant-owned paths are always diffable, regardless of who else is
296
+ // involved — "contested" only matters for a *foreign* owner.
297
+ walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", chantOwned, new Set());
298
+ } else {
299
+ walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", foreignOwned, foreignContested);
300
+ }
301
+ }
302
+
303
+ return { chantOwned, foreignOwned, foreignContested };
304
+ }
305
+
306
+ /**
307
+ * The three-question managed-fields prune rule, as a predicate over a
308
+ * {@link DeepNode} plus one object's precomputed {@link OwnershipSets} —
309
+ * shared by every lexicon layering a per-resource managed-fields prune on
310
+ * top of its own static rules (k8s's `perResourceHooks`, gcp's equivalent):
311
+ *
312
+ * 1. Chant owns the path (any chant manager) → never pruned by this rule.
313
+ * 2. A different manager owns it, chant does not, and it is not declared →
314
+ * controller-managed noise, pruned.
315
+ * 3. It is declared, regardless of who owns it live → never pruned by this
316
+ * rule, because chant's source is a statement of intent independent of
317
+ * which write currently holds the field.
318
+ *
319
+ * Only applies to the live side — the declared tree carries no managedFields
320
+ * to prune by.
321
+ */
322
+ export function pruneByOwnership(node: DeepNode, sets: OwnershipSets): boolean {
323
+ if (node.side !== "live") return false;
324
+ if (sets.chantOwned.has(node.path)) return false;
325
+ if (!sets.foreignOwned.has(node.path)) return false;
326
+ if (sets.foreignContested.has(node.path)) return false;
327
+ return true;
328
+ }
@@ -0,0 +1,97 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import { classifyOwnerChain, DEFAULT_MAX_OWNER_CHAIN_DEPTH, type OwnerChainNode } from "./owner-chain";
3
+
4
+ describe("classifyOwnerChain (#1077)", () => {
5
+ test("owned chain reaching a declared entity → declared", () => {
6
+ // pod -> replicaSet -> deployment (declared)
7
+ const nodes = new Map<string, OwnerChainNode>([
8
+ ["pod-uid", { ownerId: "rs-uid" }],
9
+ ["rs-uid", { ownerId: "deploy-uid" }],
10
+ ["deploy-uid", { declaredEntity: "web" }],
11
+ ]);
12
+ expect(classifyOwnerChain("pod-uid", nodes)).toEqual({ root: "declared", entity: "web" });
13
+ });
14
+
15
+ test("a node that is itself declared resolves immediately", () => {
16
+ const nodes = new Map<string, OwnerChainNode>([["deploy-uid", { declaredEntity: "web" }]]);
17
+ expect(classifyOwnerChain("deploy-uid", nodes)).toEqual({ root: "declared", entity: "web" });
18
+ });
19
+
20
+ test("no owner reference at all → unowned", () => {
21
+ const nodes = new Map<string, OwnerChainNode>([["pod-uid", {}]]);
22
+ expect(classifyOwnerChain("pod-uid", nodes)).toEqual({ root: "unowned" });
23
+ });
24
+
25
+ test("chain fully resolves to a live, undeclared root → foreign", () => {
26
+ // pod -> replicaSet -> deployment, but the deployment is not declared
27
+ const nodes = new Map<string, OwnerChainNode>([
28
+ ["pod-uid", { ownerId: "rs-uid" }],
29
+ ["rs-uid", { ownerId: "deploy-uid" }],
30
+ ["deploy-uid", {}],
31
+ ]);
32
+ expect(classifyOwnerChain("pod-uid", nodes)).toEqual({ root: "foreign" });
33
+ });
34
+
35
+ test("the starting node itself could not be read → unknown", () => {
36
+ const nodes = new Map<string, OwnerChainNode>([["pod-uid", { ownerUnreadable: true }]]);
37
+ expect(classifyOwnerChain("pod-uid", nodes)).toEqual({ root: "unknown" });
38
+ });
39
+
40
+ test("an intermediate owner could not be read → unknown, conservative (never foreign, never declared)", () => {
41
+ const nodes = new Map<string, OwnerChainNode>([
42
+ ["pod-uid", { ownerId: "rs-uid" }],
43
+ ["rs-uid", { ownerUnreadable: true }],
44
+ ]);
45
+ expect(classifyOwnerChain("pod-uid", nodes)).toEqual({ root: "unknown" });
46
+ });
47
+
48
+ test("an owner reference naming a node never resolved into the map → unknown", () => {
49
+ const nodes = new Map<string, OwnerChainNode>([["pod-uid", { ownerId: "rs-uid" }]]);
50
+ expect(classifyOwnerChain("pod-uid", nodes)).toEqual({ root: "unknown" });
51
+ });
52
+
53
+ test("the starting id itself is not in the map → unknown", () => {
54
+ expect(classifyOwnerChain("nope", new Map())).toEqual({ root: "unknown" });
55
+ });
56
+
57
+ test("a cycle is detected and classified unknown, not an infinite loop", () => {
58
+ const nodes = new Map<string, OwnerChainNode>([
59
+ ["a", { ownerId: "b" }],
60
+ ["b", { ownerId: "a" }],
61
+ ]);
62
+ expect(classifyOwnerChain("a", nodes)).toEqual({ root: "unknown" });
63
+ });
64
+
65
+ test("a self-referencing node is a cycle of one", () => {
66
+ const nodes = new Map<string, OwnerChainNode>([["a", { ownerId: "a" }]]);
67
+ expect(classifyOwnerChain("a", nodes)).toEqual({ root: "unknown" });
68
+ });
69
+
70
+ test("exceeding the depth bound is conservative unknown, not foreign", () => {
71
+ // A straight-line chain one hop longer than the default bound, never
72
+ // reaching a declared entity or a definite foreign root within it.
73
+ const nodes = new Map<string, OwnerChainNode>();
74
+ const depth = DEFAULT_MAX_OWNER_CHAIN_DEPTH + 5;
75
+ for (let i = 0; i < depth; i++) nodes.set(`n${i}`, { ownerId: `n${i + 1}` });
76
+ nodes.set(`n${depth}`, {}); // the true, foreign root — but out of bounds
77
+ expect(classifyOwnerChain("n0", nodes)).toEqual({ root: "unknown" });
78
+ });
79
+
80
+ test("a chain exactly at the depth bound still resolves to declared", () => {
81
+ const nodes = new Map<string, OwnerChainNode>();
82
+ const depth = DEFAULT_MAX_OWNER_CHAIN_DEPTH;
83
+ for (let i = 0; i < depth; i++) nodes.set(`n${i}`, { ownerId: `n${i + 1}` });
84
+ nodes.set(`n${depth}`, { declaredEntity: "root-entity" });
85
+ expect(classifyOwnerChain("n0", nodes)).toEqual({ root: "declared", entity: "root-entity" });
86
+ });
87
+
88
+ test("a custom maxDepth is honored", () => {
89
+ const nodes = new Map<string, OwnerChainNode>([
90
+ ["a", { ownerId: "b" }],
91
+ ["b", { ownerId: "c" }],
92
+ ["c", { declaredEntity: "deep" }],
93
+ ]);
94
+ expect(classifyOwnerChain("a", nodes, 1)).toEqual({ root: "unknown" });
95
+ expect(classifyOwnerChain("a", nodes, 2)).toEqual({ root: "declared", entity: "deep" });
96
+ });
97
+ });
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Owner-chain classification (chant #1077).
3
+ *
4
+ * `describeResources()` already lets a lexicon report a live object it never
5
+ * asked about by name — that is how `orphan` has always worked (a resource
6
+ * present in `observedNow` that is not in `declared`). Every existing consumer
7
+ * treats every such object the same way: undeclared, so a delete/adopt
8
+ * candidate.
9
+ *
10
+ * On Kubernetes that conflates two different things. A console-added SNS
11
+ * subscription (the AWS case #1014/#1015 was built for) really is out-of-band
12
+ * drift. A Pod a declared Deployment's controller created is not drift at
13
+ * all — it is the runtime doing its job, and it will be recreated the moment
14
+ * it is deleted. `ownerReferences` is what tells them apart: the Pod's chain
15
+ * of owners terminates at the Deployment, which is declared.
16
+ *
17
+ * This module owns the *category* — the four possible answers to "where does
18
+ * this object's owner chain lead" — and the pure algorithm that walks a chain
19
+ * to one of them. A lexicon supplies the chain (reading `ownerReferences`,
20
+ * possibly across several API reads to walk past an intermediate object chant
21
+ * never declared, e.g. a ReplicaSet between a Pod and its Deployment); this
22
+ * module supplies the bounded, cycle-safe interpretation, so that logic is
23
+ * written and tested once rather than once per lexicon.
24
+ */
25
+
26
+ /**
27
+ * Where a live, undeclared resource's owner-reference chain leads.
28
+ *
29
+ * - `declared` — the chain reaches an entity chant's own build declared. This
30
+ * is the whole point of #1077: the diff engine reads this as `runtime`, not
31
+ * `orphan`, and never proposes deleting it.
32
+ * - `unowned` — the resource carries no owner reference at all. A genuinely
33
+ * standalone live object; classifies as `orphan`, unchanged from before this
34
+ * module existed.
35
+ * - `foreign` — the chain fully resolves (every hop was readable, no cycle, no
36
+ * depth bound hit) but terminates at a live root that is not declared.
37
+ * Still `orphan` — it belongs to something real, just not to this build.
38
+ * - `unknown` — some hop could not be resolved: an unreadable owner, a cycle,
39
+ * or the depth bound. Composes with #1168's tri-state precedent: an owner
40
+ * chain chant could not fully verify is not a confirmed anything, so it is
41
+ * never escalated to `declared` and stays routed as `orphan` today, exactly
42
+ * as `foreign`/`unowned` are — never treated as a safer-than-warranted
43
+ * `runtime` classification just because the read was incomplete.
44
+ */
45
+ export type OwnerChainVerdict =
46
+ | { readonly root: "declared"; readonly entity: string }
47
+ | { readonly root: "unowned" }
48
+ | { readonly root: "foreign" }
49
+ | { readonly root: "unknown" };
50
+
51
+ /**
52
+ * One node in the owner graph a lexicon assembles for {@link classifyOwnerChain}.
53
+ * Keyed externally (in the `nodes` map passed to the walk) by whatever stable
54
+ * identity the lexicon's provider uses — a Kubernetes UID, for instance.
55
+ */
56
+ export interface OwnerChainNode {
57
+ /**
58
+ * This node's immediate owner, by its key in the same `nodes` map. Omit
59
+ * (`undefined`) when the object carries no owner reference at all — that is
60
+ * how a chain's *starting* node reports `unowned` rather than `unknown`.
61
+ */
62
+ ownerId?: string;
63
+ /**
64
+ * True when this node's own owner could not be determined — the read
65
+ * failed, was denied, or the object simply could not be fetched. Distinct
66
+ * from having no owner: this says "unknown", not "none".
67
+ */
68
+ ownerUnreadable?: boolean;
69
+ /**
70
+ * The declared chant entity name, when this node corresponds to one. A node
71
+ * with this set ends the walk immediately with `{ root: "declared" }` —
72
+ * whatever `ownerId`/`ownerUnreadable` it might also carry is irrelevant,
73
+ * since the chain already reached what it was looking for.
74
+ */
75
+ declaredEntity?: string;
76
+ }
77
+
78
+ /** Default bound on how many owner hops {@link classifyOwnerChain} will walk
79
+ * before giving up conservatively. Kubernetes' own garbage collector does not
80
+ * bound this at all, but a live read has to — a bound this generous is well
81
+ * past any real ownership depth (Pod → ReplicaSet → Deployment is 2 hops) and
82
+ * exists only to turn a corrupt or adversarial chain into `unknown` rather
83
+ * than an infinite walk. */
84
+ export const DEFAULT_MAX_OWNER_CHAIN_DEPTH = 12;
85
+
86
+ /**
87
+ * Walk the owner chain starting at `startId` through `nodes`, bounded and
88
+ * cycle-safe. Pure — the caller has already done whatever I/O was needed to
89
+ * populate `nodes`; this function only interprets the graph it was given.
90
+ *
91
+ * `nodes` need not contain every ancestor: a node the caller never resolved
92
+ * (because it gave up, hit the caller's own fetch bound, or the read failed)
93
+ * is simply absent from the map, and a reference to an absent node classifies
94
+ * as `unknown` — the conservative answer, same as an explicit
95
+ * `ownerUnreadable`.
96
+ */
97
+ export function classifyOwnerChain(
98
+ startId: string,
99
+ nodes: ReadonlyMap<string, OwnerChainNode>,
100
+ maxDepth: number = DEFAULT_MAX_OWNER_CHAIN_DEPTH,
101
+ ): OwnerChainVerdict {
102
+ const start = nodes.get(startId);
103
+ if (!start) return { root: "unknown" };
104
+ if (start.declaredEntity) return { root: "declared", entity: start.declaredEntity };
105
+ if (start.ownerUnreadable) return { root: "unknown" };
106
+ if (start.ownerId === undefined) return { root: "unowned" };
107
+
108
+ const visited = new Set<string>([startId]);
109
+ let currentId = start.ownerId;
110
+
111
+ for (let depth = 0; depth < maxDepth; depth++) {
112
+ if (visited.has(currentId)) return { root: "unknown" }; // cycle
113
+ visited.add(currentId);
114
+
115
+ const node = nodes.get(currentId);
116
+ if (!node) return { root: "unknown" }; // referenced but never resolved
117
+ if (node.declaredEntity) return { root: "declared", entity: node.declaredEntity };
118
+ if (node.ownerUnreadable) return { root: "unknown" };
119
+ if (node.ownerId === undefined) return { root: "foreign" }; // a real, live, undeclared root
120
+
121
+ currentId = node.ownerId;
122
+ }
123
+
124
+ // The bound was hit without resolving to a declared entity or a definite
125
+ // root. Conservative: not a confirmed `foreign` either, since one more hop
126
+ // might have reached a declared entity — see the type doc on `unknown`.
127
+ return { root: "unknown" };
128
+ }