@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
|
@@ -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)),
|
|
@@ -1,9 +1,25 @@
|
|
|
1
|
-
import { describe, it, expect } from "vitest";
|
|
1
|
+
import { describe, it, expect, vi } from "vitest";
|
|
2
2
|
import { observeResources } from "./observe";
|
|
3
3
|
import { observation } from "../observation";
|
|
4
4
|
import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
|
|
5
5
|
import type { BuildResult } from "../build";
|
|
6
6
|
|
|
7
|
+
// The per-stack scoped build (#1162) resolves each stack's `src` through the
|
|
8
|
+
// real `build`. Mock it so the test controls what each src yields — a stack
|
|
9
|
+
// whose src is scoped reports BARE entity names, matching the deployed
|
|
10
|
+
// LogicalResourceIds, not the whole-project build's disambiguated names.
|
|
11
|
+
const scopedBuilds: Record<string, string[]> = {};
|
|
12
|
+
vi.mock("../build", () => ({
|
|
13
|
+
build: async (src: string): Promise<BuildResult> => {
|
|
14
|
+
const names = scopedBuilds[src] ?? [];
|
|
15
|
+
return {
|
|
16
|
+
outputs: new Map<string, string>([["aws", "{}"]]),
|
|
17
|
+
entities: new Map(names.map((n) => [n, { lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} }])),
|
|
18
|
+
errors: [],
|
|
19
|
+
} as unknown as BuildResult;
|
|
20
|
+
},
|
|
21
|
+
}));
|
|
22
|
+
|
|
7
23
|
function mockBuild(): BuildResult {
|
|
8
24
|
return {
|
|
9
25
|
outputs: new Map<string, string>([["aws", "{}"]]),
|
|
@@ -154,7 +170,11 @@ describe("observeResources", () => {
|
|
|
154
170
|
awsPlugin((opts) => {
|
|
155
171
|
const stack = (opts as { stack?: string }).stack;
|
|
156
172
|
calls.push(stack);
|
|
157
|
-
// Different resources per stack — the multi-stack, per-component case
|
|
173
|
+
// Different resources per stack — the multi-stack, per-component case
|
|
174
|
+
// (#57 loomster). Bare-string stacks keep BARE ids (no `src` scope), so
|
|
175
|
+
// the union is `db-a`+`db-b`, not stack-qualified: per-component ids are
|
|
176
|
+
// already unique and behold reads them bare. Qualification is a scoped
|
|
177
|
+
// (`src`) feature — see the per-stack src test below.
|
|
158
178
|
const resources: Record<string, ResourceMetadata> =
|
|
159
179
|
stack === "s1"
|
|
160
180
|
? { "db-a": { type: "AWS::RDS::DBInstance", status: "AVAILABLE" } }
|
|
@@ -169,6 +189,50 @@ describe("observeResources", () => {
|
|
|
169
189
|
expect(Object.keys(observations[0].resources).sort()).toEqual(["db-a", "db-b"]);
|
|
170
190
|
});
|
|
171
191
|
|
|
192
|
+
it("with per-stack src (#1162): describeResources gets each stack's SCOPED bare names, not the whole-project build's names", async () => {
|
|
193
|
+
const { resolve } = await import("node:path");
|
|
194
|
+
// The whole-project build disambiguates colliding names by module path;
|
|
195
|
+
// each stack's scoped src reports the bare names it actually deploys.
|
|
196
|
+
scopedBuilds[resolve("east/src")] = ["server", "vpc"];
|
|
197
|
+
scopedBuilds[resolve("west/src")] = ["server", "vpc"];
|
|
198
|
+
|
|
199
|
+
const seen: Record<string, string[]> = {};
|
|
200
|
+
const plugins = [
|
|
201
|
+
awsPlugin((opts) => {
|
|
202
|
+
const o = opts as { stack?: string; entityNames: string[] };
|
|
203
|
+
seen[o.stack ?? "?"] = o.entityNames;
|
|
204
|
+
// Echo one resource keyed by a bare name the deployed stack owns.
|
|
205
|
+
return { server: { type: "AWS::EC2::Instance", status: "AVAILABLE", physicalId: `i-${o.stack}` } };
|
|
206
|
+
}),
|
|
207
|
+
];
|
|
208
|
+
// The whole-project buildResult carries DISAMBIGUATED names — proving the
|
|
209
|
+
// scoped path overrides them rather than falling through to these.
|
|
210
|
+
const wholeProject = {
|
|
211
|
+
outputs: new Map<string, string>([["aws", "{}"]]),
|
|
212
|
+
entities: new Map([
|
|
213
|
+
["EastServer", { lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} }],
|
|
214
|
+
["WestServer", { lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} }],
|
|
215
|
+
]),
|
|
216
|
+
errors: [],
|
|
217
|
+
} as unknown as BuildResult;
|
|
218
|
+
|
|
219
|
+
const { observations } = await observeResources("floci", plugins, wholeProject, {
|
|
220
|
+
stacks: [
|
|
221
|
+
{ name: "east", src: "east/src" },
|
|
222
|
+
{ name: "west", src: "west/src" },
|
|
223
|
+
],
|
|
224
|
+
});
|
|
225
|
+
|
|
226
|
+
// Each stack saw its own scoped bare names (matching deployed ids).
|
|
227
|
+
expect(seen["east"]).toEqual(["server", "vpc"]);
|
|
228
|
+
expect(seen["west"]).toEqual(["server", "vpc"]);
|
|
229
|
+
// Observed nodes are stack-qualified, so the colliding `server` id is
|
|
230
|
+
// distinct per stack and each carries its own physical id.
|
|
231
|
+
expect(Object.keys(observations[0].resources).sort()).toEqual(["east::server", "west::server"]);
|
|
232
|
+
expect(observations[0].resources["east::server"].physicalId).toBe("i-east");
|
|
233
|
+
expect(observations[0].resources["west::server"].physicalId).toBe("i-west");
|
|
234
|
+
});
|
|
235
|
+
|
|
172
236
|
it("with an empty stacks array — falls back to the single unstacked call", async () => {
|
|
173
237
|
const calls: Array<{ stack?: string }> = [];
|
|
174
238
|
const plugins = [
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -11,6 +11,8 @@
|
|
|
11
11
|
*/
|
|
12
12
|
import type { ObservationLexicon } from "../lexicon";
|
|
13
13
|
import type { BuildResult } from "../build";
|
|
14
|
+
import { build as buildProject } from "../build";
|
|
15
|
+
import { resolve as resolvePath } from "node:path";
|
|
14
16
|
import type { SerializerResult } from "../serializer";
|
|
15
17
|
import type { LiveObservation } from "../graph-ir";
|
|
16
18
|
import {
|
|
@@ -28,6 +30,19 @@ export interface ObserveResult {
|
|
|
28
30
|
errors: string[];
|
|
29
31
|
}
|
|
30
32
|
|
|
33
|
+
/**
|
|
34
|
+
* Re-key a normalized observation's entities by `${stack}::${id}` (#1162) so a
|
|
35
|
+
* bare LogicalResourceId shared across stacks (e.g. `vpc`) stays unambiguous
|
|
36
|
+
* once the per-stack results are merged. The declared canvas qualifies the same
|
|
37
|
+
* way (`buildDeclaredPerStack`), so the overlay join lines up. Applies to both
|
|
38
|
+
* the OBSERVED-PRESENT and NOT-OBSERVED maps of the tri-state (#1089).
|
|
39
|
+
*/
|
|
40
|
+
function qualifyObservation(obs: NormalizedObservation, stackName: string): NormalizedObservation {
|
|
41
|
+
const q = <T>(m: Record<string, T>): Record<string, T> =>
|
|
42
|
+
Object.fromEntries(Object.entries(m).map(([k, v]) => [`${stackName}::${k}`, v]));
|
|
43
|
+
return { resources: q(obs.resources), unobserved: q(obs.unobserved) };
|
|
44
|
+
}
|
|
45
|
+
|
|
31
46
|
/**
|
|
32
47
|
* Query every plugin that implements `describeResources` for its resources in
|
|
33
48
|
* `environment`. `owned` (default true for the managed-only diagram, epic #776)
|
|
@@ -44,20 +59,39 @@ export interface ObserveResult {
|
|
|
44
59
|
* absent an explicit `stack`) queries a stack that simply doesn't exist there,
|
|
45
60
|
* so the single-call path always observes zero nodes. When `stacks` is
|
|
46
61
|
* present and non-empty, each observing plugin's `describeResources` is
|
|
47
|
-
* called once per stack
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
51
|
-
*
|
|
62
|
+
* called once per stack and the returned observations are merged. A stack entry
|
|
63
|
+
* may be a bare name or `{ name, region?, src? }` (#1162): `src` is built
|
|
64
|
+
* SCOPED so the deployed BARE LogicalResourceIds match (the whole-project build
|
|
65
|
+
* disambiguates colliding names to `UsWest1Src…`, which the live ids never
|
|
66
|
+
* carry), and a scoped stack's observed ids are qualified `${stack}::${id}` so
|
|
67
|
+
* the same bare id in two stacks stays distinct. A bare-string stack keeps its
|
|
68
|
+
* bare ids and the tri-state merge (#57). When `stacks` is absent or empty, behavior is
|
|
69
|
+
* exactly the single call of before (no `stack` key at all), so a single-stack
|
|
70
|
+
* project is unaffected.
|
|
52
71
|
*/
|
|
53
72
|
export async function observeResources(
|
|
54
73
|
environment: string,
|
|
55
74
|
plugins: ObservationLexicon[],
|
|
56
75
|
buildResult: BuildResult,
|
|
57
|
-
opts?: { owned?: boolean; stacks?: string
|
|
76
|
+
opts?: { owned?: boolean; stacks?: Array<string | { name: string; region?: string; src?: string }> },
|
|
58
77
|
): Promise<ObserveResult> {
|
|
59
78
|
const owned = opts?.owned ?? true;
|
|
60
|
-
const stacks = opts?.stacks ?? [];
|
|
79
|
+
const stacks = (opts?.stacks ?? []).map((st) => (typeof st === "string" ? { name: st } : st));
|
|
80
|
+
// A stack's `src` (multi-stack, #1162) is built SCOPED to recover that stack's
|
|
81
|
+
// BARE entity names — the names it actually deploys. Matching deployed bare
|
|
82
|
+
// LogicalResourceIds against the whole-project build's DISAMBIGUATED names
|
|
83
|
+
// (UsWest1Src…) misses every colliding resource. Cached per src.
|
|
84
|
+
const serializers = plugins.map((p) => p.serializer);
|
|
85
|
+
const scopedBuildCache = new Map<string, BuildResult>();
|
|
86
|
+
const scopedBuild = async (src: string): Promise<BuildResult> => {
|
|
87
|
+
const key = resolvePath(src);
|
|
88
|
+
let r = scopedBuildCache.get(key);
|
|
89
|
+
if (!r) {
|
|
90
|
+
r = await buildProject(key, serializers);
|
|
91
|
+
scopedBuildCache.set(key, r);
|
|
92
|
+
}
|
|
93
|
+
return r;
|
|
94
|
+
};
|
|
61
95
|
const observations: LiveObservation[] = [];
|
|
62
96
|
const warnings: string[] = [];
|
|
63
97
|
const errors: string[] = [];
|
|
@@ -91,18 +125,45 @@ export async function observeResources(
|
|
|
91
125
|
if (stacks.length > 0) {
|
|
92
126
|
const parts: NormalizedObservation[] = [];
|
|
93
127
|
for (const stack of stacks) {
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
128
|
+
// Use this stack's scoped build (bare entity names) when it has a src,
|
|
129
|
+
// so describeResources matches the deployed bare LogicalResourceIds.
|
|
130
|
+
let stackEntityNames = entityNames;
|
|
131
|
+
let stackBuildOutput = buildOutput;
|
|
132
|
+
let stackEntities = entities;
|
|
133
|
+
if (stack.src) {
|
|
134
|
+
const sb = await scopedBuild(stack.src);
|
|
135
|
+
stackEntityNames = [];
|
|
136
|
+
stackEntities = new Map();
|
|
137
|
+
for (const [name, entity] of sb.entities) {
|
|
138
|
+
if (entity.lexicon !== plugin.name) continue;
|
|
139
|
+
stackEntityNames.push(name);
|
|
140
|
+
stackEntities.set(name, {
|
|
141
|
+
entityType: entity.entityType,
|
|
142
|
+
props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
|
|
143
|
+
});
|
|
144
|
+
}
|
|
145
|
+
const raw = sb.outputs.get(plugin.name);
|
|
146
|
+
stackBuildOutput = raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary;
|
|
147
|
+
}
|
|
148
|
+
const norm = normalizeObservation(
|
|
149
|
+
await plugin.describeResources({
|
|
150
|
+
environment,
|
|
151
|
+
buildOutput: stackBuildOutput,
|
|
152
|
+
entityNames: stackEntityNames,
|
|
153
|
+
entities: stackEntities,
|
|
154
|
+
owned,
|
|
155
|
+
stack: stack.name,
|
|
156
|
+
region: stack.region,
|
|
157
|
+
}),
|
|
105
158
|
);
|
|
159
|
+
// Qualify ids by stack ONLY for a scoped (`src`) stack (#1162): that
|
|
160
|
+
// is the multi-region case where the SAME bare LogicalResourceId
|
|
161
|
+
// (e.g. `vpc`) exists in every stack, so a bare union would collide.
|
|
162
|
+
// A bare-string stack (#57 loomster) has unique per-component ids and
|
|
163
|
+
// is asked the whole-project entity set, so it keeps the bare-id
|
|
164
|
+
// tri-state merge (present > not-observed > absent) that behold and
|
|
165
|
+
// other consumers read.
|
|
166
|
+
parts.push(stack.src ? qualifyObservation(norm, stack.name) : norm);
|
|
106
167
|
}
|
|
107
168
|
observed = mergeObservations(parts);
|
|
108
169
|
} else {
|
|
@@ -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
|
+
});
|