@intentius/chant 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 (39) 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/graph-ir.d.ts +17 -3
  8. package/dist/graph-ir.d.ts.map +1 -1
  9. package/dist/index.d.ts +1 -0
  10. package/dist/index.d.ts.map +1 -1
  11. package/dist/lexicon.d.ts +40 -0
  12. package/dist/lexicon.d.ts.map +1 -1
  13. package/dist/lifecycle/change-set.d.ts +15 -7
  14. package/dist/lifecycle/change-set.d.ts.map +1 -1
  15. package/dist/lifecycle/live-diff.d.ts +25 -1
  16. package/dist/lifecycle/live-diff.d.ts.map +1 -1
  17. package/dist/managed-fields.d.ts +118 -0
  18. package/dist/managed-fields.d.ts.map +1 -0
  19. package/dist/owner-chain.d.ts +99 -0
  20. package/dist/owner-chain.d.ts.map +1 -0
  21. package/package.json +1 -1
  22. package/src/cli/command-group.test.ts +208 -0
  23. package/src/cli/command-group.ts +199 -0
  24. package/src/cli/conflict-check.test.ts +36 -1
  25. package/src/cli/conflict-check.ts +22 -1
  26. package/src/cli/handlers/lifecycle.ts +5 -0
  27. package/src/cli/main.ts +107 -27
  28. package/src/graph-ir-live.test.ts +40 -0
  29. package/src/graph-ir.ts +32 -7
  30. package/src/index.ts +1 -0
  31. package/src/lexicon.ts +44 -0
  32. package/src/lifecycle/change-set.test.ts +100 -0
  33. package/src/lifecycle/change-set.ts +39 -10
  34. package/src/lifecycle/live-diff.test.ts +88 -0
  35. package/src/lifecycle/live-diff.ts +55 -8
  36. package/src/managed-fields.test.ts +179 -0
  37. package/src/managed-fields.ts +328 -0
  38. package/src/owner-chain.test.ts +97 -0
  39. package/src/owner-chain.ts +128 -0
@@ -19,6 +19,7 @@ describe("diffLive", () => {
19
19
  expect(result).toEqual({
20
20
  missing: [],
21
21
  orphan: [],
22
+ runtimeChildren: [],
22
23
  disappeared: [],
23
24
  newlyObserved: [],
24
25
  driftedSinceSnapshot: [],
@@ -114,6 +115,93 @@ describe("diffLive", () => {
114
115
  expect(result.unchanged).toEqual(["b"]);
115
116
  });
116
117
 
118
+ // ── Owner-reference chain classification (#1077) ──────────────────────────
119
+
120
+ describe("runtime children vs orphans", () => {
121
+ test("undeclared, chain reaches a declared entity → runtimeChildren, never orphan", () => {
122
+ const result = diffLive({
123
+ declared: new Set(["web"]),
124
+ observedNow: {
125
+ web: meta({ type: "K8s::Apps::Deployment" }),
126
+ "prod/web-abc123": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
127
+ },
128
+ observedThen: undefined,
129
+ });
130
+ expect(result.orphan).toEqual([]);
131
+ expect(result.runtimeChildren).toEqual([
132
+ { name: "prod/web-abc123", type: "K8s::Core::Pod", owner: "web" },
133
+ ]);
134
+ });
135
+
136
+ test("undeclared, no owner reference at all → orphan", () => {
137
+ const result = diffLive({
138
+ declared: new Set(),
139
+ observedNow: { "prod/standalone": meta({ ownerChain: { root: "unowned" } }) },
140
+ observedThen: undefined,
141
+ });
142
+ expect(result.orphan).toEqual(["prod/standalone"]);
143
+ expect(result.runtimeChildren).toEqual([]);
144
+ });
145
+
146
+ test("undeclared, chain resolves to a foreign (non-declared) root → orphan", () => {
147
+ const result = diffLive({
148
+ declared: new Set(),
149
+ observedNow: { "prod/other-app-pod": meta({ ownerChain: { root: "foreign" } }) },
150
+ observedThen: undefined,
151
+ });
152
+ expect(result.orphan).toEqual(["prod/other-app-pod"]);
153
+ expect(result.runtimeChildren).toEqual([]);
154
+ });
155
+
156
+ test("undeclared, chain could not be resolved (unreadable owner/cycle/depth) → conservative orphan, not runtime", () => {
157
+ const result = diffLive({
158
+ declared: new Set(),
159
+ observedNow: { "prod/mystery-pod": meta({ ownerChain: { root: "unknown" } }) },
160
+ observedThen: undefined,
161
+ });
162
+ expect(result.orphan).toEqual(["prod/mystery-pod"]);
163
+ expect(result.runtimeChildren).toEqual([]);
164
+ });
165
+
166
+ test("a lexicon that never sets ownerChain is unaffected — undeclared stays orphan", () => {
167
+ const result = diffLive({
168
+ declared: new Set(),
169
+ observedNow: { legacy: meta() }, // no ownerChain at all
170
+ observedThen: undefined,
171
+ });
172
+ expect(result.orphan).toEqual(["legacy"]);
173
+ expect(result.runtimeChildren).toEqual([]);
174
+ });
175
+
176
+ test("a runtime child rolling to a new name between snapshots is not `disappeared`", () => {
177
+ const result = diffLive({
178
+ declared: new Set(["web"]),
179
+ observedNow: {
180
+ web: meta({ type: "K8s::Apps::Deployment" }),
181
+ "prod/web-newname": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
182
+ },
183
+ observedThen: { "prod/web-oldname": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }) },
184
+ });
185
+ expect(result.disappeared).toEqual([]);
186
+ expect(result.runtimeChildren).toEqual([
187
+ { name: "prod/web-newname", type: "K8s::Core::Pod", owner: "web" },
188
+ ]);
189
+ });
190
+
191
+ test("a runtime child's own status change between snapshots is not driftedSinceSnapshot", () => {
192
+ const podThen = meta({ type: "K8s::Core::Pod", status: "PROGRESSING", ownerChain: { root: "declared", entity: "web" } });
193
+ const podNow = meta({ type: "K8s::Core::Pod", status: "READY", ownerChain: { root: "declared", entity: "web" } });
194
+ const result = diffLive({
195
+ declared: new Set(["web"]),
196
+ observedNow: { web: meta({ type: "K8s::Apps::Deployment" }), "prod/web-stable-0": podNow },
197
+ observedThen: { "prod/web-stable-0": podThen },
198
+ });
199
+ expect(result.driftedSinceSnapshot).toEqual([]);
200
+ expect(result.unchanged).not.toContain("prod/web-stable-0");
201
+ expect(result.runtimeChildren.map((r) => r.name)).toEqual(["prod/web-stable-0"]);
202
+ });
203
+ });
204
+
117
205
  // ── The observation tri-state (#1089) ─────────────────────────────────────
118
206
 
119
207
  test("declared and not observed → unobserved, not missing", () => {
@@ -36,6 +36,19 @@ export interface UnobservedResource {
36
36
  detail?: string;
37
37
  }
38
38
 
39
+ /**
40
+ * A live, undeclared resource whose owner-reference chain reaches a declared
41
+ * entity (#1077) — a Pod a declared Deployment's controller created, for
42
+ * instance. Reported separately from `orphan`: it is expected runtime, not a
43
+ * delete/adopt candidate, and is never counted as drift.
44
+ */
45
+ export interface RuntimeChildResource {
46
+ name: string;
47
+ type: string;
48
+ /** The declared chant entity this resource's owner chain resolves to. */
49
+ owner: string;
50
+ }
51
+
39
52
  export interface LiveDiffResult {
40
53
  /**
41
54
  * Declared in current build, and the provider reported it absent. Entities
@@ -43,8 +56,20 @@ export interface LiveDiffResult {
43
56
  * (#1089), so "missing" keeps meaning "confirmed not there".
44
57
  */
45
58
  missing: string[];
46
- /** Observed in cloud right now, but not declared. */
59
+ /**
60
+ * Observed in cloud right now, not declared, and either carries no owner
61
+ * chain, or the chain does not reach a declared entity (unowned, foreign,
62
+ * or unresolvable — #1077 never escalates an incomplete chain read to
63
+ * `runtimeChildren`). A resource whose chain *does* reach a declared entity
64
+ * is in `runtimeChildren` instead.
65
+ */
47
66
  orphan: string[];
67
+ /**
68
+ * Observed in cloud right now, not declared, whose owner-reference chain
69
+ * reaches a declared entity (#1077) — expected runtime, not drift. Never a
70
+ * delete/adopt candidate; excluded from `orphan` and from drift counts.
71
+ */
72
+ runtimeChildren: RuntimeChildResource[];
48
73
  /** Was in last snapshot but isn't observed now. */
49
74
  disappeared: string[];
50
75
  /** Observed now and declared, but not in the previous snapshot. */
@@ -169,6 +194,7 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
169
194
 
170
195
  const missing: string[] = [];
171
196
  const orphan: string[] = [];
197
+ const runtimeChildren: RuntimeChildResource[] = [];
172
198
  const disappeared: string[] = [];
173
199
  const newlyObserved: string[] = [];
174
200
  const driftedSinceSnapshot: ResourceDrift[] = [];
@@ -194,23 +220,43 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
194
220
  }
195
221
  }
196
222
 
197
- // In cloud right now but not declared → orphan
223
+ // In cloud right now but not declared → orphan, unless its owner-reference
224
+ // chain reaches a declared entity (#1077), in which case it is expected
225
+ // runtime rather than drift. An `unknown` chain (unreadable hop, cycle, or
226
+ // depth bound) is deliberately NOT escalated to runtime — it stays orphan,
227
+ // same as `unowned`/`foreign` — composing with #1168's tri-state precedent:
228
+ // an incomplete read never earns the more confident classification.
229
+ const runtimeChildNames = new Set<string>();
198
230
  for (const name of observedNowNames) {
199
- if (!declared.has(name)) {
231
+ if (declared.has(name)) continue;
232
+ const chain = observedNow[name]?.ownerChain;
233
+ if (chain?.root === "declared") {
234
+ runtimeChildNames.add(name);
235
+ runtimeChildren.push({ name, type: observedNow[name].type, owner: chain.entity });
236
+ } else {
200
237
  orphan.push(name);
201
238
  }
202
239
  }
203
240
 
204
241
  // In previous snapshot but not observed now → disappeared. An entity nobody
205
- // could look at has not disappeared; it is unobserved.
242
+ // could look at has not disappeared; it is unobserved. A resource the
243
+ // *previous* snapshot recorded as a runtime child (#1077) rolling to a new
244
+ // name (a Pod replaced by its controller) is not disappearance either — it
245
+ // is the same expected churn `runtimeChildren` excludes above, and counting
246
+ // it here would recreate the drift noise this module exists to remove.
206
247
  for (const name of observedThenNames) {
207
- if (!observedNowNames.has(name) && !unobservedNames.has(name)) {
208
- disappeared.push(name);
209
- }
248
+ if (observedNowNames.has(name) || unobservedNames.has(name)) continue;
249
+ if (!declared.has(name) && observedThenMap[name]?.ownerChain?.root === "declared") continue;
250
+ disappeared.push(name);
210
251
  }
211
252
 
212
- // Observed now: classify drift relative to previous snapshot
253
+ // Observed now: classify drift relative to previous snapshot. Runtime
254
+ // children (#1077) are excluded entirely — a controller-owned object's
255
+ // transient status is not drift chant should surface, and a snapshot that
256
+ // happened to record the same name (e.g. a StatefulSet's stable pod
257
+ // identity) must not turn its ordinary churn into `driftedSinceSnapshot`.
213
258
  for (const name of observedNowNames) {
259
+ if (runtimeChildNames.has(name)) continue;
214
260
  const now = observedNow[name];
215
261
  const then = observedThenMap[name];
216
262
  if (!then) {
@@ -235,6 +281,7 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
235
281
  return {
236
282
  missing: missing.sort(),
237
283
  orphan: orphan.sort(),
284
+ runtimeChildren: runtimeChildren.sort((a, b) => a.name.localeCompare(b.name)),
238
285
  disappeared: disappeared.sort(),
239
286
  newlyObserved: newlyObserved.sort(),
240
287
  driftedSinceSnapshot: driftedSinceSnapshot.sort((a, b) => a.name.localeCompare(b.name)),
@@ -0,0 +1,179 @@
1
+ import { describe, test, expect } from "vitest";
2
+ import {
3
+ K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS,
4
+ k8sListMapOrderKey,
5
+ buildOwnershipSets,
6
+ pruneByOwnership,
7
+ type OwnershipSets,
8
+ } from "./managed-fields";
9
+ import { normalizeDeepProperties, type DeepNode } from "./deep-observation";
10
+
11
+ /** The naming scheme both the k8s lexicon (via `@intentius/chant-k8s-client`'s `isChantFieldManager`) and gcp restate use in their own tests. */
12
+ function isChantManager(manager: string | undefined): boolean {
13
+ return !!manager && (manager === "chant" || manager.startsWith("chant:"));
14
+ }
15
+
16
+ function node(partial: Partial<DeepNode> & Pick<DeepNode, "path" | "pattern">): DeepNode {
17
+ return {
18
+ entityType: "Test::Entity",
19
+ key: partial.pattern,
20
+ value: undefined,
21
+ side: "live",
22
+ counterpart: "unknown",
23
+ ...partial,
24
+ };
25
+ }
26
+
27
+ describe("K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS — the generic Kubernetes object envelope", () => {
28
+ test("covers status and the server-minted metadata fields", () => {
29
+ for (const p of [
30
+ "status",
31
+ "metadata.uid",
32
+ "metadata.resourceVersion",
33
+ "metadata.generation",
34
+ "metadata.creationTimestamp",
35
+ "metadata.managedFields",
36
+ "metadata.selfLink",
37
+ ]) {
38
+ expect(K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS.has(p)).toBe(true);
39
+ }
40
+ });
41
+
42
+ test("does not cover a declared field with a similar name", () => {
43
+ expect(K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS.has("metadata.labels")).toBe(false);
44
+ expect(K8S_OBJECT_ENVELOPE_PRUNE_PATTERNS.has("spec.status")).toBe(false);
45
+ });
46
+ });
47
+
48
+ describe("k8sListMapOrderKey — Kubernetes' own list-map-key conventions", () => {
49
+ test("orders containers, env and volumes by name", () => {
50
+ const out = normalizeDeepProperties(
51
+ {
52
+ containers: [{ name: "sidecar" }, { name: "app" }],
53
+ env: [{ name: "Z" }, { name: "A" }],
54
+ },
55
+ { entityType: "Any::Type", side: "live", hooks: { orderKey: k8sListMapOrderKey } },
56
+ );
57
+ expect((out.containers as Array<{ name: string }>).map((c) => c.name)).toEqual(["app", "sidecar"]);
58
+ expect((out.env as Array<{ name: string }>).map((e) => e.name)).toEqual(["A", "Z"]);
59
+ });
60
+
61
+ test("orders container ports by containerPort+protocol and service ports by port+protocol", () => {
62
+ const containerPorts = normalizeDeepProperties(
63
+ { ports: [{ containerPort: 9090, protocol: "TCP" }, { containerPort: 8080, protocol: "TCP" }] },
64
+ { entityType: "Any::Type", side: "live", hooks: { orderKey: k8sListMapOrderKey } },
65
+ );
66
+ expect((containerPorts.ports as Array<{ containerPort: number }>).map((p) => p.containerPort)).toEqual([8080, 9090]);
67
+
68
+ const servicePorts = normalizeDeepProperties(
69
+ { ports: [{ port: 443, protocol: "TCP" }, { port: 80, protocol: "TCP" }] },
70
+ { entityType: "Any::Type", side: "live", hooks: { orderKey: k8sListMapOrderKey } },
71
+ );
72
+ expect((servicePorts.ports as Array<{ port: number }>).map((p) => p.port)).toEqual([80, 443]);
73
+ });
74
+
75
+ test("leaves an unrecognized array's order alone", () => {
76
+ expect(k8sListMapOrderKey({ entityType: "Any", path: "widgets", pattern: "widgets", element: { z: 1 }, index: 0, side: "live" })).toBeUndefined();
77
+ });
78
+ });
79
+
80
+ describe("buildOwnershipSets — resolving managedFields against live and declared trees", () => {
81
+ test("a scalar owned by chant is chant-owned regardless of the declared tree", () => {
82
+ const sets = buildOwnershipSets(
83
+ [{ manager: "chant:web", operation: "Apply", fieldsV1: { "f:spec": { "f:replicas": {} } } }],
84
+ { spec: { replicas: 3 } },
85
+ {},
86
+ isChantManager,
87
+ );
88
+ expect(sets.chantOwned.has("spec.replicas")).toBe(true);
89
+ expect(sets.foreignOwned.has("spec.replicas")).toBe(false);
90
+ });
91
+
92
+ test("a scalar owned by a foreign manager and undeclared is foreign-owned, not contested", () => {
93
+ const sets = buildOwnershipSets(
94
+ [{ manager: "kube-controller-manager", operation: "Update", fieldsV1: { "f:spec": { "f:replicas": {} } } }],
95
+ { spec: { replicas: 7 } },
96
+ { spec: {} },
97
+ isChantManager,
98
+ );
99
+ expect(sets.foreignOwned.has("spec.replicas")).toBe(true);
100
+ expect(sets.foreignContested.has("spec.replicas")).toBe(false);
101
+ });
102
+
103
+ test("a scalar owned by a foreign manager AND declared is contested", () => {
104
+ const sets = buildOwnershipSets(
105
+ [{ manager: "kubectl-client-side-apply", operation: "Update", fieldsV1: { "f:spec": { "f:replicas": {} } } }],
106
+ { spec: { replicas: 9 } },
107
+ { spec: { replicas: 5 } },
108
+ isChantManager,
109
+ );
110
+ expect(sets.foreignOwned.has("spec.replicas")).toBe(true);
111
+ expect(sets.foreignContested.has("spec.replicas")).toBe(true);
112
+ });
113
+
114
+ test("a keyed list item resolves to its live index, independent of position in the declared array", () => {
115
+ const sets = buildOwnershipSets(
116
+ [
117
+ {
118
+ manager: "istio-sidecar-injector",
119
+ operation: "Update",
120
+ fieldsV1: { "f:spec": { "f:containers": { 'k:{"name":"istio-proxy"}': { ".": {}, "f:name": {} } } } },
121
+ },
122
+ ],
123
+ { spec: { containers: [{ name: "istio-proxy" }, { name: "app" }] } },
124
+ { spec: { containers: [{ name: "app" }] } },
125
+ isChantManager,
126
+ );
127
+ expect(sets.foreignOwned.has("spec.containers[0]")).toBe(true);
128
+ expect(sets.foreignContested.has("spec.containers[0]")).toBe(false);
129
+ });
130
+
131
+ test("a subresource entry (status) is excluded", () => {
132
+ const sets = buildOwnershipSets(
133
+ [{ manager: "kube-controller-manager", operation: "Update", subresource: "status", fieldsV1: { "f:status": { "f:readyReplicas": {} } } }],
134
+ { status: { readyReplicas: 3 } },
135
+ {},
136
+ isChantManager,
137
+ );
138
+ expect(sets.foreignOwned.size).toBe(0);
139
+ });
140
+
141
+ test("an entry with no manager name is skipped", () => {
142
+ const sets = buildOwnershipSets(
143
+ [{ operation: "Update", fieldsV1: { "f:spec": {} } }],
144
+ { spec: {} },
145
+ {},
146
+ isChantManager,
147
+ );
148
+ expect(sets.chantOwned.size).toBe(0);
149
+ expect(sets.foreignOwned.size).toBe(0);
150
+ });
151
+ });
152
+
153
+ describe("pruneByOwnership — the shared three-question rule", () => {
154
+ const sets: OwnershipSets = {
155
+ chantOwned: new Set(["metadata.labels.tier"]),
156
+ foreignOwned: new Set(["spec.replicas", "metadata.annotations.noise"]),
157
+ foreignContested: new Set(["spec.replicas"]),
158
+ };
159
+
160
+ test("never prunes the declared side", () => {
161
+ expect(pruneByOwnership(node({ path: "spec.replicas", pattern: "spec.replicas", side: "declared" }), sets)).toBe(false);
162
+ });
163
+
164
+ test("never prunes a chant-owned path", () => {
165
+ expect(pruneByOwnership(node({ path: "metadata.labels.tier", pattern: "metadata.labels.tier" }), sets)).toBe(false);
166
+ });
167
+
168
+ test("prunes a foreign-owned, uncontested (undeclared) path", () => {
169
+ expect(pruneByOwnership(node({ path: "metadata.annotations.noise", pattern: "metadata.annotations.noise" }), sets)).toBe(true);
170
+ });
171
+
172
+ test("keeps a foreign-owned, contested (declared) path", () => {
173
+ expect(pruneByOwnership(node({ path: "spec.replicas", pattern: "spec.replicas" }), sets)).toBe(false);
174
+ });
175
+
176
+ test("leaves a path with no ownership information alone (never pruned by this rule)", () => {
177
+ expect(pruneByOwnership(node({ path: "spec.selector", pattern: "spec.selector" }), sets)).toBe(false);
178
+ });
179
+ });