@intentius/chant 0.31.0 → 0.33.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/cli/command-group.d.ts +134 -0
- package/dist/cli/command-group.d.ts.map +1 -0
- package/dist/cli/conflict-check.d.ts +1 -1
- package/dist/cli/conflict-check.d.ts.map +1 -1
- package/dist/cli/handlers/graph.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +58 -0
- package/dist/cli/handlers/search.d.ts.map +1 -0
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +4 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/config.d.ts +3 -0
- package/dist/config.d.ts.map +1 -1
- package/dist/graph-declared.d.ts +20 -0
- package/dist/graph-declared.d.ts.map +1 -0
- package/dist/graph-effective.d.ts +25 -0
- package/dist/graph-effective.d.ts.map +1 -0
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/index.d.ts +1 -0
- package/dist/index.d.ts.map +1 -1
- package/dist/lexicon.d.ts +49 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/change-set.d.ts +15 -7
- package/dist/lifecycle/change-set.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts +25 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +14 -6
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +118 -0
- package/dist/managed-fields.d.ts.map +1 -0
- package/dist/owner-chain.d.ts +99 -0
- package/dist/owner-chain.d.ts.map +1 -0
- package/package.json +1 -1
- package/src/cli/command-group.test.ts +208 -0
- package/src/cli/command-group.ts +199 -0
- package/src/cli/conflict-check.test.ts +36 -1
- package/src/cli/conflict-check.ts +22 -1
- package/src/cli/handlers/graph.test.ts +1 -1
- package/src/cli/handlers/graph.ts +33 -11
- package/src/cli/handlers/lifecycle.ts +5 -0
- package/src/cli/handlers/search.test.ts +113 -0
- package/src/cli/handlers/search.ts +263 -0
- package/src/cli/main.ts +114 -27
- package/src/cli/registry.ts +4 -0
- package/src/config.ts +3 -0
- package/src/graph-declared.ts +33 -0
- package/src/graph-effective.test.ts +97 -0
- package/src/graph-effective.ts +110 -0
- package/src/graph-ir-live.test.ts +40 -0
- package/src/graph-ir.ts +32 -7
- package/src/index.ts +1 -0
- package/src/lexicon.ts +50 -1
- package/src/lifecycle/change-set.test.ts +100 -0
- package/src/lifecycle/change-set.ts +39 -10
- package/src/lifecycle/live-diff.test.ts +88 -0
- package/src/lifecycle/live-diff.ts +55 -8
- package/src/lifecycle/observe.test.ts +66 -2
- package/src/lifecycle/observe.ts +79 -18
- package/src/managed-fields.test.ts +179 -0
- package/src/managed-fields.ts +328 -0
- package/src/owner-chain.test.ts +97 -0
- 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
|
+
}
|