@intentius/chant 0.33.1 → 0.34.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/commands/onboard.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 +49 -1
- package/dist/cli/handlers/search.d.ts.map +1 -1
- package/dist/cli/main.d.ts.map +1 -1
- package/dist/cli/registry.d.ts +26 -0
- package/dist/cli/registry.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +14 -0
- package/dist/graph-ir.d.ts.map +1 -1
- package/dist/graph-refs.d.ts +19 -0
- package/dist/graph-refs.d.ts.map +1 -1
- package/dist/lexicon.d.ts +141 -0
- package/dist/lexicon.d.ts.map +1 -1
- package/dist/lifecycle/deep-observe.d.ts +4 -0
- package/dist/lifecycle/deep-observe.d.ts.map +1 -1
- package/dist/lifecycle/live-diff.d.ts.map +1 -1
- package/dist/lifecycle/observe.d.ts +55 -1
- package/dist/lifecycle/observe.d.ts.map +1 -1
- package/dist/lifecycle/replay.d.ts +47 -0
- package/dist/lifecycle/replay.d.ts.map +1 -0
- package/dist/lifecycle/snapshot.d.ts +6 -0
- package/dist/lifecycle/snapshot.d.ts.map +1 -1
- package/dist/lifecycle/types.d.ts +46 -0
- package/dist/lifecycle/types.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/cli/commands/onboard.ts +10 -25
- package/src/cli/handlers/graph.test.ts +74 -0
- package/src/cli/handlers/graph.ts +77 -36
- package/src/cli/handlers/lifecycle.test.ts +86 -0
- package/src/cli/handlers/lifecycle.ts +43 -10
- package/src/cli/handlers/search.test.ts +200 -4
- package/src/cli/handlers/search.ts +383 -29
- package/src/cli/main.ts +9 -0
- package/src/cli/registry.ts +27 -0
- package/src/codegen/lexicon-wiring.test.ts +53 -0
- package/src/codegen/release-wiring.test.ts +92 -0
- package/src/graph-ir-live.test.ts +83 -0
- package/src/graph-ir.ts +51 -1
- package/src/graph-refs.test.ts +59 -0
- package/src/graph-refs.ts +39 -8
- package/src/lexicon.ts +145 -0
- package/src/lifecycle/deep-observe.ts +5 -0
- package/src/lifecycle/live-diff.test.ts +38 -0
- package/src/lifecycle/live-diff.ts +45 -2
- package/src/lifecycle/observe.ts +186 -4
- package/src/lifecycle/replay.ts +141 -0
- package/src/lifecycle/snapshot.test.ts +179 -0
- package/src/lifecycle/snapshot.ts +88 -3
- package/src/lifecycle/types.ts +47 -0
|
@@ -80,3 +80,95 @@ describe("release wiring: justfile tags vs publish.yml trigger", () => {
|
|
|
80
80
|
expect(examples).toContain("lexicon-docker-v9.9.9");
|
|
81
81
|
});
|
|
82
82
|
});
|
|
83
|
+
|
|
84
|
+
/**
|
|
85
|
+
* The body of one justfile recipe, up to the next top-level recipe header.
|
|
86
|
+
* Line-based on purpose: a recipe header sits at column 0 and every body line
|
|
87
|
+
* is indented, which a regex over the whole file gets wrong (`^` under /m
|
|
88
|
+
* matches the start of the slice too).
|
|
89
|
+
*/
|
|
90
|
+
const RECIPE_HEADER = /^[a-z_][a-z0-9_-]*(\s+[^:]*)?:/;
|
|
91
|
+
|
|
92
|
+
function recipeBody(name: string): string {
|
|
93
|
+
const lines = readFileSync(join(repoRoot, "justfile"), "utf-8").split("\n");
|
|
94
|
+
const start = lines.findIndex((l) => new RegExp(`^${name}(\\s|:)`).test(l));
|
|
95
|
+
if (start === -1) throw new Error(`justfile: recipe "${name}" not found`);
|
|
96
|
+
|
|
97
|
+
let end = lines.length;
|
|
98
|
+
for (let i = start + 1; i < lines.length; i++) {
|
|
99
|
+
if (RECIPE_HEADER.test(lines[i])) {
|
|
100
|
+
end = i;
|
|
101
|
+
break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
return lines.slice(start, end).join("\n");
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* #1255.2 — `just release` rewrites the `@intentius/*` peer ranges alongside
|
|
109
|
+
* `.version` (added in #411, because ranges frozen at `^0.1.0` break clean
|
|
110
|
+
* installs). `release-lexicon` only set `.version`, so a single-lexicon patch
|
|
111
|
+
* needing a newer core silently shipped a stale range.
|
|
112
|
+
*
|
|
113
|
+
* The two recipes pin to different things on purpose: a whole-repo release
|
|
114
|
+
* moves everything together so `^$next` is right, while a single-lexicon
|
|
115
|
+
* patch leaves core where it is, so the range must track the CURRENT core
|
|
116
|
+
* version. Pinning it to `$next` would demand a core that does not exist —
|
|
117
|
+
* which is why this asserts the mechanism, not a shared literal.
|
|
118
|
+
*/
|
|
119
|
+
describe("release wiring: peer ranges stay in lockstep (#1255)", () => {
|
|
120
|
+
it("the whole-repo release rewrites both @intentius peer ranges", () => {
|
|
121
|
+
const body = recipeBody("release");
|
|
122
|
+
expect(body).toMatch(/peerDependencies\["@intentius\/chant"\]/);
|
|
123
|
+
expect(body).toMatch(/peerDependencies\["@intentius\/chant-lexicon-github"\]/);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("the single-lexicon release rewrites them too", () => {
|
|
127
|
+
const body = recipeBody("release-lexicon");
|
|
128
|
+
expect(
|
|
129
|
+
body.includes('peerDependencies["@intentius/chant"]'),
|
|
130
|
+
"release-lexicon sets .version without touching the @intentius/chant peer range — " +
|
|
131
|
+
"a lexicon patch that needs a newer core would ship a stale range (#411, #1255)",
|
|
132
|
+
).toBe(true);
|
|
133
|
+
expect(body).toMatch(/peerDependencies\["@intentius\/chant-lexicon-github"\]/);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it("the single-lexicon release pins peers to core, not to its own new version", () => {
|
|
137
|
+
const body = recipeBody("release-lexicon");
|
|
138
|
+
// It reads the current core version rather than reusing $next.
|
|
139
|
+
expect(body).toMatch(/jq -r \.version packages\/core\/package\.json/);
|
|
140
|
+
expect(
|
|
141
|
+
/peerDependencies\["@intentius\/chant"\]\s*=\s*"\^"\s*\+\s*\$next/.test(body),
|
|
142
|
+
"release-lexicon must not pin the core peer range to the lexicon's own new version",
|
|
143
|
+
).toBe(false);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("both recipes keep the committed lockfile in step", () => {
|
|
147
|
+
for (const recipe of ["release", "release-lexicon"]) {
|
|
148
|
+
expect(recipeBody(recipe)).toMatch(/npm install --package-lock-only/);
|
|
149
|
+
}
|
|
150
|
+
});
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
/**
|
|
154
|
+
* #1255.3 — both recipes pushed the bump commit straight to main, bypassing
|
|
155
|
+
* branch protection, so a release tag could point at a commit CI never ran.
|
|
156
|
+
*/
|
|
157
|
+
describe("release wiring: preflight gates both recipes (#1255)", () => {
|
|
158
|
+
it("every release recipe runs the preflight before tagging", () => {
|
|
159
|
+
for (const recipe of ["release", "release-lexicon"]) {
|
|
160
|
+
const body = recipeBody(recipe);
|
|
161
|
+
expect(
|
|
162
|
+
body.includes("scripts/release-preflight.sh"),
|
|
163
|
+
`justfile recipe "${recipe}" tags and pushes without running the preflight — ` +
|
|
164
|
+
"it could release a commit CI never ran (#1255)",
|
|
165
|
+
).toBe(true);
|
|
166
|
+
// Ordering matters: a check that runs after `git tag` proves nothing.
|
|
167
|
+
expect(body.indexOf("scripts/release-preflight.sh")).toBeLessThan(body.indexOf("git tag"));
|
|
168
|
+
}
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("the whole-repo release requires main, since it pushes main", () => {
|
|
172
|
+
expect(recipeBody("release")).toMatch(/release-preflight\.sh main/);
|
|
173
|
+
});
|
|
174
|
+
});
|
|
@@ -157,6 +157,34 @@ describe("sourceOverlayGraphs (#821 source-anchored overlay)", () => {
|
|
|
157
157
|
expect(vpc.ownership).toBe("owned");
|
|
158
158
|
});
|
|
159
159
|
|
|
160
|
+
it("carries observed attributes onto a managed declared node (#1279)", () => {
|
|
161
|
+
// Before this, the overlay copied identity but dropped everything observed
|
|
162
|
+
// ABOUT the resource, so a fact that only exists in the account — an
|
|
163
|
+
// instance's VpcId — was unreachable from the overlaid graph and
|
|
164
|
+
// `search --show VpcId` printed a blank column.
|
|
165
|
+
const observed: GraphIR = {
|
|
166
|
+
...live,
|
|
167
|
+
nodes: live.nodes.map((x) => (x.id === "web-vpc" ? { ...x, attrs: { CidrBlock: "10.0.0.0/16" } } : x)),
|
|
168
|
+
};
|
|
169
|
+
const vpc = sourceOverlayGraphs(declared, observed).nodes.find((x) => x.id === "web-vpc")!;
|
|
170
|
+
expect(vpc.attrs.CidrBlock).toBe("10.0.0.0/16");
|
|
171
|
+
});
|
|
172
|
+
|
|
173
|
+
it("lets an observed value beat the declared one of the same name (#1279)", () => {
|
|
174
|
+
// The declared side holds an unresolved reference to another entity; the
|
|
175
|
+
// account holds the id it actually resolved to.
|
|
176
|
+
const withRef: GraphIR = {
|
|
177
|
+
...declared,
|
|
178
|
+
nodes: declared.nodes.map((x) => (x.id === "web-vpc" ? { ...x, attrs: { VpcId: "${vpc.id}" } } : x)),
|
|
179
|
+
};
|
|
180
|
+
const observed: GraphIR = {
|
|
181
|
+
...live,
|
|
182
|
+
nodes: live.nodes.map((x) => (x.id === "web-vpc" ? { ...x, attrs: { VpcId: "vpc-0a1b" } } : x)),
|
|
183
|
+
};
|
|
184
|
+
const vpc = sourceOverlayGraphs(withRef, observed).nodes.find((x) => x.id === "web-vpc")!;
|
|
185
|
+
expect(vpc.attrs.VpcId).toBe("vpc-0a1b");
|
|
186
|
+
});
|
|
187
|
+
|
|
160
188
|
it("appends foreign nodes with their live edges, and keeps declared groups", () => {
|
|
161
189
|
const ir = sourceOverlayGraphs(declared, live);
|
|
162
190
|
expect(ir.nodes.map((x) => x.id)).toContain("rogue-sg");
|
|
@@ -208,3 +236,58 @@ describe("collectUnobserved (#1089)", () => {
|
|
|
208
236
|
).toEqual({ a: { reason: "read-failed" }, b: { reason: "no-binding" } });
|
|
209
237
|
});
|
|
210
238
|
});
|
|
239
|
+
|
|
240
|
+
// #1271 — an observation can report relationships, not just existence. Without
|
|
241
|
+
// these the live side of the graph has no edges, so a topology fold has nothing
|
|
242
|
+
// to traverse and a lexicon has to compute derived answers itself.
|
|
243
|
+
describe("observed edges (#1271)", () => {
|
|
244
|
+
const twoNodes = {
|
|
245
|
+
lexicon: "aws",
|
|
246
|
+
resources: {
|
|
247
|
+
"app-subnet": { type: "AWS::EC2::Subnet", status: "CREATE_COMPLETE", physicalId: "subnet-0c2d" },
|
|
248
|
+
"web-vpc": { type: "AWS::EC2::VPC", status: "CREATE_COMPLETE", physicalId: "vpc-0a1b" },
|
|
249
|
+
},
|
|
250
|
+
} satisfies LiveObservation;
|
|
251
|
+
|
|
252
|
+
it("projects an observed edge between two observed nodes", () => {
|
|
253
|
+
const ir = buildLiveGraphIr([
|
|
254
|
+
{ ...twoNodes, edges: [{ from: "app-subnet", to: "web-vpc", kind: "ref", viaAttr: "VpcId" }] },
|
|
255
|
+
]);
|
|
256
|
+
expect(ir.edges).toEqual([{ from: "app-subnet", to: "web-vpc", kind: "ref", viaAttr: "VpcId" }]);
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("no edges reported → no edges, unchanged from before", () => {
|
|
260
|
+
expect(buildLiveGraphIr([twoNodes]).edges).toEqual([]);
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it("drops an edge whose endpoint was never observed", () => {
|
|
264
|
+
// A dangling reference would traverse to nothing during a fold, which reads
|
|
265
|
+
// as "no such relationship" rather than "the other end was not read".
|
|
266
|
+
const ir = buildLiveGraphIr([
|
|
267
|
+
{ ...twoNodes, edges: [{ from: "app-subnet", to: "never-read", kind: "ref", viaAttr: "VpcId" }] },
|
|
268
|
+
]);
|
|
269
|
+
expect(ir.edges).toEqual([]);
|
|
270
|
+
});
|
|
271
|
+
|
|
272
|
+
it("dedupes identical edges reported by more than one observation", () => {
|
|
273
|
+
const edge = { from: "app-subnet", to: "web-vpc", kind: "ref" as const, viaAttr: "VpcId" };
|
|
274
|
+
const ir = buildLiveGraphIr([
|
|
275
|
+
{ ...twoNodes, edges: [edge] },
|
|
276
|
+
{ ...twoNodes, edges: [edge] },
|
|
277
|
+
]);
|
|
278
|
+
expect(ir.edges).toHaveLength(1);
|
|
279
|
+
});
|
|
280
|
+
|
|
281
|
+
it("orders edges deterministically — the IR is compared and committed", () => {
|
|
282
|
+
const ir = buildLiveGraphIr([
|
|
283
|
+
{
|
|
284
|
+
...twoNodes,
|
|
285
|
+
edges: [
|
|
286
|
+
{ from: "web-vpc", to: "app-subnet", kind: "ref", viaAttr: "Z" },
|
|
287
|
+
{ from: "app-subnet", to: "web-vpc", kind: "ref", viaAttr: "A" },
|
|
288
|
+
],
|
|
289
|
+
},
|
|
290
|
+
]);
|
|
291
|
+
expect(ir.edges?.map((e) => e.from)).toEqual(["app-subnet", "web-vpc"]);
|
|
292
|
+
});
|
|
293
|
+
});
|
package/src/graph-ir.ts
CHANGED
|
@@ -489,6 +489,20 @@ export interface LiveObservation {
|
|
|
489
489
|
* overlay must not paint them "pending". See {@link sourceOverlayGraphs}.
|
|
490
490
|
*/
|
|
491
491
|
unobserved?: Record<string, UnobservedEntity>;
|
|
492
|
+
/**
|
|
493
|
+
* Relationships the lexicon observed between the resources it read (#1271).
|
|
494
|
+
*
|
|
495
|
+
* Without these an observation can only say what exists, never how it
|
|
496
|
+
* connects — so a fold over topology has nothing to traverse on the live side
|
|
497
|
+
* and a lexicon has to compute derived answers itself and inject them as
|
|
498
|
+
* attributes. Reporting the edges instead lets the one graph fold in
|
|
499
|
+
* {@link import("./graph-effective").enrichEffectiveTopology} do the work,
|
|
500
|
+
* for every lexicon, over live and recorded observations alike.
|
|
501
|
+
*
|
|
502
|
+
* `from`/`to` are node ids in the same space as {@link LiveObservation.resources}
|
|
503
|
+
* keys, so an edge can only reference something the observation also reported.
|
|
504
|
+
*/
|
|
505
|
+
edges?: IREdge[];
|
|
492
506
|
}
|
|
493
507
|
|
|
494
508
|
/**
|
|
@@ -540,7 +554,31 @@ export function buildLiveGraphIr(observations: LiveObservation[]): GraphIR {
|
|
|
540
554
|
if (Object.keys(byLexicon).length) groups.byLexicon = sortKeys(byLexicon);
|
|
541
555
|
if (Object.keys(byStack).length) groups.byStack = sortKeys(byStack);
|
|
542
556
|
|
|
543
|
-
|
|
557
|
+
// Observed relationships (#1271). An edge whose endpoints were not both
|
|
558
|
+
// observed is dropped rather than kept as a dangling reference: the fold
|
|
559
|
+
// resolves ids to nodes, and a half-edge would silently traverse to nothing.
|
|
560
|
+
// Deduped and sorted for the same reason nodes are — the IR is compared and
|
|
561
|
+
// committed, so it has to be stable across reads.
|
|
562
|
+
const observedIds = new Set(nodes.map((n) => n.id));
|
|
563
|
+
const seen = new Set<string>();
|
|
564
|
+
const edges: IREdge[] = [];
|
|
565
|
+
for (const observation of observations) {
|
|
566
|
+
for (const edge of observation.edges ?? []) {
|
|
567
|
+
if (!observedIds.has(edge.from) || !observedIds.has(edge.to)) continue;
|
|
568
|
+
const key = `${edge.from}${edge.to}${edge.viaAttr ?? ""}${edge.toAttr ?? ""}`;
|
|
569
|
+
if (seen.has(key)) continue;
|
|
570
|
+
seen.add(key);
|
|
571
|
+
edges.push(edge);
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
edges.sort(
|
|
575
|
+
(a, b) =>
|
|
576
|
+
a.from.localeCompare(b.from) ||
|
|
577
|
+
a.to.localeCompare(b.to) ||
|
|
578
|
+
(a.viaAttr ?? "").localeCompare(b.viaAttr ?? ""),
|
|
579
|
+
);
|
|
580
|
+
|
|
581
|
+
return { nodes, edges, groups };
|
|
544
582
|
}
|
|
545
583
|
|
|
546
584
|
/** How an overlay learns which declared nodes were never looked at (#1089). */
|
|
@@ -647,6 +685,18 @@ export function sourceOverlayGraphs(declared: GraphIR, live: GraphIR, opts?: Ove
|
|
|
647
685
|
const merged: IRNode = { ...n }; // managed — carry the observed identity
|
|
648
686
|
if (obs.physicalId) merged.physicalId = obs.physicalId;
|
|
649
687
|
if (obs.ownership) merged.ownership = obs.ownership;
|
|
688
|
+
// ...and what was observed ABOUT it (#1279). The declared canvas holds what
|
|
689
|
+
// the source says; the observation holds what the account says, and a fact
|
|
690
|
+
// like `VpcId` exists only on the second. Dropping it here made every
|
|
691
|
+
// observed property invisible to anything reading the overlaid graph —
|
|
692
|
+
// `search --show VpcId` printed six blank columns, which reads as an estate
|
|
693
|
+
// with no VPCs rather than a read that never happened.
|
|
694
|
+
//
|
|
695
|
+
// Observed wins a collision. The declared value for something like `VpcId`
|
|
696
|
+
// is an unresolved reference to another entity, not an id — the account has
|
|
697
|
+
// the id. Same precedence `enrichLiveAttrs` already uses when it folds live
|
|
698
|
+
// facts onto this graph, so both paths agree on what a name means.
|
|
699
|
+
if (obs.attrs && Object.keys(obs.attrs).length > 0) merged.attrs = { ...n.attrs, ...obs.attrs };
|
|
650
700
|
return tagStatus(merged, "good");
|
|
651
701
|
});
|
|
652
702
|
for (const n of live.nodes) {
|
package/src/graph-refs.test.ts
CHANGED
|
@@ -129,3 +129,62 @@ describe("mergeCatalogs", () => {
|
|
|
129
129
|
expect(m.refs).toHaveLength(1);
|
|
130
130
|
});
|
|
131
131
|
});
|
|
132
|
+
|
|
133
|
+
// #1275 — `viaAttr` defaulted to `label ?? path`, which serves a renderer and
|
|
134
|
+
// starves a traversal: the catalog's labels are human-facing ("sg", "via")
|
|
135
|
+
// while a fold matches provider attribute names ("SecurityGroupIds").
|
|
136
|
+
describe("traversal name vs rendering label (#1275)", () => {
|
|
137
|
+
const nodes: IRNode[] = [
|
|
138
|
+
{ id: "web", kind: "AWS::EC2::Instance", lexicon: "aws", attrs: { SubnetId: "subnet-1", SecurityGroups: [{ GroupId: "sg-1" }] } },
|
|
139
|
+
{ id: "sub", kind: "AWS::EC2::Subnet", lexicon: "aws", attrs: { SubnetId: "subnet-1" } },
|
|
140
|
+
{ id: "sg", kind: "AWS::EC2::SecurityGroup", lexicon: "aws", attrs: { GroupId: "sg-1" } },
|
|
141
|
+
];
|
|
142
|
+
const identities = [
|
|
143
|
+
{ kind: "AWS::EC2::Subnet", ids: ["SubnetId"] },
|
|
144
|
+
{ kind: "AWS::EC2::SecurityGroup", ids: ["GroupId"] },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
it("viaAttr wins over label, so the fold can match the attribute name", () => {
|
|
148
|
+
const { edges } = reconstructEdges(nodes, {
|
|
149
|
+
identities,
|
|
150
|
+
refs: [
|
|
151
|
+
{ from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
|
|
152
|
+
],
|
|
153
|
+
});
|
|
154
|
+
expect(edges).toEqual([{ from: "web", to: "sg", kind: "ref", viaAttr: "SecurityGroupIds" }]);
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
it("without viaAttr the label still wins, unchanged", () => {
|
|
158
|
+
const { edges } = reconstructEdges(nodes, {
|
|
159
|
+
identities,
|
|
160
|
+
refs: [
|
|
161
|
+
{ from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
|
|
162
|
+
],
|
|
163
|
+
});
|
|
164
|
+
expect(edges[0].viaAttr).toBe("sg");
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
it("a containment rule with viaAttr yields both the boundary pair and a traversable edge", () => {
|
|
168
|
+
// An instance is *in* a subnet — a boundary for the picture, and the first
|
|
169
|
+
// hop of internetFacing. It has to be both without being drawn twice.
|
|
170
|
+
const { edges, containment } = reconstructEdges(nodes, {
|
|
171
|
+
identities,
|
|
172
|
+
refs: [
|
|
173
|
+
{ from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet", viaAttr: "SubnetId" },
|
|
174
|
+
],
|
|
175
|
+
});
|
|
176
|
+
expect(containment).toEqual([{ child: "web", parent: "sub", label: "in subnet" }]);
|
|
177
|
+
expect(edges).toEqual([{ from: "web", to: "sub", kind: "ref", viaAttr: "SubnetId" }]);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it("a containment rule without viaAttr stays a boundary hint only", () => {
|
|
181
|
+
const { edges, containment } = reconstructEdges(nodes, {
|
|
182
|
+
identities,
|
|
183
|
+
refs: [
|
|
184
|
+
{ from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
|
|
185
|
+
],
|
|
186
|
+
});
|
|
187
|
+
expect(containment).toHaveLength(1);
|
|
188
|
+
expect(edges).toEqual([]);
|
|
189
|
+
});
|
|
190
|
+
});
|
package/src/graph-refs.ts
CHANGED
|
@@ -40,6 +40,25 @@ export interface RefRule {
|
|
|
40
40
|
relation: "reference" | "containment";
|
|
41
41
|
/** Edge / containment label (e.g. "in VPC", "sg", "targets"). */
|
|
42
42
|
label?: string;
|
|
43
|
+
/**
|
|
44
|
+
* What the reconstructed edge's `viaAttr` should be, when traversal needs a
|
|
45
|
+
* different string than rendering does (#1275).
|
|
46
|
+
*
|
|
47
|
+
* `viaAttr` defaulted to `label ?? path`, which serves a renderer well and a
|
|
48
|
+
* traversal badly. The labels here are human-facing — "sg", "via", "in VPC" —
|
|
49
|
+
* while a fold like `enrichEffectiveTopology` matches provider attribute
|
|
50
|
+
* names: `SecurityGroupIds`, `SubnetId`, `LaunchTemplateId`. One field could
|
|
51
|
+
* not be both, so a rule that is traversed declares the name explicitly and
|
|
52
|
+
* keeps its label for the picture.
|
|
53
|
+
*
|
|
54
|
+
* On a `containment` rule this additionally opts the relation into producing
|
|
55
|
+
* an edge, on top of the boundary pair it already produces. Containment is
|
|
56
|
+
* not an edge by default and should not become one — but a fold's first hop
|
|
57
|
+
* is sometimes exactly a containment relation (an instance is *in* a subnet),
|
|
58
|
+
* and that hop has to be traversable without duplicating the rule as a
|
|
59
|
+
* reference and drawing the line twice.
|
|
60
|
+
*/
|
|
61
|
+
viaAttr?: string;
|
|
43
62
|
}
|
|
44
63
|
|
|
45
64
|
/** A lexicon's reference knowledge — its identity map and reference rules. */
|
|
@@ -154,17 +173,29 @@ export function reconstructEdges(nodes: IRNode[], catalog: ReferenceCatalog): Re
|
|
|
154
173
|
continue;
|
|
155
174
|
}
|
|
156
175
|
if (match.id === node.id) continue; // self-reference (e.g. an SG rule to its own group)
|
|
157
|
-
|
|
158
|
-
const k = `${node.id}|${match.id}`;
|
|
159
|
-
if (seenCont.has(k)) continue;
|
|
160
|
-
seenCont.add(k);
|
|
161
|
-
containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
|
|
162
|
-
} else {
|
|
163
|
-
const via = rule.label ?? rule.path;
|
|
176
|
+
const pushEdge = (via: string): void => {
|
|
164
177
|
const k = `${node.id}|${match.id}|${via}`;
|
|
165
|
-
if (seenEdge.has(k))
|
|
178
|
+
if (seenEdge.has(k)) return;
|
|
166
179
|
seenEdge.add(k);
|
|
167
180
|
edges.push({ from: node.id, to: match.id, kind: "ref", viaAttr: via });
|
|
181
|
+
};
|
|
182
|
+
if (rule.relation === "containment") {
|
|
183
|
+
const k = `${node.id}|${match.id}`;
|
|
184
|
+
if (!seenCont.has(k)) {
|
|
185
|
+
seenCont.add(k);
|
|
186
|
+
containment.push({ child: node.id, parent: match.id, ...(rule.label ? { label: rule.label } : {}) });
|
|
187
|
+
}
|
|
188
|
+
// A containment relation is a boundary hint, not an edge — unless the
|
|
189
|
+
// rule declares a traversal name (#1275). A fold's first hop is
|
|
190
|
+
// sometimes exactly a containment ("an instance is in a subnet"), and
|
|
191
|
+
// it must be traversable without duplicating the rule as a reference
|
|
192
|
+
// and drawing the line twice.
|
|
193
|
+
if (rule.viaAttr) pushEdge(rule.viaAttr);
|
|
194
|
+
} else {
|
|
195
|
+
// Traversal name wins over the rendering label: the labels are
|
|
196
|
+
// human-facing ("sg", "via"), and a fold matches provider attribute
|
|
197
|
+
// names (#1275).
|
|
198
|
+
pushEdge(rule.viaAttr ?? rule.label ?? rule.path);
|
|
168
199
|
}
|
|
169
200
|
}
|
|
170
201
|
}
|
package/src/lexicon.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { DriverComponent } from "./components/driver";
|
|
|
11
11
|
import type { EmulatorCapability } from "./op/emulator-lifecycle";
|
|
12
12
|
import type { RuleMeta } from "./audit/catalog";
|
|
13
13
|
import type { ReferenceCatalog } from "./graph-refs";
|
|
14
|
+
import type { IREdge } from "./graph-ir";
|
|
14
15
|
import type { DescribeResourcesResult } from "./observation";
|
|
15
16
|
import type { DeepNormalizationHooks, DeepObservationResult } from "./deep-observation";
|
|
16
17
|
import type { OwnerChainVerdict } from "./owner-chain";
|
|
@@ -24,6 +25,10 @@ export type { CommandGroup, CommandGroupCommand, CommandGroupContext } from "./c
|
|
|
24
25
|
// `@intentius/chant/lexicon` entry they import the plugin contract from.
|
|
25
26
|
export type { ReferenceCatalog, IdentityRule, RefRule } from "./graph-refs";
|
|
26
27
|
|
|
28
|
+
// An observation can report relationships (#1271/#1273), so a lexicon needs the
|
|
29
|
+
// edge type from the same entry it imports the plugin contract from.
|
|
30
|
+
export type { IREdge } from "./graph-ir";
|
|
31
|
+
|
|
27
32
|
// The observation contract (#1089), re-exported from the same entry so a
|
|
28
33
|
// lexicon's `describeResources` can report NOT-OBSERVED without a second
|
|
29
34
|
// import path. Runtime helpers live in `@intentius/chant/observation`.
|
|
@@ -641,6 +646,84 @@ export interface LexiconPlugin {
|
|
|
641
646
|
* Throwing is the whole-lexicon failure, same as the thin read: core turns it
|
|
642
647
|
* into `read-failed` for every declared entity.
|
|
643
648
|
*/
|
|
649
|
+
/**
|
|
650
|
+
* Report the undeclared resources this estate *depends on* (#1273), as
|
|
651
|
+
* opposed to the ones it manages.
|
|
652
|
+
*
|
|
653
|
+
* `describeResources` is scoped to what the stack declares. Anything the
|
|
654
|
+
* estate references but does not declare — an account's default VPC route
|
|
655
|
+
* tables, a shared subnet, networking owned by another team — is invisible to
|
|
656
|
+
* it, so it never becomes a node, so no edge can reach it and no fold can
|
|
657
|
+
* traverse it. That is why derived facts about un-modelled topology have had
|
|
658
|
+
* to be computed inside lexicons and injected as attributes.
|
|
659
|
+
*
|
|
660
|
+
* The closure rule is depth one by reference, plus whatever chains this
|
|
661
|
+
* lexicon's {@link referenceCatalog} declares as meaningful — so AWS follows
|
|
662
|
+
* `SubnetId` → `RouteTableId` → `GatewayId` because the catalog says those
|
|
663
|
+
* references matter, not because they happen to be reachable. Without a rule
|
|
664
|
+
* a VPC transitively reaches most of an account.
|
|
665
|
+
*
|
|
666
|
+
* Every returned resource must carry {@link ResourceMetadata.referencedBy},
|
|
667
|
+
* naming the nodes that pulled it in. A dependency with no referrer is
|
|
668
|
+
* unbounded discovery, which is what the closure rule exists to prevent.
|
|
669
|
+
*
|
|
670
|
+
* Optional and additive. A lexicon that does not implement it behaves exactly
|
|
671
|
+
* as before, and a consumer that ignores dependencies sees what it always saw.
|
|
672
|
+
*/
|
|
673
|
+
observeDependencies?(options: {
|
|
674
|
+
environment: string;
|
|
675
|
+
/** Declared entities, for a lexicon that resolves references from source. */
|
|
676
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
677
|
+
/** What {@link describeResources} just found — the roots of the closure. */
|
|
678
|
+
observed: Record<string, ResourceMetadata>;
|
|
679
|
+
stack?: string;
|
|
680
|
+
region?: string;
|
|
681
|
+
}): Promise<DependencyObservation>;
|
|
682
|
+
|
|
683
|
+
/**
|
|
684
|
+
* Report resources of a kind this estate manages that exist in the account
|
|
685
|
+
* without being declared or referenced (#1278).
|
|
686
|
+
*
|
|
687
|
+
* `describeResources` answers "what do I manage" and `observeDependencies`
|
|
688
|
+
* answers "what do I rely on". Neither can see a resource that is simply
|
|
689
|
+
* *there* — an unattached security group, an orphaned volume — because both
|
|
690
|
+
* resolve outward from the declared estate and an unused resource is reached
|
|
691
|
+
* by nothing.
|
|
692
|
+
*
|
|
693
|
+
* That is a real question with no other answer: "which of my security groups
|
|
694
|
+
* are unused" cannot be resolved from a state file at all, because a state
|
|
695
|
+
* file knows only what it created. A lexicon that can enumerate a kind can
|
|
696
|
+
* answer it.
|
|
697
|
+
*
|
|
698
|
+
* `kinds` bounds the scan to types the project actually declares, so a
|
|
699
|
+
* project managing security groups is not made to enumerate the account. A
|
|
700
|
+
* lexicon returns resources marked {@link ResourceMetadata.ambient}, and must
|
|
701
|
+
* exclude anything already in `observed` — those are managed, not ambient.
|
|
702
|
+
*
|
|
703
|
+
* Optional and opt-in. A lexicon that does not implement it, or a caller that
|
|
704
|
+
* does not ask, sees exactly what it saw before.
|
|
705
|
+
*/
|
|
706
|
+
/**
|
|
707
|
+
* Kinds this lexicon can enumerate beyond the declared estate (#1278).
|
|
708
|
+
*
|
|
709
|
+
* Declared separately from {@link observeAmbient} so a caller can say that
|
|
710
|
+
* ambient resources of a kind are POSSIBLE without paying for a scan to find
|
|
711
|
+
* out. `chant search` uses it to point out that `--ambient` is relevant to
|
|
712
|
+
* the kind just queried — an agent asking which security groups are unused
|
|
713
|
+
* has no way to know that some are not in the answer at all.
|
|
714
|
+
*/
|
|
715
|
+
ambientKinds?(): string[];
|
|
716
|
+
|
|
717
|
+
observeAmbient?(options: {
|
|
718
|
+
environment: string;
|
|
719
|
+
/** Entity types the project declares — the bound on what to enumerate. */
|
|
720
|
+
kinds: string[];
|
|
721
|
+
/** Already-observed managed resources, to exclude. */
|
|
722
|
+
observed: Record<string, ResourceMetadata>;
|
|
723
|
+
stack?: string;
|
|
724
|
+
region?: string;
|
|
725
|
+
}): Promise<Record<string, ResourceMetadata>>;
|
|
726
|
+
|
|
644
727
|
observeResourcesDeep?(options: {
|
|
645
728
|
environment: string;
|
|
646
729
|
buildOutput: string;
|
|
@@ -648,6 +731,10 @@ export interface LexiconPlugin {
|
|
|
648
731
|
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
649
732
|
/** Deployed stack to observe, for a multi-stack project (see `stacks` in {@link ChantConfig}). */
|
|
650
733
|
stack?: string;
|
|
734
|
+
/** Region the stack is deployed in (#1267), mirroring the thin path's
|
|
735
|
+
* `describeResources` (#1261). When omitted, an implementation keeps its
|
|
736
|
+
* ambient-region default. */
|
|
737
|
+
region?: string;
|
|
651
738
|
/** Restrict to chant-owned resources (#119). A lexicon with no marker channel on this path says so. */
|
|
652
739
|
owned?: boolean;
|
|
653
740
|
}): Promise<DeepObservationResult>;
|
|
@@ -799,6 +886,31 @@ export type ExportedTemplate = TemplateIR & {
|
|
|
799
886
|
/**
|
|
800
887
|
* Metadata about a deployed resource, returned by describeResources.
|
|
801
888
|
*/
|
|
889
|
+
/**
|
|
890
|
+
* What a lexicon saw outside its declared estate (#1273): the resources
|
|
891
|
+
* something declared references, and how they connect.
|
|
892
|
+
*
|
|
893
|
+
* Kept as its own result rather than folded into `describeResources` so every
|
|
894
|
+
* consumer downstream can tell "what I manage" from "what I depend on" without
|
|
895
|
+
* disentangling them — the same reason the change set separates `orphan` from
|
|
896
|
+
* `runtimeChildren`.
|
|
897
|
+
*/
|
|
898
|
+
export interface DependencyObservation {
|
|
899
|
+
/**
|
|
900
|
+
* Undeclared resources, keyed by a stable id. Physical id is the natural
|
|
901
|
+
* choice: these have no logical name, and the key has to match between a live
|
|
902
|
+
* read and a snapshot replay or an overlay double-counts them.
|
|
903
|
+
*/
|
|
904
|
+
resources: Record<string, ResourceMetadata>;
|
|
905
|
+
/**
|
|
906
|
+
* Relationships among the dependencies and back to the declared nodes that
|
|
907
|
+
* reached them. Reported rather than reconstructed, because a reference
|
|
908
|
+
* catalog can only resolve what it has an identity index for, and part of the
|
|
909
|
+
* point here is expressing a hop the catalog does not model.
|
|
910
|
+
*/
|
|
911
|
+
edges?: IREdge[];
|
|
912
|
+
}
|
|
913
|
+
|
|
802
914
|
export interface ResourceMetadata {
|
|
803
915
|
/** Entity type (e.g. AWS::S3::Bucket, K8s::Apps::Deployment) */
|
|
804
916
|
type: string;
|
|
@@ -832,6 +944,39 @@ export interface ResourceMetadata {
|
|
|
832
944
|
* undeclared live resource stays `orphan`.
|
|
833
945
|
*/
|
|
834
946
|
ownerChain?: OwnerChainVerdict;
|
|
947
|
+
/**
|
|
948
|
+
* The declared node ids that reference this resource (#1273), set on an
|
|
949
|
+
* undeclared resource observed only because something declared points at it —
|
|
950
|
+
* the account's default VPC route table an instance routes through, a shared
|
|
951
|
+
* subnet, a network someone else owns.
|
|
952
|
+
*
|
|
953
|
+
* A third kind of observed-but-undeclared resource, beside {@link ownership}
|
|
954
|
+
* and {@link ownerChain}. Not an orphan: nobody is going to adopt or delete
|
|
955
|
+
* the account's default VPC, so offering it as a delete candidate is wrong.
|
|
956
|
+
* Not a runtime child either — nothing declared created it. It gets a runtime
|
|
957
|
+
* child's drift treatment for the same reason: it is not yours, it changes on
|
|
958
|
+
* its own, and alerting on it is noise.
|
|
959
|
+
*
|
|
960
|
+
* Carries the referrers rather than a bare flag so the reason a resource was
|
|
961
|
+
* pulled in is answerable, and so a closure can be walked back to the declared
|
|
962
|
+
* entity that justified it.
|
|
963
|
+
*/
|
|
964
|
+
referencedBy?: string[];
|
|
965
|
+
/**
|
|
966
|
+
* Observed in the account, of a kind this estate manages, but neither
|
|
967
|
+
* declared nor referenced by anything declared (#1278).
|
|
968
|
+
*
|
|
969
|
+
* The third and last category of observed-but-undeclared resource, after
|
|
970
|
+
* {@link ownerChain}'s runtime children and {@link referencedBy}'s
|
|
971
|
+
* dependencies. A default security group AWS creates per VPC is the type
|
|
972
|
+
* case: nothing declares it, nothing points at it, and it is exactly what
|
|
973
|
+
* "which of my security groups are unused" is asking about.
|
|
974
|
+
*
|
|
975
|
+
* Opt-in, because finding these means asking the provider what exists rather
|
|
976
|
+
* than resolving out from what is declared, which is a broader read and a
|
|
977
|
+
* different claim about what an observation is.
|
|
978
|
+
*/
|
|
979
|
+
ambient?: boolean;
|
|
835
980
|
}
|
|
836
981
|
|
|
837
982
|
/**
|
|
@@ -39,6 +39,10 @@ export interface DeepObserveOptions {
|
|
|
39
39
|
entities: DeclaredEntities;
|
|
40
40
|
/** Deployed stack for a multi-stack project (#932). */
|
|
41
41
|
stack?: string;
|
|
42
|
+
/** Region that stack is deployed in (#1267). Same contract as the thin path
|
|
43
|
+
* (#1261): a multi-region estate reads each stack in its own region, not in
|
|
44
|
+
* whichever one the shell is set to. */
|
|
45
|
+
region?: string;
|
|
42
46
|
/** Component projects deploy one stack per component; read them all and merge. */
|
|
43
47
|
componentStacks?: string[];
|
|
44
48
|
owned?: boolean;
|
|
@@ -80,6 +84,7 @@ export async function observeDeep(
|
|
|
80
84
|
buildOutput: opts.buildOutput,
|
|
81
85
|
entityNames,
|
|
82
86
|
entities: opts.entities,
|
|
87
|
+
...(opts.region ? { region: opts.region } : {}),
|
|
83
88
|
...(opts.owned !== undefined ? { owned: opts.owned } : {}),
|
|
84
89
|
};
|
|
85
90
|
try {
|
|
@@ -336,3 +336,41 @@ describe("diffSnapshots (#822)", () => {
|
|
|
336
336
|
expect(d.added).toEqual(["a", "b"]);
|
|
337
337
|
});
|
|
338
338
|
});
|
|
339
|
+
|
|
340
|
+
describe("attribute comparison is key-order insensitive (#1279)", () => {
|
|
341
|
+
const inst = (attributes: Record<string, unknown>): ResourceMetadata => ({
|
|
342
|
+
type: "AWS::EC2::Instance",
|
|
343
|
+
status: "OBSERVED",
|
|
344
|
+
physicalId: "i-1",
|
|
345
|
+
attributes,
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
test("does not drift when a provider reorders the keys of a nested object", () => {
|
|
349
|
+
// Exactly what AWS did between two reads of the same untouched instance.
|
|
350
|
+
const result = diffLive({
|
|
351
|
+
declared: new Set(["one"]),
|
|
352
|
+
observedNow: { one: inst({ Placement: { Tenancy: "default", AvailabilityZone: "us-east-1c" } }) },
|
|
353
|
+
observedThen: { one: inst({ Placement: { AvailabilityZone: "us-east-1c", Tenancy: "default" } }) },
|
|
354
|
+
});
|
|
355
|
+
expect(result.driftedSinceSnapshot).toEqual([]);
|
|
356
|
+
expect(result.unchanged).toEqual(["one"]);
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
test("still reports a real change to a nested value", () => {
|
|
360
|
+
const result = diffLive({
|
|
361
|
+
declared: new Set(["one"]),
|
|
362
|
+
observedNow: { one: inst({ Placement: { AvailabilityZone: "us-east-1d" } }) },
|
|
363
|
+
observedThen: { one: inst({ Placement: { AvailabilityZone: "us-east-1c" } }) },
|
|
364
|
+
});
|
|
365
|
+
expect(result.driftedSinceSnapshot.map((d) => d.changes[0].path)).toEqual(["attributes.Placement"]);
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
test("keeps array order significant — for a list, order is part of the value", () => {
|
|
369
|
+
const result = diffLive({
|
|
370
|
+
declared: new Set(["one"]),
|
|
371
|
+
observedNow: { one: inst({ Rules: [{ p: 80 }, { p: 22 }] }) },
|
|
372
|
+
observedThen: { one: inst({ Rules: [{ p: 22 }, { p: 80 }] }) },
|
|
373
|
+
});
|
|
374
|
+
expect(result.driftedSinceSnapshot).toHaveLength(1);
|
|
375
|
+
});
|
|
376
|
+
});
|