@intentius/chant 0.38.0 → 0.41.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/apply.d.ts +171 -0
- package/dist/apply.d.ts.map +1 -0
- package/dist/build.d.ts +21 -0
- package/dist/build.d.ts.map +1 -1
- package/dist/cli/commands/build.d.ts.map +1 -1
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/commands/doctor.d.ts.map +1 -1
- package/dist/cli/handlers/lifecycle.d.ts.map +1 -1
- package/dist/cli/handlers/run-client.d.ts.map +1 -1
- package/dist/cli/handlers/search.d.ts +30 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/plugins.d.ts +20 -0
- package/dist/cli/plugins.d.ts.map +1 -1
- package/dist/codegen/naming.d.ts +48 -1
- package/dist/codegen/naming.d.ts.map +1 -1
- package/dist/codegen/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- package/dist/codegen/validate.d.ts +31 -0
- package/dist/codegen/validate.d.ts.map +1 -1
- package/dist/components/sandbox/driver.d.ts.map +1 -1
- package/dist/composite.d.ts +23 -4
- package/dist/composite.d.ts.map +1 -1
- package/dist/deep-observation.d.ts +11 -0
- package/dist/deep-observation.d.ts.map +1 -1
- package/dist/discovery/index.d.ts.map +1 -1
- package/dist/discovery/sandbox/driver.d.ts.map +1 -1
- package/dist/graph-declared.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +17 -3
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +24 -0
- package/dist/graph-refs.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.map +1 -1
- package/dist/lexicon-config.d.ts +61 -0
- package/dist/lexicon-config.d.ts.map +1 -0
- package/dist/lexicon.d.ts +19 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-diff.d.ts +11 -0
- package/dist/lifecycle/deep-diff.d.ts.map +1 -1
- package/dist/lifecycle/digest.d.ts.map +1 -1
- package/dist/lifecycle/identity.d.ts +52 -0
- package/dist/lifecycle/identity.d.ts.map +1 -0
- package/dist/lifecycle/observe.d.ts +5 -0
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +30 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +11 -0
- package/dist/managed-fields.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/apply.test.ts +169 -0
- package/src/apply.ts +249 -0
- package/src/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon.ts +20 -1
- package/src/cli/commands/doctor.test.ts +45 -0
- package/src/cli/commands/doctor.ts +40 -0
- package/src/cli/handlers/lifecycle.ts +10 -0
- package/src/cli/handlers/run-client.ts +3 -1
- package/src/cli/handlers/search-kind.test.ts +45 -0
- package/src/cli/handlers/search.ts +102 -4
- package/src/cli/main.ts +32 -10
- package/src/cli/param-flag-scope.test.ts +69 -0
- package/src/cli/plugins.test.ts +33 -1
- package/src/cli/plugins.ts +55 -0
- package/src/codegen/naming.test.ts +129 -0
- package/src/codegen/naming.ts +72 -1
- package/src/codegen/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -0
- package/src/codegen/validate.test.ts +86 -0
- package/src/codegen/validate.ts +74 -0
- package/src/components/SPRAWL-VALIDATION.md +5 -5
- package/src/components/sandbox/driver.test.ts +27 -0
- package/src/components/sandbox/driver.ts +12 -0
- package/src/composite.ts +33 -4
- package/src/deep-observation.ts +11 -0
- package/src/discovery/index.ts +59 -0
- package/src/discovery/params-cjs-warning.test.ts +75 -0
- package/src/discovery/sandbox/driver.test.ts +34 -0
- package/src/discovery/sandbox/driver.ts +19 -0
- package/src/graph-declared.test.ts +86 -0
- package/src/graph-declared.ts +14 -2
- package/src/graph-ir.ts +32 -8
- package/src/graph-refs.test.ts +56 -0
- package/src/graph-refs.ts +37 -1
- package/src/index.ts +1 -0
- package/src/kubectl-context.ts +4 -1
- package/src/lexicon-config.test.ts +111 -0
- package/src/lexicon-config.ts +92 -0
- package/src/lexicon.ts +20 -0
- package/src/lifecycle/deep-diff.test.ts +48 -1
- package/src/lifecycle/deep-diff.ts +16 -0
- package/src/lifecycle/digest.test.ts +81 -0
- package/src/lifecycle/digest.ts +34 -3
- package/src/lifecycle/identity.test.ts +39 -0
- package/src/lifecycle/identity.ts +61 -0
- package/src/lifecycle/observe.test.ts +75 -1
- package/src/lifecycle/observe.ts +28 -2
- package/src/lifecycle/replay.test.ts +251 -0
- package/src/lifecycle/replay.ts +67 -19
- package/src/lifecycle/types.ts +26 -0
- package/src/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
|
@@ -0,0 +1,251 @@
|
|
|
1
|
+
import { describe, it, expect, vi, beforeEach } from "vitest";
|
|
2
|
+
import type { LifecycleSnapshot } from "./types";
|
|
3
|
+
|
|
4
|
+
// `replaySnapshots` reads the orphan branch through this one function, so the
|
|
5
|
+
// stored set is the whole input surface.
|
|
6
|
+
const stored = new Map<string, string>();
|
|
7
|
+
vi.mock("./git", () => ({
|
|
8
|
+
readEnvironmentSnapshots: async (): Promise<Map<string, string>> => stored,
|
|
9
|
+
}));
|
|
10
|
+
|
|
11
|
+
const { replaySnapshots } = await import("./replay");
|
|
12
|
+
|
|
13
|
+
/** One region's recorded stack: an instance it manages, plus ambient resources. */
|
|
14
|
+
function snapshot(
|
|
15
|
+
stack: string,
|
|
16
|
+
region: string,
|
|
17
|
+
opts: {
|
|
18
|
+
ambient?: Record<string, { type: string; physicalId?: string }>;
|
|
19
|
+
managed?: Record<string, string>;
|
|
20
|
+
edges?: Array<{ from: string; to: string; kind: "ref" }>;
|
|
21
|
+
} = {},
|
|
22
|
+
): string {
|
|
23
|
+
const snap: LifecycleSnapshot = {
|
|
24
|
+
lexicon: "aws",
|
|
25
|
+
environment: "prod",
|
|
26
|
+
stack,
|
|
27
|
+
commit: "abc123",
|
|
28
|
+
timestamp: "2026-08-03T00:00:00.000Z",
|
|
29
|
+
resources: {
|
|
30
|
+
...Object.fromEntries(
|
|
31
|
+
Object.entries(opts.managed ?? {}).map(([name, physicalId]) => [
|
|
32
|
+
name,
|
|
33
|
+
{ type: "AWS::EC2::Instance", status: "OBSERVED", physicalId },
|
|
34
|
+
]),
|
|
35
|
+
),
|
|
36
|
+
...Object.fromEntries(
|
|
37
|
+
Object.entries(opts.ambient ?? {}).map(([id, meta]) => [
|
|
38
|
+
id,
|
|
39
|
+
{
|
|
40
|
+
type: meta.type,
|
|
41
|
+
status: "OBSERVED",
|
|
42
|
+
physicalId: meta.physicalId ?? id,
|
|
43
|
+
ambient: true,
|
|
44
|
+
ownership: "foreign" as const,
|
|
45
|
+
// What `stampRegion` puts there on the real path (#1279).
|
|
46
|
+
attributes: { region },
|
|
47
|
+
},
|
|
48
|
+
]),
|
|
49
|
+
),
|
|
50
|
+
},
|
|
51
|
+
...(opts.edges ? { edges: opts.edges } : {}),
|
|
52
|
+
};
|
|
53
|
+
return JSON.stringify(snap);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function keys(observations: Array<{ resources: Record<string, unknown> }>): string[] {
|
|
57
|
+
return observations.flatMap((o) => Object.keys(o.resources)).sort();
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
beforeEach(() => {
|
|
61
|
+
stored.clear();
|
|
62
|
+
});
|
|
63
|
+
|
|
64
|
+
describe("replaySnapshots — ambient identity across regions (#1416)", () => {
|
|
65
|
+
it("keeps each region's copy when the ids collide", async () => {
|
|
66
|
+
// What Floci does (lex00/floci#21): the default VPC and its subnets carry
|
|
67
|
+
// the same id strings in every region. Real AWS ids are globally unique, so
|
|
68
|
+
// this is only observable against the emulator — but the merge is the same
|
|
69
|
+
// merge either way.
|
|
70
|
+
for (const region of ["us-east-1", "us-west-1", "us-west-2"]) {
|
|
71
|
+
stored.set(`${region}__aws`, snapshot(region, region, {
|
|
72
|
+
ambient: {
|
|
73
|
+
"vpc-default": { type: "AWS::EC2::VPC" },
|
|
74
|
+
"subnet-default-a": { type: "AWS::EC2::Subnet" },
|
|
75
|
+
"subnet-default-b": { type: "AWS::EC2::Subnet" },
|
|
76
|
+
},
|
|
77
|
+
}));
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
81
|
+
if ("error" in result) throw new Error(result.error);
|
|
82
|
+
|
|
83
|
+
// Nine ambient resources went in; before this fix three came out.
|
|
84
|
+
expect(keys(result.observations)).toEqual([
|
|
85
|
+
"us-east-1::subnet-default-a",
|
|
86
|
+
"us-east-1::subnet-default-b",
|
|
87
|
+
"us-east-1::vpc-default",
|
|
88
|
+
"us-west-1::subnet-default-a",
|
|
89
|
+
"us-west-1::subnet-default-b",
|
|
90
|
+
"us-west-1::vpc-default",
|
|
91
|
+
"us-west-2::subnet-default-a",
|
|
92
|
+
"us-west-2::subnet-default-b",
|
|
93
|
+
"us-west-2::vpc-default",
|
|
94
|
+
]);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("still merges one region's resource seen from two stacks", async () => {
|
|
98
|
+
// The case the account-level rule was written for: two stacks in the same
|
|
99
|
+
// region each recorded the region's default security group. That is one
|
|
100
|
+
// group, and counting it twice would inflate every count over it.
|
|
101
|
+
stored.set("web__aws", snapshot("web", "us-east-1", {
|
|
102
|
+
ambient: { "sg-default": { type: "AWS::EC2::SecurityGroup" } },
|
|
103
|
+
}));
|
|
104
|
+
stored.set("api__aws", snapshot("api", "us-east-1", {
|
|
105
|
+
ambient: { "sg-default": { type: "AWS::EC2::SecurityGroup" } },
|
|
106
|
+
}));
|
|
107
|
+
|
|
108
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
109
|
+
if ("error" in result) throw new Error(result.error);
|
|
110
|
+
expect(keys(result.observations)).toEqual(["us-east-1::sg-default"]);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it("leaves a single recorded stack's ids exactly as recorded", async () => {
|
|
114
|
+
// One stack is one region: nothing to merge, so nothing to disambiguate.
|
|
115
|
+
// A single-region project's ids must not move.
|
|
116
|
+
stored.set("main__aws", snapshot("main", "us-east-1", {
|
|
117
|
+
ambient: { "sg-default": { type: "AWS::EC2::SecurityGroup" } },
|
|
118
|
+
managed: { web: "i-123" },
|
|
119
|
+
}));
|
|
120
|
+
|
|
121
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
122
|
+
if ("error" in result) throw new Error(result.error);
|
|
123
|
+
expect(keys(result.observations)).toEqual(["sg-default", "web"]);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("does not region-qualify a managed resource", async () => {
|
|
127
|
+
// Managed ids join the declared canvas, which qualifies by stack or not at
|
|
128
|
+
// all. Region-qualifying `web` would unjoin it from its own declaration.
|
|
129
|
+
for (const region of ["us-east-1", "us-west-1"]) {
|
|
130
|
+
stored.set(`${region}__aws`, snapshot(region, region, { managed: { web: `i-${region}` } }));
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const result = await replaySnapshots("prod", "latest", new Set(["us-east-1", "us-west-1"]));
|
|
134
|
+
if ("error" in result) throw new Error(result.error);
|
|
135
|
+
expect(keys(result.observations)).toEqual(["us-east-1::web", "us-west-1::web"]);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("leaves an ambient resource with no recorded region alone", async () => {
|
|
139
|
+
// A resource carrying no region is account-level as far as anything here
|
|
140
|
+
// can tell, and account-level is what the original merge assumed. IAM is
|
|
141
|
+
// the shape: one policy, seen from every region's stack.
|
|
142
|
+
const global = (stack: string): string =>
|
|
143
|
+
JSON.stringify({
|
|
144
|
+
lexicon: "aws",
|
|
145
|
+
environment: "prod",
|
|
146
|
+
stack,
|
|
147
|
+
commit: "abc123",
|
|
148
|
+
timestamp: "2026-08-03T00:00:00.000Z",
|
|
149
|
+
resources: {
|
|
150
|
+
"arn:aws:iam::1:policy/p": {
|
|
151
|
+
type: "AWS::IAM::Policy",
|
|
152
|
+
status: "OBSERVED",
|
|
153
|
+
physicalId: "arn:aws:iam::1:policy/p",
|
|
154
|
+
ambient: true,
|
|
155
|
+
},
|
|
156
|
+
},
|
|
157
|
+
} satisfies LifecycleSnapshot);
|
|
158
|
+
stored.set("us-east-1__aws", global("us-east-1"));
|
|
159
|
+
stored.set("us-west-1__aws", global("us-west-1"));
|
|
160
|
+
|
|
161
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
162
|
+
if ("error" in result) throw new Error(result.error);
|
|
163
|
+
expect(keys(result.observations)).toEqual(["arn:aws:iam::1:policy/p"]);
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
it("re-points edges at the keys the resources ended up under", async () => {
|
|
167
|
+
// An edge naming `subnet-default-a` has to reach the copy in ITS region,
|
|
168
|
+
// not whichever region sorted first. A dangling edge is worse than the
|
|
169
|
+
// collision it replaced: the node is present and simply unreachable.
|
|
170
|
+
for (const region of ["us-east-1", "us-west-1"]) {
|
|
171
|
+
stored.set(`${region}__aws`, snapshot(region, region, {
|
|
172
|
+
ambient: {
|
|
173
|
+
"subnet-default-a": { type: "AWS::EC2::Subnet" },
|
|
174
|
+
"eni-1": { type: "AWS::EC2::NetworkInterface" },
|
|
175
|
+
},
|
|
176
|
+
edges: [{ from: "eni-1", to: "subnet-default-a", kind: "ref" }],
|
|
177
|
+
}));
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
181
|
+
if ("error" in result) throw new Error(result.error);
|
|
182
|
+
const edges = result.observations.flatMap((o) => o.edges ?? []);
|
|
183
|
+
expect(edges).toEqual([
|
|
184
|
+
{ from: "us-east-1::eni-1", to: "us-east-1::subnet-default-a", kind: "ref" },
|
|
185
|
+
{ from: "us-west-1::eni-1", to: "us-west-1::subnet-default-a", kind: "ref" },
|
|
186
|
+
]);
|
|
187
|
+
// Every endpoint resolves to a node that exists.
|
|
188
|
+
const nodes = new Set(keys(result.observations));
|
|
189
|
+
for (const edge of edges) {
|
|
190
|
+
expect(nodes.has(edge.from)).toBe(true);
|
|
191
|
+
expect(nodes.has(edge.to)).toBe(true);
|
|
192
|
+
}
|
|
193
|
+
});
|
|
194
|
+
});
|
|
195
|
+
|
|
196
|
+
describe("a resource recorded twice is one node (#1432 follow-up)", () => {
|
|
197
|
+
// A subnet its stack declares, ALSO recorded as a dependency because
|
|
198
|
+
// instances reference it. Same physical subnet, two entries.
|
|
199
|
+
function snap(stack: string): string {
|
|
200
|
+
return JSON.stringify({
|
|
201
|
+
lexicon: "aws", environment: "prod", stack,
|
|
202
|
+
commit: "abc", timestamp: "2026-08-03T00:00:00.000Z",
|
|
203
|
+
resources: {
|
|
204
|
+
publicSubnet: { type: "AWS::EC2::Subnet", status: "OBSERVED", physicalId: "subnet-9af06b90" },
|
|
205
|
+
"subnet-9af06b90": {
|
|
206
|
+
type: "AWS::EC2::Subnet", status: "OBSERVED", physicalId: "subnet-9af06b90",
|
|
207
|
+
referencedBy: ["webServer"],
|
|
208
|
+
},
|
|
209
|
+
webServer: { type: "AWS::EC2::Instance", status: "OBSERVED", physicalId: "i-1" },
|
|
210
|
+
},
|
|
211
|
+
edges: [{ from: "webServer", to: "subnet-9af06b90", kind: "ref", viaAttr: "SubnetId" }],
|
|
212
|
+
});
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
it("drops the duplicate rather than counting the subnet twice", async () => {
|
|
216
|
+
stored.set("main__aws", snap("main"));
|
|
217
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
218
|
+
if ("error" in result) throw new Error(result.error);
|
|
219
|
+
expect(keys(result.observations)).toEqual(["publicSubnet", "webServer"]);
|
|
220
|
+
});
|
|
221
|
+
|
|
222
|
+
it("re-points the dropped duplicate's edges at the survivor", async () => {
|
|
223
|
+
// Dropping the node alone would take this edge with it: buildLiveGraphIr
|
|
224
|
+
// discards an edge whose endpoints were not both observed, so the instance
|
|
225
|
+
// would stop being in any subnet at all.
|
|
226
|
+
stored.set("main__aws", snap("main"));
|
|
227
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
228
|
+
if ("error" in result) throw new Error(result.error);
|
|
229
|
+
expect(result.observations.flatMap((o) => o.edges ?? [])).toEqual([
|
|
230
|
+
{ from: "webServer", to: "publicSubnet", kind: "ref", viaAttr: "SubnetId" },
|
|
231
|
+
]);
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("keeps a dependency nothing manages", async () => {
|
|
235
|
+
// The account's default route table: referenced, managed by nobody. It is
|
|
236
|
+
// not a duplicate and must survive.
|
|
237
|
+
stored.set("main__aws", JSON.stringify({
|
|
238
|
+
lexicon: "aws", environment: "prod", stack: "main",
|
|
239
|
+
commit: "abc", timestamp: "2026-08-03T00:00:00.000Z",
|
|
240
|
+
resources: {
|
|
241
|
+
"rtb-default": {
|
|
242
|
+
type: "AWS::EC2::RouteTable", status: "OBSERVED", physicalId: "rtb-default",
|
|
243
|
+
referencedBy: ["webServer"],
|
|
244
|
+
},
|
|
245
|
+
},
|
|
246
|
+
}));
|
|
247
|
+
const result = await replaySnapshots("prod", "latest", new Set());
|
|
248
|
+
if ("error" in result) throw new Error(result.error);
|
|
249
|
+
expect(keys(result.observations)).toEqual(["rtb-default"]);
|
|
250
|
+
});
|
|
251
|
+
});
|
package/src/lifecycle/replay.ts
CHANGED
|
@@ -15,6 +15,7 @@
|
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
17
|
import { readEnvironmentSnapshots } from "./git";
|
|
18
|
+
import { unqualifiedKey } from "./identity";
|
|
18
19
|
import type { LiveObservation } from "../graph-ir";
|
|
19
20
|
import type { LifecycleSnapshot } from "./types";
|
|
20
21
|
|
|
@@ -72,16 +73,35 @@ export async function replaySnapshots(
|
|
|
72
73
|
// account-level: the default security group three stacks each recorded is one
|
|
73
74
|
// group, not three. Managed resources are stack-qualified below and cannot
|
|
74
75
|
// collide, so only the unqualified ones need this.
|
|
76
|
+
//
|
|
77
|
+
// "Account-level" is the part that needed qualifying (#1416): a resource that
|
|
78
|
+
// records a region is regional, and two regions' copies are two resources
|
|
79
|
+
// however equal their ids look. `unqualifiedKey` keys those by region, so the
|
|
80
|
+
// set below still collapses one region's resource seen from two stacks and no
|
|
81
|
+
// longer collapses two regions'.
|
|
75
82
|
const seenUnqualified = new Set<string>();
|
|
83
|
+
// One recorded stack is one region, so there is nothing to merge and nothing
|
|
84
|
+
// to disambiguate — its ids stay exactly what they were recorded as, which is
|
|
85
|
+
// also what `chant search --live` gives that project.
|
|
86
|
+
const merging = stored.size > 1;
|
|
76
87
|
// A stack's snapshot could only exclude what THAT stack manages, so a stack
|
|
77
88
|
// declaring no security groups reported the neighbouring stack's as ambient.
|
|
78
89
|
// The union is only knowable here, with every snapshot in hand.
|
|
79
90
|
const managedPhysicalIds = new Set<string>();
|
|
91
|
+
// Where a managed resource ends up, by physical id. A duplicate is dropped
|
|
92
|
+
// below rather than rendered twice, and anything that pointed at the
|
|
93
|
+
// duplicate has to be re-pointed at the survivor — dropping the node alone
|
|
94
|
+
// would take its edges with it, since `buildLiveGraphIr` discards an edge
|
|
95
|
+
// whose endpoints were not both observed.
|
|
96
|
+
const managedKeyOf = new Map<string, string>();
|
|
80
97
|
for (const content of stored.values()) {
|
|
81
98
|
const snap = JSON.parse(content) as LifecycleSnapshot;
|
|
82
|
-
|
|
99
|
+
const snapStack = snap.stack;
|
|
100
|
+
const snapQualifies = snapStack !== undefined && scopedStacks.has(snapStack);
|
|
101
|
+
for (const [id, meta] of Object.entries(snap.resources ?? {})) {
|
|
83
102
|
if (!meta.ambient && !meta.referencedBy?.length && meta.physicalId) {
|
|
84
103
|
managedPhysicalIds.add(meta.physicalId);
|
|
104
|
+
managedKeyOf.set(meta.physicalId, snapQualifies ? `${snapStack}::${id}` : id);
|
|
85
105
|
}
|
|
86
106
|
}
|
|
87
107
|
}
|
|
@@ -97,32 +117,60 @@ export async function replaySnapshots(
|
|
|
97
117
|
// collide, and none of them join the declared canvas, which qualifies.
|
|
98
118
|
const stack = snapshot.stack;
|
|
99
119
|
const qualify = stack !== undefined && scopedStacks.has(stack);
|
|
100
|
-
// Dependencies (#1273) are keyed by physical
|
|
101
|
-
// default VPC's route table is one
|
|
102
|
-
//
|
|
103
|
-
// edges into it.
|
|
104
|
-
const
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
120
|
+
// Dependencies (#1273) and ambient resources (#1278) are keyed by physical
|
|
121
|
+
// id and are NOT stack-qualified: the default VPC's route table is one
|
|
122
|
+
// resource however many stacks route through it, and qualifying it would
|
|
123
|
+
// split it per stack and break the edges into it.
|
|
124
|
+
const unqualifiedByNature = (meta: { referencedBy?: string[]; ambient?: boolean }): boolean =>
|
|
125
|
+
Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0);
|
|
126
|
+
// Region qualification (#1416) applies to exactly those, and never to a
|
|
127
|
+
// managed resource: an unscoped project's declared canvas joins on bare
|
|
128
|
+
// ids, so re-keying `web` to `us-east-1::web` would unjoin it from its own
|
|
129
|
+
// declaration.
|
|
130
|
+
const keyOf = (id: string, meta: { referencedBy?: string[]; ambient?: boolean; attributes?: Record<string, unknown> }): string => {
|
|
131
|
+
if (unqualifiedByNature(meta)) return merging ? unqualifiedKey(id, meta) : id;
|
|
132
|
+
return qualify ? `${stack}::${id}` : id;
|
|
133
|
+
};
|
|
108
134
|
const resources: Record<string, (typeof snapshot.resources)[string]> = {};
|
|
135
|
+
// Duplicates dropped here, and where they went, so edges can follow.
|
|
136
|
+
const merged = new Map<string, string>();
|
|
109
137
|
for (const [id, meta] of Object.entries(snapshot.resources ?? {})) {
|
|
110
|
-
const key =
|
|
111
|
-
if (
|
|
112
|
-
// Ambient means "nothing manages this"
|
|
113
|
-
//
|
|
114
|
-
|
|
115
|
-
//
|
|
116
|
-
//
|
|
117
|
-
|
|
118
|
-
|
|
138
|
+
const key = keyOf(id, meta);
|
|
139
|
+
if (!qualify || unqualifiedByNature(meta)) {
|
|
140
|
+
// Ambient means "nothing manages this", and being referenced means "the
|
|
141
|
+
// estate reaches this" — neither stops it being managed. When a stack
|
|
142
|
+
// manages the same physical resource, this entry is that resource seen
|
|
143
|
+
// from outside, and rendering both inflates any count over it.
|
|
144
|
+
//
|
|
145
|
+
// The dependency half of that was missing, and it was not cosmetic: a
|
|
146
|
+
// subnet declared by its stack was also recorded as `subnet-9af06b90`
|
|
147
|
+
// with `referencedBy`, so an estate with 13 subnets replayed as 16 and
|
|
148
|
+
// "which subnets have no network interfaces" answered 11 where the
|
|
149
|
+
// truth was 8. The three extra were occupied subnets whose second copy
|
|
150
|
+
// had no interface pointing at it — a duplicate reads as empty, because
|
|
151
|
+
// the edges resolved to the other one.
|
|
152
|
+
const duplicate = Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0);
|
|
153
|
+
if (duplicate && meta.physicalId && managedPhysicalIds.has(meta.physicalId)) {
|
|
154
|
+
const survivor = managedKeyOf.get(meta.physicalId);
|
|
155
|
+
if (survivor && survivor !== key) merged.set(id, survivor);
|
|
156
|
+
continue;
|
|
157
|
+
}
|
|
158
|
+
// Unqualified: one resource per key, so first sighting wins and the
|
|
159
|
+
// rest are the same resource seen again from another stack's snapshot.
|
|
160
|
+
// The key carries the region when there is one, so "the same resource"
|
|
161
|
+
// no longer spans regions.
|
|
162
|
+
if (seenUnqualified.has(key)) continue;
|
|
163
|
+
seenUnqualified.add(key);
|
|
119
164
|
}
|
|
120
165
|
resources[key] = meta;
|
|
121
166
|
}
|
|
122
167
|
const known = new Set(Object.keys(snapshot.resources ?? {}));
|
|
123
168
|
const requalify = (id: string): string => {
|
|
169
|
+
// A dropped duplicate's edges belong to the resource that survived it.
|
|
170
|
+
const survivor = merged.get(id);
|
|
171
|
+
if (survivor) return survivor;
|
|
124
172
|
const meta = (snapshot.resources ?? {})[id];
|
|
125
|
-
return known.has(id) && meta ?
|
|
173
|
+
return known.has(id) && meta ? keyOf(id, meta) : id;
|
|
126
174
|
};
|
|
127
175
|
const edges = (snapshot.edges ?? []).map((e) => ({ ...e, from: requalify(e.from), to: requalify(e.to) }));
|
|
128
176
|
observations.push({
|
package/src/lifecycle/types.ts
CHANGED
|
@@ -97,6 +97,18 @@ export interface ResourceDigest {
|
|
|
97
97
|
export interface BuildDigest {
|
|
98
98
|
/** Per-resource digest keyed by logical name */
|
|
99
99
|
resources: Record<string, ResourceDigest>;
|
|
100
|
+
/**
|
|
101
|
+
* chant #1442 — lexicon name → version that produced this build.
|
|
102
|
+
*
|
|
103
|
+
* Recorded once per lexicon rather than on every {@link ResourceDigest},
|
|
104
|
+
* which would repeat one string across every resource of a stack for no
|
|
105
|
+
* added information.
|
|
106
|
+
*
|
|
107
|
+
* Optional because a digest read back from an older snapshot will not have
|
|
108
|
+
* it. Absent and empty are different: absent means "recorded before this
|
|
109
|
+
* existed", empty means "recorded, and no plugins were loaded".
|
|
110
|
+
*/
|
|
111
|
+
lexiconVersions?: Record<string, string>;
|
|
100
112
|
/** Resource-level dependency graph */
|
|
101
113
|
dependencies: Record<string, string[]>;
|
|
102
114
|
/** Cross-lexicon output bridges from BuildManifest */
|
|
@@ -117,4 +129,18 @@ export interface DigestDiff {
|
|
|
117
129
|
changed: string[];
|
|
118
130
|
/** Resources where propsHash matches */
|
|
119
131
|
unchanged: string[];
|
|
132
|
+
/**
|
|
133
|
+
* chant #1442 — lexicons whose VERSION moved between the two digests, even
|
|
134
|
+
* where every resource is unchanged.
|
|
135
|
+
*
|
|
136
|
+
* A build whose only difference is the lexicon that emitted it is a real
|
|
137
|
+
* difference: the lexicon is a generated artifact pinned to an upstream
|
|
138
|
+
* spec, so a bump can change output with no source change. Reported
|
|
139
|
+
* separately from `changed` because no resource's declaration moved — the
|
|
140
|
+
* interpreter did.
|
|
141
|
+
*
|
|
142
|
+
* Empty when neither digest recorded versions, so a comparison against a
|
|
143
|
+
* pre-#1442 snapshot reports nothing rather than inventing a change.
|
|
144
|
+
*/
|
|
145
|
+
lexiconVersionChanges: Array<{ lexicon: string; previous?: string; current?: string }>;
|
|
120
146
|
}
|
|
@@ -155,6 +155,11 @@ describe("pruneByOwnership — the shared three-question rule", () => {
|
|
|
155
155
|
chantOwned: new Set(["metadata.labels.tier"]),
|
|
156
156
|
foreignOwned: new Set(["spec.replicas", "metadata.annotations.noise"]),
|
|
157
157
|
foreignContested: new Set(["spec.replicas"]),
|
|
158
|
+
owners: new Map([
|
|
159
|
+
["metadata.labels.tier", "chant"],
|
|
160
|
+
["spec.replicas", "hpa-controller"],
|
|
161
|
+
["metadata.annotations.noise", "kube-controller-manager"],
|
|
162
|
+
]),
|
|
158
163
|
};
|
|
159
164
|
|
|
160
165
|
test("never prunes the declared side", () => {
|
|
@@ -177,3 +182,48 @@ describe("pruneByOwnership — the shared three-question rule", () => {
|
|
|
177
182
|
expect(pruneByOwnership(node({ path: "spec.selector", pattern: "spec.selector" }), sets)).toBe(false);
|
|
178
183
|
});
|
|
179
184
|
});
|
|
185
|
+
|
|
186
|
+
// #1189 — the three sets answer "which category owns this path". A reader needs
|
|
187
|
+
// the other question: `hpa-controller` and `kubectl-client-side-apply` are the
|
|
188
|
+
// same category and mean opposite things to an operator.
|
|
189
|
+
describe("buildOwnershipSets — owning manager per path (#1189)", () => {
|
|
190
|
+
const entries = [
|
|
191
|
+
{ manager: "chant", operation: "Apply", fieldsV1: { "f:metadata": { "f:labels": { "f:tier": {} } } } },
|
|
192
|
+
{ manager: "hpa-controller", operation: "Apply", fieldsV1: { "f:spec": { "f:replicas": {} } } },
|
|
193
|
+
{
|
|
194
|
+
manager: "kubectl-client-side-apply",
|
|
195
|
+
operation: "Update",
|
|
196
|
+
fieldsV1: { "f:spec": { "f:template": { "f:spec": { "f:containers": {} } } } },
|
|
197
|
+
},
|
|
198
|
+
];
|
|
199
|
+
|
|
200
|
+
test("records which manager owns each path", () => {
|
|
201
|
+
const sets = buildOwnershipSets(
|
|
202
|
+
entries,
|
|
203
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 3, template: { spec: { containers: [] } } } },
|
|
204
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 2 } },
|
|
205
|
+
(m) => m === "chant",
|
|
206
|
+
);
|
|
207
|
+
expect(sets.owners.get("metadata.labels.tier")).toBe("chant");
|
|
208
|
+
expect(sets.owners.get("spec.replicas")).toBe("hpa-controller");
|
|
209
|
+
expect(sets.owners.get("spec.template.spec.containers")).toBe("kubectl-client-side-apply");
|
|
210
|
+
});
|
|
211
|
+
|
|
212
|
+
test("the categories are unchanged by recording owners", () => {
|
|
213
|
+
const sets = buildOwnershipSets(
|
|
214
|
+
entries,
|
|
215
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 3, template: { spec: { containers: [] } } } },
|
|
216
|
+
{ metadata: { labels: { tier: "web" } }, spec: { replicas: 2 } },
|
|
217
|
+
(m) => m === "chant",
|
|
218
|
+
);
|
|
219
|
+
expect(sets.chantOwned.has("metadata.labels.tier")).toBe(true);
|
|
220
|
+
expect(sets.foreignOwned.has("spec.replicas")).toBe(true);
|
|
221
|
+
// Declared AND foreign-owned — contested, so still diffable.
|
|
222
|
+
expect(sets.foreignContested.has("spec.replicas")).toBe(true);
|
|
223
|
+
});
|
|
224
|
+
|
|
225
|
+
test("is empty when the object carries no managedFields at all", () => {
|
|
226
|
+
const sets = buildOwnershipSets([], { spec: {} }, { spec: {} }, (m) => m === "chant");
|
|
227
|
+
expect(sets.owners.size).toBe(0);
|
|
228
|
+
});
|
|
229
|
+
});
|
package/src/managed-fields.ts
CHANGED
|
@@ -144,6 +144,17 @@ export interface OwnershipSets {
|
|
|
144
144
|
foreignOwned: ReadonlySet<string>;
|
|
145
145
|
/** The subset of `foreignOwned` where the declared manifest also sets the path — drift-relevant despite foreign ownership. */
|
|
146
146
|
foreignContested: ReadonlySet<string>;
|
|
147
|
+
/**
|
|
148
|
+
* Path → the name of the manager that owns it (#1189).
|
|
149
|
+
*
|
|
150
|
+
* The three sets above answer *which category* owns a path, which is all the
|
|
151
|
+
* prune rule needs. A reader needs the other question — "owned by
|
|
152
|
+
* `kubectl-client-side-apply`" and "owned by `hpa-controller`" are the same
|
|
153
|
+
* category and mean very different things to an operator. Last writer wins
|
|
154
|
+
* where several managers touch one path, matching what the API server itself
|
|
155
|
+
* reports.
|
|
156
|
+
*/
|
|
157
|
+
owners: ReadonlyMap<string, string>;
|
|
147
158
|
}
|
|
148
159
|
|
|
149
160
|
function sameJson(a: unknown, b: unknown): boolean {
|
|
@@ -188,6 +199,9 @@ function walkOwnership(
|
|
|
188
199
|
path: string,
|
|
189
200
|
owned: Set<string>,
|
|
190
201
|
contested: Set<string>,
|
|
202
|
+
/** Records path → manager as it walks (#1189); omitted by callers that only need the sets. */
|
|
203
|
+
owners?: Map<string, string>,
|
|
204
|
+
manager?: string,
|
|
191
205
|
): void {
|
|
192
206
|
if (fieldsNode === null || typeof fieldsNode !== "object" || Array.isArray(fieldsNode)) return;
|
|
193
207
|
|
|
@@ -196,6 +210,7 @@ function walkOwnership(
|
|
|
196
210
|
if (path !== "") {
|
|
197
211
|
owned.add(path);
|
|
198
212
|
if (declaredNode !== undefined) contested.add(path);
|
|
213
|
+
if (owners && manager) owners.set(path, manager);
|
|
199
214
|
}
|
|
200
215
|
continue;
|
|
201
216
|
}
|
|
@@ -208,7 +223,8 @@ function walkOwnership(
|
|
|
208
223
|
const childPath = joinField(path, name);
|
|
209
224
|
owned.add(childPath);
|
|
210
225
|
if (childDeclared !== undefined) contested.add(childPath);
|
|
211
|
-
|
|
226
|
+
if (owners && manager) owners.set(childPath, manager);
|
|
227
|
+
walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
|
|
212
228
|
continue;
|
|
213
229
|
}
|
|
214
230
|
|
|
@@ -220,7 +236,8 @@ function walkOwnership(
|
|
|
220
236
|
const childPath = joinIndex(path, idx);
|
|
221
237
|
owned.add(childPath);
|
|
222
238
|
if (childDeclared !== undefined) contested.add(childPath);
|
|
223
|
-
|
|
239
|
+
if (owners && manager) owners.set(childPath, manager);
|
|
240
|
+
walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
|
|
224
241
|
continue;
|
|
225
242
|
}
|
|
226
243
|
|
|
@@ -255,7 +272,8 @@ function walkOwnership(
|
|
|
255
272
|
const childPath = joinIndex(path, liveIdx);
|
|
256
273
|
owned.add(childPath);
|
|
257
274
|
if (childDeclared !== undefined) contested.add(childPath);
|
|
258
|
-
|
|
275
|
+
if (owners && manager) owners.set(childPath, manager);
|
|
276
|
+
walkOwnership(child, childLive, childDeclared, childPath, owned, contested, owners, manager);
|
|
259
277
|
continue;
|
|
260
278
|
}
|
|
261
279
|
// An unrecognized prefix (a future fieldsV1 encoding) — skip.
|
|
@@ -286,6 +304,7 @@ export function buildOwnershipSets(
|
|
|
286
304
|
const chantOwned = new Set<string>();
|
|
287
305
|
const foreignOwned = new Set<string>();
|
|
288
306
|
const foreignContested = new Set<string>();
|
|
307
|
+
const owners = new Map<string, string>();
|
|
289
308
|
|
|
290
309
|
for (const entry of entries) {
|
|
291
310
|
if (typeof entry.manager !== "string" || entry.manager.length === 0) continue;
|
|
@@ -294,13 +313,13 @@ export function buildOwnershipSets(
|
|
|
294
313
|
if (isChantManager(entry.manager)) {
|
|
295
314
|
// Chant-owned paths are always diffable, regardless of who else is
|
|
296
315
|
// involved — "contested" only matters for a *foreign* owner.
|
|
297
|
-
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", chantOwned, new Set());
|
|
316
|
+
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", chantOwned, new Set(), owners, entry.manager);
|
|
298
317
|
} else {
|
|
299
|
-
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", foreignOwned, foreignContested);
|
|
318
|
+
walkOwnership(entry.fieldsV1, liveRoot, declaredRoot, "", foreignOwned, foreignContested, owners, entry.manager);
|
|
300
319
|
}
|
|
301
320
|
}
|
|
302
321
|
|
|
303
|
-
return { chantOwned, foreignOwned, foreignContested };
|
|
322
|
+
return { chantOwned, foreignOwned, foreignContested, owners };
|
|
304
323
|
}
|
|
305
324
|
|
|
306
325
|
/**
|