@intentius/chant 0.37.2 → 0.39.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/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-mcp.d.ts +44 -0
- package/dist/cli/commands/check-lexicon-mcp.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts +57 -0
- package/dist/cli/commands/check-lexicon-plugin.d.ts.map +1 -0
- package/dist/cli/commands/check-lexicon.d.ts.map +1 -1
- package/dist/cli/handlers/emulator.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/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/mcp/server.d.ts +26 -2
- package/dist/cli/mcp/server.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/registry.d.ts +23 -0
- package/dist/codegen/registry.d.ts.map +1 -0
- 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/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/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 +85 -37
- 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/live-endpoint.d.ts +21 -22
- package/dist/live-endpoint.d.ts.map +1 -1
- package/dist/managed-fields.d.ts +11 -0
- package/dist/managed-fields.d.ts.map +1 -1
- package/dist/op/emulator-freshness.d.ts +44 -0
- package/dist/op/emulator-freshness.d.ts.map +1 -0
- package/dist/op/emulator-lifecycle.d.ts +36 -0
- package/dist/op/emulator-lifecycle.d.ts.map +1 -1
- package/dist/op/index.d.ts +4 -2
- package/dist/op/index.d.ts.map +1 -1
- package/dist/ownership.d.ts +33 -0
- package/dist/ownership.d.ts.map +1 -1
- package/dist/serializer.d.ts +15 -0
- package/dist/serializer.d.ts.map +1 -1
- package/package.json +1 -1
- package/src/audit/catalog.test.ts +58 -6
- package/src/build.ts +24 -0
- package/src/cli/commands/build.ts +10 -0
- package/src/cli/commands/check-lexicon-doc-drift.test.ts +73 -0
- package/src/cli/commands/check-lexicon-mcp.test.ts +93 -0
- package/src/cli/commands/check-lexicon-mcp.ts +103 -0
- package/src/cli/commands/check-lexicon-plugin.test.ts +149 -0
- package/src/cli/commands/check-lexicon-plugin.ts +115 -0
- package/src/cli/commands/check-lexicon.ts +176 -26
- package/src/cli/handlers/components.test.ts +17 -0
- package/src/cli/handlers/components.ts +1 -1
- package/src/cli/handlers/emulator.ts +12 -8
- package/src/cli/handlers/graph.test.ts +71 -12
- package/src/cli/handlers/graph.ts +46 -5
- package/src/cli/handlers/lifecycle.test.ts +25 -4
- package/src/cli/handlers/lifecycle.ts +19 -3
- 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/mcp/server.test.ts +82 -0
- package/src/cli/mcp/server.ts +40 -5
- 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/registry.test.ts +56 -0
- package/src/codegen/registry.ts +69 -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/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/kubectl-context.ts +4 -1
- package/src/lexicon-config.test.ts +111 -0
- package/src/lexicon-config.ts +92 -0
- package/src/lexicon-doc-coverage.test.ts +128 -0
- package/src/lexicon-seams.test.ts +113 -0
- package/src/lexicon.ts +88 -38
- 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/live-endpoint.test.ts +51 -12
- package/src/live-endpoint.ts +32 -33
- package/src/managed-fields.test.ts +50 -0
- package/src/managed-fields.ts +25 -6
- package/src/meta/peer-deps.test.ts +111 -14
- package/src/op/emulator-declaration.test.ts +63 -0
- package/src/op/emulator-freshness.test.ts +135 -0
- package/src/op/emulator-freshness.ts +102 -0
- package/src/op/emulator-lifecycle.ts +49 -0
- package/src/op/index.ts +4 -2
- package/src/ownership.ts +41 -0
- package/src/serializer.ts +16 -0
|
@@ -59,6 +59,17 @@ export interface PropertyDrift {
|
|
|
59
59
|
* accepted value there would lose the most useful column in the report.
|
|
60
60
|
*/
|
|
61
61
|
baseline?: unknown;
|
|
62
|
+
/**
|
|
63
|
+
* The field manager that owns this path live, where the substrate records one
|
|
64
|
+
* (#1189) — Kubernetes' `managedFields`, and nowhere else today.
|
|
65
|
+
*
|
|
66
|
+
* `kind` says a path is `undeclared` or `changed`; this says who did it.
|
|
67
|
+
* "Owned by `kubectl-client-side-apply`" and "owned by `hpa-controller`" are
|
|
68
|
+
* the same `kind` and mean opposite things: one is somebody bypassing the
|
|
69
|
+
* pipeline, the other is a controller doing its job. Absent on a substrate
|
|
70
|
+
* with no per-field ownership, which is every substrate but k8s.
|
|
71
|
+
*/
|
|
72
|
+
owner?: string;
|
|
62
73
|
}
|
|
63
74
|
|
|
64
75
|
/** Property-level drift for one declared entity. */
|
|
@@ -173,11 +184,16 @@ export function diffDeep(input: DiffDeepInput): DeepDiffResult {
|
|
|
173
184
|
if (hasDeclared && hasLive && deepValueEqual(declaredValue, liveValue)) continue;
|
|
174
185
|
|
|
175
186
|
const kind: PropertyDriftKind = !hasDeclared ? "undeclared" : !hasLive ? "absent" : "changed";
|
|
187
|
+
// Who owns the path live, where the substrate records it (#1189). Only
|
|
188
|
+
// meaningful for a path that exists live — an `absent` drift has no live
|
|
189
|
+
// field for anyone to own.
|
|
190
|
+
const owner = hasLive ? liveEntity.fieldOwners?.[path] : undefined;
|
|
176
191
|
const drift: PropertyDrift = {
|
|
177
192
|
path,
|
|
178
193
|
kind,
|
|
179
194
|
...(hasDeclared ? { declared: declaredValue } : {}),
|
|
180
195
|
...(hasLive ? { live: liveValue } : {}),
|
|
196
|
+
...(owner ? { owner } : {}),
|
|
181
197
|
};
|
|
182
198
|
|
|
183
199
|
const acceptedEntry = acceptedDeviation(baseline, name, path);
|
|
@@ -115,3 +115,84 @@ describe("diffDigests", () => {
|
|
|
115
115
|
expect(result.removed.sort()).toEqual(["c"]);
|
|
116
116
|
});
|
|
117
117
|
});
|
|
118
|
+
|
|
119
|
+
/**
|
|
120
|
+
* chant #1442 — a digest records what interpreted the declarations, not only
|
|
121
|
+
* what was declared.
|
|
122
|
+
*/
|
|
123
|
+
function withVersions(versions: Record<string, string> | undefined): BuildResult {
|
|
124
|
+
const result = makeBuildResult({ k8s: [{ name: "app", type: "K8s::Apps::Deployment", props: { replicas: 2 } }] });
|
|
125
|
+
return { ...result, lexiconVersions: versions } as unknown as BuildResult;
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
describe("computeBuildDigest — lexicon versions (#1442)", () => {
|
|
129
|
+
test("records the version of each lexicon that served the build", () => {
|
|
130
|
+
expect(computeBuildDigest(withVersions({ k8s: "0.38.0" })).lexiconVersions).toEqual({ k8s: "0.38.0" });
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test("records once per lexicon, not once per resource", () => {
|
|
134
|
+
const many = makeBuildResult({
|
|
135
|
+
k8s: [
|
|
136
|
+
{ name: "a", type: "K8s::Apps::Deployment", props: {} },
|
|
137
|
+
{ name: "b", type: "K8s::Core::Service", props: {} },
|
|
138
|
+
],
|
|
139
|
+
});
|
|
140
|
+
const digest = computeBuildDigest({ ...many, lexiconVersions: { k8s: "0.38.0" } } as unknown as BuildResult);
|
|
141
|
+
expect(Object.keys(digest.resources)).toHaveLength(2);
|
|
142
|
+
expect(digest.lexiconVersions).toEqual({ k8s: "0.38.0" });
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
test("a build with no plugins records an empty map, not absence", () => {
|
|
146
|
+
// Absent and empty mean different things when read back: absent is
|
|
147
|
+
// "recorded before #1442", empty is "recorded, nothing loaded".
|
|
148
|
+
expect(computeBuildDigest(withVersions(undefined)).lexiconVersions).toEqual({});
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("the recorded map is a copy, so later mutation cannot rewrite history", () => {
|
|
152
|
+
const versions = { k8s: "0.38.0" };
|
|
153
|
+
const digest = computeBuildDigest(withVersions(versions));
|
|
154
|
+
versions.k8s = "0.39.0";
|
|
155
|
+
expect(digest.lexiconVersions).toEqual({ k8s: "0.38.0" });
|
|
156
|
+
});
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
describe("diffDigests — lexicon version changes (#1442)", () => {
|
|
160
|
+
const resources = { app: { type: "K8s::Apps::Deployment", lexicon: "k8s", propsHash: "same" } };
|
|
161
|
+
const digest = (lexiconVersions?: Record<string, string>): BuildDigest =>
|
|
162
|
+
({ resources, dependencies: {}, outputs: {}, deployOrder: ["k8s"], lexiconVersions }) as BuildDigest;
|
|
163
|
+
|
|
164
|
+
test("reports a bump even when every resource is unchanged", () => {
|
|
165
|
+
const diff = diffDigests(digest({ k8s: "0.39.0" }), digest({ k8s: "0.38.0" }));
|
|
166
|
+
expect(diff.changed).toEqual([]);
|
|
167
|
+
expect(diff.unchanged).toEqual(["app"]);
|
|
168
|
+
expect(diff.lexiconVersionChanges).toEqual([{ lexicon: "k8s", previous: "0.38.0", current: "0.39.0" }]);
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
test("reports nothing when versions match", () => {
|
|
172
|
+
expect(diffDigests(digest({ k8s: "0.38.0" }), digest({ k8s: "0.38.0" })).lexiconVersionChanges).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
test("reports a lexicon added to or dropped from the build", () => {
|
|
176
|
+
const added = diffDigests(digest({ k8s: "0.38.0", aws: "0.38.0" }), digest({ k8s: "0.38.0" }));
|
|
177
|
+
expect(added.lexiconVersionChanges).toEqual([{ lexicon: "aws", previous: undefined, current: "0.38.0" }]);
|
|
178
|
+
|
|
179
|
+
const dropped = diffDigests(digest({ k8s: "0.38.0" }), digest({ k8s: "0.38.0", aws: "0.38.0" }));
|
|
180
|
+
expect(dropped.lexiconVersionChanges).toEqual([{ lexicon: "aws", previous: "0.38.0", current: undefined }]);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test("a pre-#1442 snapshot reports no change rather than inventing one", () => {
|
|
184
|
+
// The older digest never recorded versions. Every lexicon would otherwise
|
|
185
|
+
// look newly-added on the first comparison after upgrading chant.
|
|
186
|
+
expect(diffDigests(digest({ k8s: "0.38.0" }), digest(undefined)).lexiconVersionChanges).toEqual([]);
|
|
187
|
+
expect(diffDigests(digest(undefined), digest({ k8s: "0.38.0" })).lexiconVersionChanges).toEqual([]);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
test("with no previous digest at all, there is no version change", () => {
|
|
191
|
+
expect(diffDigests(digest({ k8s: "0.38.0" }), undefined).lexiconVersionChanges).toEqual([]);
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
test("changes are ordered by lexicon name, so output is stable", () => {
|
|
195
|
+
const diff = diffDigests(digest({ k8s: "2", aws: "2", gcp: "2" }), digest({ k8s: "1", aws: "1", gcp: "1" }));
|
|
196
|
+
expect(diff.lexiconVersionChanges.map((c) => c.lexicon)).toEqual(["aws", "gcp", "k8s"]);
|
|
197
|
+
});
|
|
198
|
+
});
|
package/src/lifecycle/digest.ts
CHANGED
|
@@ -41,6 +41,10 @@ export function computeBuildDigest(buildResult: BuildResult): BuildDigest {
|
|
|
41
41
|
|
|
42
42
|
return {
|
|
43
43
|
resources,
|
|
44
|
+
// chant #1442 — what interpreted the declarations, alongside what was
|
|
45
|
+
// declared. Always present on a freshly computed digest, so "absent"
|
|
46
|
+
// unambiguously means "recorded before #1442".
|
|
47
|
+
lexiconVersions: { ...buildResult.lexiconVersions },
|
|
44
48
|
dependencies,
|
|
45
49
|
outputs: buildResult.manifest.outputs,
|
|
46
50
|
deployOrder: buildResult.manifest.deployOrder,
|
|
@@ -60,9 +64,10 @@ export function diffDigests(
|
|
|
60
64
|
const unchanged: string[] = [];
|
|
61
65
|
|
|
62
66
|
if (!previous) {
|
|
63
|
-
// No previous digest — everything is added
|
|
67
|
+
// No previous digest — everything is added, and there is no version to
|
|
68
|
+
// have moved away from.
|
|
64
69
|
added.push(...Object.keys(current.resources));
|
|
65
|
-
return { added, removed, changed, unchanged };
|
|
70
|
+
return { added, removed, changed, unchanged, lexiconVersionChanges: [] };
|
|
66
71
|
}
|
|
67
72
|
|
|
68
73
|
// Check current resources against previous
|
|
@@ -84,5 +89,31 @@ export function diffDigests(
|
|
|
84
89
|
}
|
|
85
90
|
}
|
|
86
91
|
|
|
87
|
-
return { added, removed, changed, unchanged };
|
|
92
|
+
return { added, removed, changed, unchanged, lexiconVersionChanges: diffLexiconVersions(current, previous) };
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Lexicons whose version moved between two digests (chant #1442).
|
|
97
|
+
*
|
|
98
|
+
* A digest recorded before this existed has no `lexiconVersions` at all. That
|
|
99
|
+
* is reported as no change rather than as every lexicon appearing — comparing
|
|
100
|
+
* against an older snapshot must not manufacture a difference that the older
|
|
101
|
+
* build simply never recorded.
|
|
102
|
+
*/
|
|
103
|
+
function diffLexiconVersions(
|
|
104
|
+
current: BuildDigest,
|
|
105
|
+
previous: BuildDigest,
|
|
106
|
+
): Array<{ lexicon: string; previous?: string; current?: string }> {
|
|
107
|
+
if (!current.lexiconVersions || !previous.lexiconVersions) return [];
|
|
108
|
+
|
|
109
|
+
const changes: Array<{ lexicon: string; previous?: string; current?: string }> = [];
|
|
110
|
+
for (const lexicon of new Set([
|
|
111
|
+
...Object.keys(current.lexiconVersions),
|
|
112
|
+
...Object.keys(previous.lexiconVersions),
|
|
113
|
+
])) {
|
|
114
|
+
const now = current.lexiconVersions[lexicon];
|
|
115
|
+
const before = previous.lexiconVersions[lexicon];
|
|
116
|
+
if (now !== before) changes.push({ lexicon, previous: before, current: now });
|
|
117
|
+
}
|
|
118
|
+
return changes.sort((a, b) => a.lexicon.localeCompare(b.lexicon));
|
|
88
119
|
}
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import { regionOf, unqualifiedKey } from "./identity";
|
|
3
|
+
|
|
4
|
+
describe("regionOf", () => {
|
|
5
|
+
it("reads the region a lexicon stamped", () => {
|
|
6
|
+
expect(regionOf({ attributes: { region: "us-west-1" } })).toBe("us-west-1");
|
|
7
|
+
});
|
|
8
|
+
|
|
9
|
+
it("treats a missing, empty or non-string region as none", () => {
|
|
10
|
+
expect(regionOf({})).toBeUndefined();
|
|
11
|
+
expect(regionOf({ attributes: {} })).toBeUndefined();
|
|
12
|
+
expect(regionOf({ attributes: { region: "" } })).toBeUndefined();
|
|
13
|
+
expect(regionOf({ attributes: { region: 1 } })).toBeUndefined();
|
|
14
|
+
});
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
describe("unqualifiedKey", () => {
|
|
18
|
+
it("carries the region when there is one", () => {
|
|
19
|
+
expect(unqualifiedKey("sg-1", { attributes: { region: "us-east-1" } })).toBe("us-east-1::sg-1");
|
|
20
|
+
});
|
|
21
|
+
|
|
22
|
+
it("leaves an id with no region bare", () => {
|
|
23
|
+
expect(unqualifiedKey("arn:aws:iam::1:policy/p", {})).toBe("arn:aws:iam::1:policy/p");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("is idempotent, so a second pass does not double-qualify", () => {
|
|
27
|
+
const meta = { attributes: { region: "us-east-1" } };
|
|
28
|
+
expect(unqualifiedKey(unqualifiedKey("sg-1", meta), meta)).toBe("us-east-1::sg-1");
|
|
29
|
+
});
|
|
30
|
+
|
|
31
|
+
it("qualifies an id that already carries a DIFFERENT region", () => {
|
|
32
|
+
// Not a case the readers produce, but the guard is a prefix test and it
|
|
33
|
+
// should not silently accept `us-west-1::sg-1` as already being in
|
|
34
|
+
// us-east-1.
|
|
35
|
+
expect(unqualifiedKey("us-west-1::sg-1", { attributes: { region: "us-east-1" } })).toBe(
|
|
36
|
+
"us-east-1::us-west-1::sg-1",
|
|
37
|
+
);
|
|
38
|
+
});
|
|
39
|
+
});
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What makes an unqualified resource one resource (#1416).
|
|
3
|
+
*
|
|
4
|
+
* Managed resources are stack-qualified (`${stack}::${id}`, #1162) and cannot
|
|
5
|
+
* collide. Everything else — dependencies (#1273) and ambient resources (#1278)
|
|
6
|
+
* — is keyed by physical id and merged across stacks, because the account's
|
|
7
|
+
* default VPC route table is one resource however many stacks route through it.
|
|
8
|
+
*
|
|
9
|
+
* That is right for a resource that really is account-level, and wrong for a
|
|
10
|
+
* regional one. A VPC, subnet or security group in `us-west-1` is a different
|
|
11
|
+
* resource from one in `us-east-1`, and merging them by id drops all but the
|
|
12
|
+
* first sighting.
|
|
13
|
+
*
|
|
14
|
+
* On AWS the difference is invisible: VPC, subnet and security-group ids are
|
|
15
|
+
* globally unique, so keying by id and keying by (region, id) give the same
|
|
16
|
+
* answer. It shows up against an emulator that reuses ids across regions
|
|
17
|
+
* (lex00/floci#21), where a three-region estate's nine default subnets merged
|
|
18
|
+
* to three and "how many subnets are empty" came back 2 instead of 8.
|
|
19
|
+
*
|
|
20
|
+
* The rule is deliberately not a table of regional kinds. A lexicon stamps a
|
|
21
|
+
* region on a resource when region is part of that resource's identity — the
|
|
22
|
+
* AWS lexicon does it in `stampRegion` for ambient resources and its own
|
|
23
|
+
* property reads — and this reads that stamp back. A genuinely account-level
|
|
24
|
+
* resource carries no region and keeps merging as it always did, which is why
|
|
25
|
+
* dependencies (never stamped) are untouched by this.
|
|
26
|
+
*/
|
|
27
|
+
|
|
28
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* The region a resource records itself as being in, if it records one.
|
|
32
|
+
*
|
|
33
|
+
* Read from `attributes.region`, which is where a lexicon stamps it. Absent
|
|
34
|
+
* means the resource is account-level, or the lexicon has no region to give —
|
|
35
|
+
* both of which are read the same way, as "region is not part of this id".
|
|
36
|
+
*/
|
|
37
|
+
export function regionOf(meta: Pick<ResourceMetadata, "attributes">): string | undefined {
|
|
38
|
+
const region = meta.attributes?.region;
|
|
39
|
+
return typeof region === "string" && region.length > 0 ? region : undefined;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The key an unqualified resource merges under.
|
|
44
|
+
*
|
|
45
|
+
* `${region}::${id}` for a resource that carries a region, matching the shape
|
|
46
|
+
* stack qualification already uses, and the bare id for one that does not.
|
|
47
|
+
*
|
|
48
|
+
* Applied only where results from more than one region are actually merged —
|
|
49
|
+
* `collectAmbient` across stacks, `replaySnapshots` across recorded stacks.
|
|
50
|
+
* Recording one region's observation qualifies nothing, so a stored snapshot
|
|
51
|
+
* keeps the keys it has always had and the first snapshot taken after this
|
|
52
|
+
* lands does not diff as "every ambient resource replaced".
|
|
53
|
+
*
|
|
54
|
+
* Idempotent, so an id that already carries this region survives a second pass
|
|
55
|
+
* unchanged rather than becoming `us-east-1::us-east-1::sg-1`.
|
|
56
|
+
*/
|
|
57
|
+
export function unqualifiedKey(id: string, meta: Pick<ResourceMetadata, "attributes">): string {
|
|
58
|
+
const region = regionOf(meta);
|
|
59
|
+
if (!region) return id;
|
|
60
|
+
return id.startsWith(`${region}::`) ? id : `${region}::${id}`;
|
|
61
|
+
}
|
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { describe, it, expect, vi } from "vitest";
|
|
2
|
-
import { observeResources } from "./observe";
|
|
2
|
+
import { observeResources, collectAmbient } from "./observe";
|
|
3
3
|
import { observation } from "../observation";
|
|
4
4
|
import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
|
|
5
5
|
import type { BuildResult } from "../build";
|
|
@@ -246,3 +246,77 @@ describe("observeResources", () => {
|
|
|
246
246
|
expect(calls[0]).not.toHaveProperty("stack");
|
|
247
247
|
});
|
|
248
248
|
});
|
|
249
|
+
|
|
250
|
+
describe("collectAmbient — one resource per region, not per account (#1416)", () => {
|
|
251
|
+
const subnets = (region: string): Record<string, ResourceMetadata> =>
|
|
252
|
+
Object.fromEntries(
|
|
253
|
+
["subnet-default-a", "subnet-default-b"].map((id) => [
|
|
254
|
+
id,
|
|
255
|
+
{
|
|
256
|
+
type: "AWS::EC2::Subnet",
|
|
257
|
+
status: "OBSERVED",
|
|
258
|
+
physicalId: id,
|
|
259
|
+
ambient: true,
|
|
260
|
+
attributes: { region },
|
|
261
|
+
} as ResourceMetadata,
|
|
262
|
+
]),
|
|
263
|
+
);
|
|
264
|
+
|
|
265
|
+
function ambientPlugin(): ObservationLexicon {
|
|
266
|
+
return {
|
|
267
|
+
name: "aws",
|
|
268
|
+
describeResources: async () => ({}),
|
|
269
|
+
observeAmbient: async (opts: { region?: string }) => subnets(opts.region ?? "us-east-1"),
|
|
270
|
+
} as unknown as ObservationLexicon;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
it("keeps every region's copy when the ids collide across regions", async () => {
|
|
274
|
+
const found = await collectAmbient(ambientPlugin(), {
|
|
275
|
+
environment: "prod",
|
|
276
|
+
kinds: ["AWS::EC2::Subnet"],
|
|
277
|
+
observed: {},
|
|
278
|
+
stacks: [
|
|
279
|
+
{ name: "east", region: "us-east-1" },
|
|
280
|
+
{ name: "west1", region: "us-west-1" },
|
|
281
|
+
{ name: "west2", region: "us-west-2" },
|
|
282
|
+
],
|
|
283
|
+
warnings: [],
|
|
284
|
+
});
|
|
285
|
+
expect(Object.keys(found).sort()).toEqual([
|
|
286
|
+
"us-east-1::subnet-default-a",
|
|
287
|
+
"us-east-1::subnet-default-b",
|
|
288
|
+
"us-west-1::subnet-default-a",
|
|
289
|
+
"us-west-1::subnet-default-b",
|
|
290
|
+
"us-west-2::subnet-default-a",
|
|
291
|
+
"us-west-2::subnet-default-b",
|
|
292
|
+
]);
|
|
293
|
+
});
|
|
294
|
+
|
|
295
|
+
it("still merges two stacks in the same region to one resource each", async () => {
|
|
296
|
+
const found = await collectAmbient(ambientPlugin(), {
|
|
297
|
+
environment: "prod",
|
|
298
|
+
kinds: ["AWS::EC2::Subnet"],
|
|
299
|
+
observed: {},
|
|
300
|
+
stacks: [
|
|
301
|
+
{ name: "web", region: "us-east-1" },
|
|
302
|
+
{ name: "api", region: "us-east-1" },
|
|
303
|
+
],
|
|
304
|
+
warnings: [],
|
|
305
|
+
});
|
|
306
|
+
expect(Object.keys(found).sort()).toEqual([
|
|
307
|
+
"us-east-1::subnet-default-a",
|
|
308
|
+
"us-east-1::subnet-default-b",
|
|
309
|
+
]);
|
|
310
|
+
});
|
|
311
|
+
|
|
312
|
+
it("leaves a single stack's ids bare — one region is not a merge", async () => {
|
|
313
|
+
const found = await collectAmbient(ambientPlugin(), {
|
|
314
|
+
environment: "prod",
|
|
315
|
+
kinds: ["AWS::EC2::Subnet"],
|
|
316
|
+
observed: {},
|
|
317
|
+
stacks: [{ name: "main", region: "us-east-1" }],
|
|
318
|
+
warnings: [],
|
|
319
|
+
});
|
|
320
|
+
expect(Object.keys(found).sort()).toEqual(["subnet-default-a", "subnet-default-b"]);
|
|
321
|
+
});
|
|
322
|
+
});
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -24,6 +24,7 @@ import {
|
|
|
24
24
|
type NormalizedObservation,
|
|
25
25
|
} from "../observation";
|
|
26
26
|
import { zeroResourcesWarning } from "../live-endpoint";
|
|
27
|
+
import { unqualifiedKey } from "./identity";
|
|
27
28
|
|
|
28
29
|
export interface ObserveResult {
|
|
29
30
|
observations: LiveObservation[];
|
|
@@ -208,7 +209,21 @@ export async function observeResources(
|
|
|
208
209
|
warnings,
|
|
209
210
|
})
|
|
210
211
|
: {};
|
|
211
|
-
|
|
212
|
+
// A resource can be reached as a dependency AND enumerated as ambient —
|
|
213
|
+
// the default VPC an instance sits in is both. It is one resource, and
|
|
214
|
+
// the dependency entry is the one carrying `referencedBy`, so it wins.
|
|
215
|
+
// Matched on physical id rather than on key, because ambient keys carry a
|
|
216
|
+
// region (#1416) and dependency keys do not, so the keys no longer
|
|
217
|
+
// collide even when the resource is the same one.
|
|
218
|
+
const dependedOn = new Set(
|
|
219
|
+
Object.values(dependencies.resources)
|
|
220
|
+
.map((m) => m.physicalId)
|
|
221
|
+
.filter((id): id is string => typeof id === "string"),
|
|
222
|
+
);
|
|
223
|
+
for (const [id, meta] of Object.entries(ambient)) {
|
|
224
|
+
if (meta.physicalId && dependedOn.has(meta.physicalId)) continue;
|
|
225
|
+
dependencies.resources[id] ??= meta;
|
|
226
|
+
}
|
|
212
227
|
pushObservation(
|
|
213
228
|
observations,
|
|
214
229
|
warnings,
|
|
@@ -268,6 +283,11 @@ function scopeToStack(
|
|
|
268
283
|
* Ask a lexicon what exists of the kinds it manages, beyond what is declared
|
|
269
284
|
* (#1278). Once per stack for the region, merged by physical id — the same
|
|
270
285
|
* ambient resource seen from two stacks is one resource.
|
|
286
|
+
*
|
|
287
|
+
* "The same" is per region (#1416). Two stacks in one region reporting the
|
|
288
|
+
* account's default security group is one group; two regions' default security
|
|
289
|
+
* groups are two, whatever their ids look like, so the merge key carries the
|
|
290
|
+
* region a resource records itself in.
|
|
271
291
|
*/
|
|
272
292
|
export async function collectAmbient(
|
|
273
293
|
plugin: ObservationLexicon,
|
|
@@ -282,6 +302,10 @@ export async function collectAmbient(
|
|
|
282
302
|
if (!plugin.observeAmbient || opts.kinds.length === 0) return {};
|
|
283
303
|
const found: Record<string, ResourceMetadata> = {};
|
|
284
304
|
const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }];
|
|
305
|
+
// Only a merge needs region-qualified keys, and one ref is not a merge. This
|
|
306
|
+
// is what keeps a single-region project's ids exactly what they were, and a
|
|
307
|
+
// recorded snapshot — always one stack, so always one ref — bare.
|
|
308
|
+
const merging = refs.length > 1;
|
|
285
309
|
for (const ref of refs) {
|
|
286
310
|
try {
|
|
287
311
|
const part = await plugin.observeAmbient({
|
|
@@ -291,7 +315,9 @@ export async function collectAmbient(
|
|
|
291
315
|
...(ref.name ? { stack: ref.name } : {}),
|
|
292
316
|
...(ref.region ? { region: ref.region } : {}),
|
|
293
317
|
});
|
|
294
|
-
for (const [id, meta] of Object.entries(part))
|
|
318
|
+
for (const [id, meta] of Object.entries(part)) {
|
|
319
|
+
found[merging ? unqualifiedKey(id, meta) : id] ??= meta;
|
|
320
|
+
}
|
|
295
321
|
} catch (err) {
|
|
296
322
|
opts.warnings.push(
|
|
297
323
|
`${plugin.name}: ambient resources not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${err instanceof Error ? err.message : String(err)}`,
|
|
@@ -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
|
+
});
|