@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.
- 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/lifecycle.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/codegen/generate.d.ts +16 -0
- package/dist/codegen/generate.d.ts.map +1 -1
- 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/kubectl-context.d.ts +18 -1
- package/dist/kubectl-context.d.ts.map +1 -1
- package/dist/lexicon.d.ts +40 -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/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/lifecycle.ts +5 -0
- package/src/cli/main.ts +107 -27
- package/src/codegen/generate.ts +25 -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/kubectl-context.ts +22 -2
- package/src/lexicon.ts +44 -0
- 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/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
|
@@ -106,6 +106,77 @@ describe("buildChangeSet (#118)", () => {
|
|
|
106
106
|
expect(cs.entries.filter((e) => e.action === "adopt").map((e) => e.name)).toEqual(["b", "c"]);
|
|
107
107
|
});
|
|
108
108
|
|
|
109
|
+
// ── Owner-reference chain classification (#1077) ──────────────────────────
|
|
110
|
+
|
|
111
|
+
test("undeclared, owner chain reaches a declared entity → runtime, never delete or adopt", () => {
|
|
112
|
+
const cs = buildChangeSet("prod", {
|
|
113
|
+
declared: new Set(["web"]),
|
|
114
|
+
observedNow: {
|
|
115
|
+
web: meta({ type: "K8s::Apps::Deployment" }),
|
|
116
|
+
"prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
|
|
117
|
+
},
|
|
118
|
+
observedThen: undefined,
|
|
119
|
+
});
|
|
120
|
+
const e = cs.entries.find((x) => x.name === "prod/web-abc")!;
|
|
121
|
+
expect(e.action).toBe("runtime");
|
|
122
|
+
expect(e.runtimeOwner).toBe("web");
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
test("a runtime child that also carries chant's own ownership marker is still `runtime`, never `delete`", () => {
|
|
126
|
+
// Guards the ordering in buildChangeSet: runtimeOwner must be checked
|
|
127
|
+
// before the ownership marker, in case a runtime child ever inherits the
|
|
128
|
+
// marker (e.g. label propagation from its owner's pod template).
|
|
129
|
+
const cs = buildChangeSet("prod", {
|
|
130
|
+
declared: new Set(),
|
|
131
|
+
observedNow: {
|
|
132
|
+
"prod/web-abc": meta({ ownership: "owned", ownerChain: { root: "declared", entity: "web" } }),
|
|
133
|
+
},
|
|
134
|
+
observedThen: undefined,
|
|
135
|
+
});
|
|
136
|
+
const e = cs.entries.find((x) => x.name === "prod/web-abc")!;
|
|
137
|
+
expect(e.action).toBe("runtime");
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
test("undeclared, unowned → orphan/adopt, not runtime", () => {
|
|
141
|
+
const cs = buildChangeSet("prod", {
|
|
142
|
+
declared: new Set(),
|
|
143
|
+
observedNow: { "prod/standalone": meta({ ownerChain: { root: "unowned" } }) },
|
|
144
|
+
observedThen: undefined,
|
|
145
|
+
});
|
|
146
|
+
const e = cs.entries.find((x) => x.name === "prod/standalone")!;
|
|
147
|
+
expect(e.action).toBe("adopt");
|
|
148
|
+
expect(e.runtimeOwner).toBeUndefined();
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("undeclared, foreign root → orphan/adopt, not runtime", () => {
|
|
152
|
+
const cs = buildChangeSet("prod", {
|
|
153
|
+
declared: new Set(),
|
|
154
|
+
observedNow: { "prod/other": meta({ ownerChain: { root: "foreign" } }) },
|
|
155
|
+
observedThen: undefined,
|
|
156
|
+
});
|
|
157
|
+
expect(cs.entries.find((x) => x.name === "prod/other")!.action).toBe("adopt");
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
test("undeclared, unresolved chain (unreadable/cycle/depth) → conservative adopt, not runtime", () => {
|
|
161
|
+
const cs = buildChangeSet("prod", {
|
|
162
|
+
declared: new Set(),
|
|
163
|
+
observedNow: { "prod/mystery": meta({ ownerChain: { root: "unknown" } }) },
|
|
164
|
+
observedThen: undefined,
|
|
165
|
+
});
|
|
166
|
+
const e = cs.entries.find((x) => x.name === "prod/mystery")!;
|
|
167
|
+
expect(e.action).toBe("adopt");
|
|
168
|
+
expect(e.runtimeOwner).toBeUndefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("a lexicon with no owner chain at all is unaffected — undeclared stays adopt/delete as before", () => {
|
|
172
|
+
const cs = buildChangeSet("prod", {
|
|
173
|
+
declared: new Set(),
|
|
174
|
+
observedNow: { orphan: meta({ ownership: "owned" }) },
|
|
175
|
+
observedThen: undefined,
|
|
176
|
+
});
|
|
177
|
+
expect(cs.entries.find((x) => x.name === "orphan")!.action).toBe("delete");
|
|
178
|
+
});
|
|
179
|
+
|
|
109
180
|
test("only in snapshot (gone now, undeclared) → noop", () => {
|
|
110
181
|
const cs = buildChangeSet("prod", {
|
|
111
182
|
declared: new Set(),
|
|
@@ -148,6 +219,23 @@ describe("summarize / renderChangeSet", () => {
|
|
|
148
219
|
expect(out).toContain("ADOPT:");
|
|
149
220
|
expect(out).toContain("orphan");
|
|
150
221
|
});
|
|
222
|
+
|
|
223
|
+
test("summarize and render surface the runtime action (#1077)", () => {
|
|
224
|
+
const withRuntime = buildChangeSet("prod", {
|
|
225
|
+
declared: new Set(["web"]),
|
|
226
|
+
observedNow: {
|
|
227
|
+
web: meta({ type: "K8s::Apps::Deployment" }),
|
|
228
|
+
"prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
|
|
229
|
+
},
|
|
230
|
+
observedThen: undefined,
|
|
231
|
+
});
|
|
232
|
+
expect(summarize(withRuntime).runtime).toBe(1);
|
|
233
|
+
expect(summarize(withRuntime).adopt).toBe(0);
|
|
234
|
+
const out = renderChangeSet(withRuntime);
|
|
235
|
+
expect(out).toContain("RUNTIME");
|
|
236
|
+
expect(out).toContain("prod/web-abc");
|
|
237
|
+
expect(out).toContain("owned by web");
|
|
238
|
+
});
|
|
151
239
|
});
|
|
152
240
|
|
|
153
241
|
describe("gitlabMrReport (#329)", () => {
|
|
@@ -169,6 +257,18 @@ describe("gitlabMrReport (#329)", () => {
|
|
|
169
257
|
expect(gitlabMrReport(cs)).toEqual({ create: 1, update: 1, delete: 1 });
|
|
170
258
|
});
|
|
171
259
|
|
|
260
|
+
test("a runtime child (#1077) is excluded from the widget — never counted as a change", () => {
|
|
261
|
+
const cs = buildChangeSet("prod", {
|
|
262
|
+
declared: new Set(["web"]),
|
|
263
|
+
observedNow: {
|
|
264
|
+
web: meta({ type: "K8s::Apps::Deployment" }),
|
|
265
|
+
"prod/web-abc": meta({ type: "K8s::Core::Pod", ownerChain: { root: "declared", entity: "web" } }),
|
|
266
|
+
},
|
|
267
|
+
observedThen: undefined,
|
|
268
|
+
});
|
|
269
|
+
expect(gitlabMrReport(cs)).toEqual({ create: 0, update: 0, delete: 0 });
|
|
270
|
+
});
|
|
271
|
+
|
|
172
272
|
test("empty plan reports all zeros", () => {
|
|
173
273
|
const cs = buildChangeSet("prod", {
|
|
174
274
|
declared: new Set(),
|
|
@@ -3,8 +3,8 @@
|
|
|
3
3
|
*
|
|
4
4
|
* `chant lifecycle diff --live` computes a three-way comparison — declared now /
|
|
5
5
|
* last snapshot / live now — and prints it. `buildChangeSet` promotes that
|
|
6
|
-
* same signal into a classified create/update/delete/adopt/noop set
|
|
7
|
-
* tooling (reconcile, apply) can act on.
|
|
6
|
+
* same signal into a classified create/update/delete/adopt/runtime/noop set
|
|
7
|
+
* that other tooling (reconcile, apply) can act on.
|
|
8
8
|
*
|
|
9
9
|
* Strictly read-only and pure: no I/O, no mutation. The classification reads
|
|
10
10
|
* ownership from the live marker only (populated downstream); until ownership
|
|
@@ -25,12 +25,17 @@ import { unobservedReasonText, type UnobservedReason } from "../observation";
|
|
|
25
25
|
* snapshot.
|
|
26
26
|
* - `adopt` — live but undeclared, ownership not established → a candidate to
|
|
27
27
|
* pull back into source, never an auto-delete.
|
|
28
|
+
* - `runtime` — live but undeclared, and its owner-reference chain reaches a
|
|
29
|
+
* declared entity (#1077): a Pod a declared Deployment's controller
|
|
30
|
+
* created, for instance. Never a delete, never an adopt candidate — it is
|
|
31
|
+
* not drift, just the runtime doing its job. `runtimeOwner` names the
|
|
32
|
+
* declared entity it belongs to.
|
|
28
33
|
* - `noop` — declared and live with no drift, or already reconciled.
|
|
29
34
|
* - `unobserved` — declared, and the lexicon could not look (#1089). Not a
|
|
30
35
|
* proposal at all: it is the plan admitting a hole. Never a create, never a
|
|
31
36
|
* delete. Read `unobservedReason` for which hole.
|
|
32
37
|
*/
|
|
33
|
-
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "noop" | "unobserved";
|
|
38
|
+
export type ChangeAction = "create" | "update" | "delete" | "adopt" | "runtime" | "noop" | "unobserved";
|
|
34
39
|
|
|
35
40
|
/**
|
|
36
41
|
* Who answers "is this resource chant's?". `unknown` until a live ownership
|
|
@@ -69,6 +74,8 @@ export interface ChangeSetEntry {
|
|
|
69
74
|
unobservedReason?: UnobservedReason;
|
|
70
75
|
/** Human-readable backing for `unobservedReason` (the failing command, the missing binding). */
|
|
71
76
|
unobservedDetail?: string;
|
|
77
|
+
/** The declared entity this resource's owner chain resolves to, for `action: "runtime"` (#1077). */
|
|
78
|
+
runtimeOwner?: string;
|
|
72
79
|
}
|
|
73
80
|
|
|
74
81
|
export interface ChangeSet {
|
|
@@ -121,6 +128,15 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
121
128
|
// record chant has to host.
|
|
122
129
|
const ownership: Ownership = observedNow[name]?.ownership ?? "unknown";
|
|
123
130
|
|
|
131
|
+
// Owner-reference chain (#1077), same live-only provenance as ownership
|
|
132
|
+
// above. Only a `declared` root changes the classification; `unknown` is
|
|
133
|
+
// deliberately not escalated (#1168's tri-state precedent — an
|
|
134
|
+
// unconfirmed chain never earns the more confident verdict).
|
|
135
|
+
const runtimeOwner =
|
|
136
|
+
!isDeclared && observedNow[name]?.ownerChain?.root === "declared"
|
|
137
|
+
? observedNow[name]!.ownerChain!.entity
|
|
138
|
+
: undefined;
|
|
139
|
+
|
|
124
140
|
let action: ChangeAction;
|
|
125
141
|
let deltas: AttributeChange[] | undefined;
|
|
126
142
|
|
|
@@ -140,6 +156,13 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
140
156
|
} else {
|
|
141
157
|
action = "noop";
|
|
142
158
|
}
|
|
159
|
+
} else if (live && runtimeOwner) {
|
|
160
|
+
// Live, undeclared, and its owner chain reaches a declared entity
|
|
161
|
+
// (#1077) — expected runtime, never a delete/adopt candidate, checked
|
|
162
|
+
// ahead of the ownership marker below: even a runtime child that
|
|
163
|
+
// happens to carry chant's own marker (label propagation from its
|
|
164
|
+
// owner's template) must never be proposed for deletion.
|
|
165
|
+
action = "runtime";
|
|
143
166
|
} else if (live) {
|
|
144
167
|
// Live but undeclared. Only a chant-owned orphan is a safe delete; a
|
|
145
168
|
// foreign or unknown orphan can be adopted but never auto-deleted.
|
|
@@ -162,6 +185,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
162
185
|
...(unobservedEntry.detail ? { unobservedDetail: unobservedEntry.detail } : {}),
|
|
163
186
|
}
|
|
164
187
|
: {}),
|
|
188
|
+
...(runtimeOwner ? { runtimeOwner } : {}),
|
|
165
189
|
});
|
|
166
190
|
}
|
|
167
191
|
|
|
@@ -169,7 +193,7 @@ export function buildChangeSet(env: string, input: DiffLiveInput): ChangeSet {
|
|
|
169
193
|
return { env, entries };
|
|
170
194
|
}
|
|
171
195
|
|
|
172
|
-
const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "noop", "unobserved"];
|
|
196
|
+
const ACTION_ORDER: ChangeAction[] = ["create", "update", "delete", "adopt", "runtime", "noop", "unobserved"];
|
|
173
197
|
|
|
174
198
|
/** Count entries per action. */
|
|
175
199
|
export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
@@ -178,6 +202,7 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
|
178
202
|
update: 0,
|
|
179
203
|
delete: 0,
|
|
180
204
|
adopt: 0,
|
|
205
|
+
runtime: 0,
|
|
181
206
|
noop: 0,
|
|
182
207
|
unobserved: 0,
|
|
183
208
|
};
|
|
@@ -191,10 +216,11 @@ export function summarize(cs: ChangeSet): Record<ChangeAction, number> {
|
|
|
191
216
|
* GitLab renders an `artifacts:reports:terraform` artifact in the merge-request
|
|
192
217
|
* UI as "N to add, M to change, K to delete". The format is generic — any tool
|
|
193
218
|
* that emits this JSON gets the widget — and the chant plan maps onto it
|
|
194
|
-
* directly. Only the mutating actions count: `adopt`, `noop` and
|
|
195
|
-
* are excluded, since the widget has no column for "live but
|
|
196
|
-
*
|
|
197
|
-
*
|
|
219
|
+
* directly. Only the mutating actions count: `adopt`, `runtime`, `noop` and
|
|
220
|
+
* `unobserved` are excluded, since the widget has no column for "live but
|
|
221
|
+
* undeclared", "expected runtime child" (#1077), "no change", or "could not
|
|
222
|
+
* look" (#1089). The widget is therefore a floor, not a complete plan: read
|
|
223
|
+
* the full change set when entities are unobserved or classified runtime.
|
|
198
224
|
*
|
|
199
225
|
* The widget label reads "Terraform" regardless of producer; that is GitLab's
|
|
200
226
|
* fixed string, not a claim chant makes.
|
|
@@ -223,14 +249,17 @@ export function renderChangeSet(cs: ChangeSet): string {
|
|
|
223
249
|
lines.push(
|
|
224
250
|
action === "unobserved"
|
|
225
251
|
? "\nUNOBSERVED (declared; chant could not read live state — no action proposed):"
|
|
226
|
-
:
|
|
252
|
+
: action === "runtime"
|
|
253
|
+
? "\nRUNTIME (owned by a declared resource; not drift, never a delete/adopt candidate):"
|
|
254
|
+
: `\n${action.toUpperCase()}:`,
|
|
227
255
|
);
|
|
228
256
|
for (const e of group) {
|
|
229
257
|
const own = e.ownership === "unknown" ? "" : ` [${e.ownership}]`;
|
|
230
258
|
const why = e.unobservedReason
|
|
231
259
|
? ` — ${unobservedReasonText(e.unobservedReason)}${e.unobservedDetail ? `: ${e.unobservedDetail}` : ""}`
|
|
232
260
|
: "";
|
|
233
|
-
|
|
261
|
+
const owner = e.runtimeOwner ? ` — owned by ${e.runtimeOwner}` : "";
|
|
262
|
+
lines.push(` ${e.name}${e.type ? ` (${e.type})` : ""}${own}${why}${owner}`);
|
|
234
263
|
for (const d of e.deltas ?? []) {
|
|
235
264
|
lines.push(` ${d.path}: ${fmt(d.oldValue)} → ${fmt(d.newValue)}`);
|
|
236
265
|
}
|
|
@@ -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
|
-
/**
|
|
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 (
|
|
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 (
|
|
208
|
-
|
|
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
|
+
});
|