@intentius/chant 0.33.0 → 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 +72 -0
- 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-effective.d.ts.map +1 -1
- package/dist/graph-ir.d.ts +21 -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/dist/observation.d.ts +71 -0
- package/dist/observation.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 +246 -4
- package/src/cli/handlers/search.ts +432 -27
- 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 +174 -0
- package/src/graph-effective.ts +7 -1
- package/src/graph-ir-live.test.ts +83 -0
- package/src/graph-ir.ts +58 -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
- package/src/observation.test.ts +135 -0
- package/src/observation.ts +151 -0
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Replay a recorded snapshot as if it had just been observed (#1266, #1279).
|
|
3
|
+
*
|
|
4
|
+
* Shared by `chant search --at` and `chant graph --at`. A snapshot already holds
|
|
5
|
+
* what an observation is — resources with their physical ids and attributes,
|
|
6
|
+
* and since #1266 the relationships between them — so turning it back into
|
|
7
|
+
* `LiveObservation[]` lets a replay rejoin the live path at `buildLiveGraphIr`.
|
|
8
|
+
* Every fold, overlay and edge reconstruction below that point is shared, and
|
|
9
|
+
* nothing downstream needs to know which source it got.
|
|
10
|
+
*
|
|
11
|
+
* That sharing is the reason this is a module rather than a helper inside one
|
|
12
|
+
* command: `graph` had no `--at` at all, so anyone wanting the raw IR of a
|
|
13
|
+
* recorded estate had to reach for the live endpoint, and a snapshot could
|
|
14
|
+
* answer most questions but never all of them.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { readEnvironmentSnapshots } from "./git";
|
|
18
|
+
import type { LiveObservation } from "../graph-ir";
|
|
19
|
+
import type { LifecycleSnapshot } from "./types";
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Rebuild observations from a recorded snapshot (#1266).
|
|
23
|
+
*
|
|
24
|
+
* A snapshot already holds what an observation is: resources with their
|
|
25
|
+
* physical ids and attributes, and — since #1266 — the relationships between
|
|
26
|
+
* them. Turning it back into `LiveObservation[]` means the replay rejoins the
|
|
27
|
+
* live path at `buildLiveGraphIr`, and every fold, overlay and query below that
|
|
28
|
+
* is shared. Nothing downstream needs to know which source it got.
|
|
29
|
+
*
|
|
30
|
+
* `latest` is the only ref for now. A specific commit is the natural extension
|
|
31
|
+
* and the storage already supports it (`readSnapshotAt`), but "answer from what
|
|
32
|
+
* is recorded" is the question worth settling first.
|
|
33
|
+
*/
|
|
34
|
+
/**
|
|
35
|
+
* Whether this environment has a recording, without reading one.
|
|
36
|
+
*
|
|
37
|
+
* Used to turn "the estate could not be read" into "the estate could not be
|
|
38
|
+
* read, and you already have a recording of it". Cheap and best-effort: a
|
|
39
|
+
* failure here means the caller says the plain version of the message, never
|
|
40
|
+
* that the command fails.
|
|
41
|
+
*/
|
|
42
|
+
export async function hasSnapshot(environment: string): Promise<boolean> {
|
|
43
|
+
try {
|
|
44
|
+
return (await readEnvironmentSnapshots(environment)).size > 0;
|
|
45
|
+
} catch {
|
|
46
|
+
return false;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
export async function replaySnapshots(
|
|
51
|
+
environment: string,
|
|
52
|
+
ref: string,
|
|
53
|
+
scopedStacks: Set<string>,
|
|
54
|
+
): Promise<{ observations: LiveObservation[]; commit: string; timestamp: string } | { error: string; hint?: string }> {
|
|
55
|
+
if (ref !== "latest" && ref !== "true") {
|
|
56
|
+
return {
|
|
57
|
+
error: `chant search --at only accepts "latest" for now, got "${ref}"`,
|
|
58
|
+
hint: "a specific snapshot commit is not wired up yet",
|
|
59
|
+
};
|
|
60
|
+
}
|
|
61
|
+
const stored = await readEnvironmentSnapshots(environment);
|
|
62
|
+
if (stored.size === 0) {
|
|
63
|
+
return {
|
|
64
|
+
error: `No snapshots found for environment "${environment}"`,
|
|
65
|
+
hint: `Record one first: chant lifecycle snapshot ${environment}`,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
const observations: LiveObservation[] = [];
|
|
69
|
+
let commit = "";
|
|
70
|
+
let timestamp = "";
|
|
71
|
+
// Ambient and dependency resources are keyed by physical id and are
|
|
72
|
+
// account-level: the default security group three stacks each recorded is one
|
|
73
|
+
// group, not three. Managed resources are stack-qualified below and cannot
|
|
74
|
+
// collide, so only the unqualified ones need this.
|
|
75
|
+
const seenUnqualified = new Set<string>();
|
|
76
|
+
// A stack's snapshot could only exclude what THAT stack manages, so a stack
|
|
77
|
+
// declaring no security groups reported the neighbouring stack's as ambient.
|
|
78
|
+
// The union is only knowable here, with every snapshot in hand.
|
|
79
|
+
const managedPhysicalIds = new Set<string>();
|
|
80
|
+
for (const content of stored.values()) {
|
|
81
|
+
const snap = JSON.parse(content) as LifecycleSnapshot;
|
|
82
|
+
for (const meta of Object.values(snap.resources ?? {})) {
|
|
83
|
+
if (!meta.ambient && !meta.referencedBy?.length && meta.physicalId) {
|
|
84
|
+
managedPhysicalIds.add(meta.physicalId);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
for (const [key, content] of stored) {
|
|
89
|
+
const snapshot = JSON.parse(content) as LifecycleSnapshot;
|
|
90
|
+
// The storage key is `<stack>__<lexicon>` for a multi-stack project; the
|
|
91
|
+
// snapshot carries its own lexicon, which is the one to trust.
|
|
92
|
+
const lexicon = snapshot.lexicon ?? key;
|
|
93
|
+
// A scoped stack's ids are qualified `${stack}::${id}` on the live path
|
|
94
|
+
// (#1162), because the same bare LogicalResourceId exists in every region's
|
|
95
|
+
// stack. A snapshot stores them bare, so a replay has to re-apply the same
|
|
96
|
+
// rule — otherwise `server` from us-west-1 and `server` from us-west-2
|
|
97
|
+
// collide, and none of them join the declared canvas, which qualifies.
|
|
98
|
+
const stack = snapshot.stack;
|
|
99
|
+
const qualify = stack !== undefined && scopedStacks.has(stack);
|
|
100
|
+
// Dependencies (#1273) are keyed by physical id and are account-level: the
|
|
101
|
+
// default VPC's route table is one resource however many stacks route
|
|
102
|
+
// through it. Qualifying those would split it per stack and break the
|
|
103
|
+
// edges into it.
|
|
104
|
+
const managed = (id: string, meta: { referencedBy?: string[]; ambient?: boolean }): string =>
|
|
105
|
+
qualify && !meta.ambient && !(meta.referencedBy && meta.referencedBy.length > 0)
|
|
106
|
+
? `${stack}::${id}`
|
|
107
|
+
: id;
|
|
108
|
+
const resources: Record<string, (typeof snapshot.resources)[string]> = {};
|
|
109
|
+
for (const [id, meta] of Object.entries(snapshot.resources ?? {})) {
|
|
110
|
+
const key = managed(id, meta);
|
|
111
|
+
if (key === id) {
|
|
112
|
+
// Ambient means "nothing manages this". Another stack managing it makes
|
|
113
|
+
// that false, and reporting it twice would inflate any count over it.
|
|
114
|
+
if (meta.ambient && meta.physicalId && managedPhysicalIds.has(meta.physicalId)) continue;
|
|
115
|
+
// Unqualified: account-level, so first sighting wins and the rest are
|
|
116
|
+
// the same resource seen again from another stack's snapshot.
|
|
117
|
+
if (seenUnqualified.has(id)) continue;
|
|
118
|
+
seenUnqualified.add(id);
|
|
119
|
+
}
|
|
120
|
+
resources[key] = meta;
|
|
121
|
+
}
|
|
122
|
+
const known = new Set(Object.keys(snapshot.resources ?? {}));
|
|
123
|
+
const requalify = (id: string): string => {
|
|
124
|
+
const meta = (snapshot.resources ?? {})[id];
|
|
125
|
+
return known.has(id) && meta ? managed(id, meta) : id;
|
|
126
|
+
};
|
|
127
|
+
const edges = (snapshot.edges ?? []).map((e) => ({ ...e, from: requalify(e.from), to: requalify(e.to) }));
|
|
128
|
+
observations.push({
|
|
129
|
+
lexicon,
|
|
130
|
+
resources,
|
|
131
|
+
...(edges.length > 0 ? { edges } : {}),
|
|
132
|
+
});
|
|
133
|
+
commit ||= snapshot.commit ?? "";
|
|
134
|
+
// Report the OLDEST timestamp across stacks: a caller asking how stale this
|
|
135
|
+
// answer is wants the weakest link, not the freshest one.
|
|
136
|
+
if (!timestamp || (snapshot.timestamp && snapshot.timestamp < timestamp)) {
|
|
137
|
+
timestamp = snapshot.timestamp ?? timestamp;
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
return { observations, commit, timestamp };
|
|
141
|
+
}
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
import { describe, test, expect, vi, beforeEach } from "vitest";
|
|
2
2
|
import { createMockPlugin, staticDescribeResources, staticObservation, staticListArtifacts } from "@intentius/chant-test-utils";
|
|
3
3
|
import type { BuildResult } from "../build";
|
|
4
|
+
import type { DeepResourceObservation } from "../deep-observation";
|
|
5
|
+
import type { UnobservedEntity } from "../observation";
|
|
4
6
|
|
|
5
7
|
const writeSnapshotMock = vi.fn();
|
|
6
8
|
const getHeadCommitMock = vi.fn();
|
|
@@ -83,6 +85,126 @@ describe("takeSnapshot", () => {
|
|
|
83
85
|
expect(writeSnapshotMock.mock.calls[0][1]).toBe("loom-backend__aws");
|
|
84
86
|
});
|
|
85
87
|
|
|
88
|
+
test("region option: the stack's own region reaches describeResources (#1261)", async () => {
|
|
89
|
+
let observedRegion: string | undefined = "unset";
|
|
90
|
+
const plugin = createMockPlugin({
|
|
91
|
+
name: "aws",
|
|
92
|
+
describeResources: async (options: { region?: string }) => {
|
|
93
|
+
observedRegion = options.region;
|
|
94
|
+
return { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
|
|
95
|
+
},
|
|
96
|
+
});
|
|
97
|
+
await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), {
|
|
98
|
+
stack: "loom-us-west-2",
|
|
99
|
+
region: "us-west-2",
|
|
100
|
+
});
|
|
101
|
+
// Without this the reader falls back to the ambient region, and every stack
|
|
102
|
+
// outside it snapshots as "no valid resources or artifacts returned".
|
|
103
|
+
expect(observedRegion).toBe("us-west-2");
|
|
104
|
+
});
|
|
105
|
+
|
|
106
|
+
test("no region declared: describeResources keeps its ambient-region default", async () => {
|
|
107
|
+
let observedRegion: string | undefined = "unset";
|
|
108
|
+
const plugin = createMockPlugin({
|
|
109
|
+
name: "aws",
|
|
110
|
+
describeResources: async (options: { region?: string }) => {
|
|
111
|
+
observedRegion = options.region;
|
|
112
|
+
return { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
|
|
113
|
+
},
|
|
114
|
+
});
|
|
115
|
+
await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }));
|
|
116
|
+
expect(observedRegion).toBeUndefined();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
// #1267 — a snapshot records identity by default; --deep also records each
|
|
120
|
+
// resource's property tree, which is what a fold over topology needs.
|
|
121
|
+
describe("deep snapshots (#1267)", () => {
|
|
122
|
+
const identity = { bucket: { type: "AWS::S3::Bucket", status: "CREATE_COMPLETE", physicalId: "b" } };
|
|
123
|
+
|
|
124
|
+
function deepPlugin(
|
|
125
|
+
resources: Record<string, DeepResourceObservation>,
|
|
126
|
+
unobserved: Record<string, UnobservedEntity> = {},
|
|
127
|
+
) {
|
|
128
|
+
return createMockPlugin({
|
|
129
|
+
name: "aws",
|
|
130
|
+
describeResources: staticDescribeResources(identity),
|
|
131
|
+
observeResourcesDeep: async () => ({ deepObservation: "v1" as const, resources, unobserved }),
|
|
132
|
+
});
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
test("without --deep: identity only, and depth is not written", async () => {
|
|
136
|
+
const plugin = deepPlugin({
|
|
137
|
+
bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: { versioning: "Enabled" } },
|
|
138
|
+
});
|
|
139
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }));
|
|
140
|
+
// Absent, not "identity" — every snapshot written before #1267 is thin,
|
|
141
|
+
// and a reader must treat a missing field as thin rather than unknown.
|
|
142
|
+
expect(result.snapshots[0].depth).toBeUndefined();
|
|
143
|
+
expect(result.snapshots[0].properties).toBeUndefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
test("with --deep: records the property trees alongside identity", async () => {
|
|
147
|
+
const plugin = deepPlugin({
|
|
148
|
+
bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: { versioning: "Enabled" } },
|
|
149
|
+
});
|
|
150
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
|
|
151
|
+
expect(result.snapshots[0].depth).toBe("deep");
|
|
152
|
+
expect(result.snapshots[0].properties?.bucket.properties).toEqual({ versioning: "Enabled" });
|
|
153
|
+
// Identity is still there — deep adds, it does not replace.
|
|
154
|
+
expect(result.snapshots[0].resources.bucket).toMatchObject({ type: "AWS::S3::Bucket" });
|
|
155
|
+
});
|
|
156
|
+
|
|
157
|
+
test("--deep against a lexicon with no deep reader: identity snapshot plus a warning", async () => {
|
|
158
|
+
const plugin = createMockPlugin({ name: "aws", describeResources: staticDescribeResources(identity) });
|
|
159
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
|
|
160
|
+
// Still a usable snapshot, but it must not claim a depth it does not have.
|
|
161
|
+
expect(result.snapshots).toHaveLength(1);
|
|
162
|
+
expect(result.snapshots[0].depth).toBeUndefined();
|
|
163
|
+
expect(result.warnings.join("\n")).toContain("no deep reader");
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
test("--deep returning nothing: downgrades to identity rather than discarding a good snapshot", async () => {
|
|
167
|
+
const plugin = deepPlugin({});
|
|
168
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
|
|
169
|
+
expect(result.snapshots).toHaveLength(1);
|
|
170
|
+
expect(result.snapshots[0].depth).toBeUndefined();
|
|
171
|
+
expect(result.warnings.join("\n")).toContain("deep read returned no properties");
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
test("--deep passes the stack's region to the deep reader (#1261 family)", async () => {
|
|
175
|
+
let seen: string | undefined = "unset";
|
|
176
|
+
const plugin = createMockPlugin({
|
|
177
|
+
name: "aws",
|
|
178
|
+
describeResources: staticDescribeResources(identity),
|
|
179
|
+
observeResourcesDeep: async (options: { region?: string }) => {
|
|
180
|
+
seen = options.region;
|
|
181
|
+
return {
|
|
182
|
+
deepObservation: "v1" as const,
|
|
183
|
+
resources: { bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: {} } },
|
|
184
|
+
unobserved: {},
|
|
185
|
+
};
|
|
186
|
+
},
|
|
187
|
+
});
|
|
188
|
+
await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), {
|
|
189
|
+
stack: "app-us-west-2",
|
|
190
|
+
region: "us-west-2",
|
|
191
|
+
deep: true,
|
|
192
|
+
});
|
|
193
|
+
// Without this the deep read targets the ambient region and comes back
|
|
194
|
+
// empty, which downgrades a multi-region snapshot to identity silently.
|
|
195
|
+
expect(seen).toBe("us-west-2");
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
test("--deep reports entities the deep reader could not read", async () => {
|
|
199
|
+
const plugin = deepPlugin(
|
|
200
|
+
{ bucket: { type: "AWS::S3::Bucket", physicalId: "b", properties: {} } },
|
|
201
|
+
{ queue: { reason: "read-failed", detail: "boom" } },
|
|
202
|
+
);
|
|
203
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["bucket"] }), { deep: true });
|
|
204
|
+
expect(result.warnings.join("\n")).toContain("not observed deeply");
|
|
205
|
+
});
|
|
206
|
+
});
|
|
207
|
+
|
|
86
208
|
test("plugin without describeResources is skipped", async () => {
|
|
87
209
|
const plugin = createMockPlugin({ name: "aws" });
|
|
88
210
|
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["x"] }));
|
|
@@ -231,3 +353,60 @@ describe("takeSnapshot", () => {
|
|
|
231
353
|
expect(result.snapshots).toEqual([]);
|
|
232
354
|
});
|
|
233
355
|
});
|
|
356
|
+
|
|
357
|
+
// #1266 — a snapshot that records only what it manages cannot answer a fold
|
|
358
|
+
// question when it is replayed: the account's default VPC routing is not in it,
|
|
359
|
+
// so `internetFacing` is unanswerable and `search --at` would be quietly weaker
|
|
360
|
+
// than `search --live`.
|
|
361
|
+
describe("dependencies and edges in a snapshot (#1266)", () => {
|
|
362
|
+
const identity = { webServer: { type: "AWS::EC2::Instance", status: "OK", physicalId: "i-1" } };
|
|
363
|
+
|
|
364
|
+
test("records the dependencies the estate references, and the edges to them", async () => {
|
|
365
|
+
const plugin = createMockPlugin({
|
|
366
|
+
name: "aws",
|
|
367
|
+
describeResources: staticDescribeResources(identity),
|
|
368
|
+
observeDependencies: async () => ({
|
|
369
|
+
resources: {
|
|
370
|
+
"rtb-default": {
|
|
371
|
+
type: "AWS::EC2::RouteTable",
|
|
372
|
+
status: "OBSERVED",
|
|
373
|
+
physicalId: "rtb-default",
|
|
374
|
+
referencedBy: ["webServer"],
|
|
375
|
+
},
|
|
376
|
+
},
|
|
377
|
+
edges: [{ from: "webServer", to: "rtb-default", kind: "ref" as const, viaAttr: "RouteTableId" }],
|
|
378
|
+
}),
|
|
379
|
+
});
|
|
380
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["webServer"] }));
|
|
381
|
+
// Both, in one record: what exists and how it connects.
|
|
382
|
+
expect(result.snapshots[0].resources["rtb-default"]).toMatchObject({ referencedBy: ["webServer"] });
|
|
383
|
+
expect(result.snapshots[0].edges).toEqual([
|
|
384
|
+
{ from: "webServer", to: "rtb-default", kind: "ref", viaAttr: "RouteTableId" },
|
|
385
|
+
]);
|
|
386
|
+
});
|
|
387
|
+
|
|
388
|
+
test("a lexicon with no dependency reader snapshots exactly as before", async () => {
|
|
389
|
+
const plugin = createMockPlugin({ name: "aws", describeResources: staticDescribeResources(identity) });
|
|
390
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["webServer"] }));
|
|
391
|
+
expect(Object.keys(result.snapshots[0].resources)).toEqual(["webServer"]);
|
|
392
|
+
// Absent, not empty — "no relationships recorded", not "none existed".
|
|
393
|
+
expect(result.snapshots[0].edges).toBeUndefined();
|
|
394
|
+
});
|
|
395
|
+
|
|
396
|
+
test("a dependency read that fails warns and keeps the managed snapshot", async () => {
|
|
397
|
+
const plugin = createMockPlugin({
|
|
398
|
+
name: "aws",
|
|
399
|
+
describeResources: staticDescribeResources(identity),
|
|
400
|
+
observeDependencies: async () => {
|
|
401
|
+
throw new Error("route tables unreadable");
|
|
402
|
+
},
|
|
403
|
+
});
|
|
404
|
+
const result = await takeSnapshot("prod", [plugin], makeBuildResult({ aws: ["webServer"] }));
|
|
405
|
+
// The managed observation is complete and useful on its own; losing it
|
|
406
|
+
// because an ambient dependency could not be read trades a whole answer
|
|
407
|
+
// for none.
|
|
408
|
+
expect(result.snapshots).toHaveLength(1);
|
|
409
|
+
expect(result.snapshots[0].resources.webServer).toBeDefined();
|
|
410
|
+
expect(result.warnings.join("\n")).toContain("dependencies not read");
|
|
411
|
+
});
|
|
412
|
+
});
|
|
@@ -5,8 +5,11 @@
|
|
|
5
5
|
import type { ObservationLexicon, ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
6
6
|
import type { BuildResult } from "../build";
|
|
7
7
|
import type { SerializerResult } from "../serializer";
|
|
8
|
-
import type { LifecycleSnapshot } from "./types";
|
|
8
|
+
import type { LifecycleSnapshot, ObservationDepth } from "./types";
|
|
9
|
+
import { observeDeep } from "./deep-observe";
|
|
10
|
+
import type { DeepResourceObservation } from "../deep-observation";
|
|
9
11
|
import { computeBuildDigest } from "./digest";
|
|
12
|
+
import { collectDependencies, collectAmbient } from "./observe";
|
|
10
13
|
import { writeSnapshot, snapshotStorageKey, getHeadCommit, pushLifecycle } from "./git";
|
|
11
14
|
import { sortedJsonReplacer } from "../utils";
|
|
12
15
|
import { formatUnobserved, normalizeObservation, unobservedAll, type UnobservedEntity } from "../observation";
|
|
@@ -80,9 +83,24 @@ export async function takeSnapshot(
|
|
|
80
83
|
environment: string,
|
|
81
84
|
plugins: ObservationLexicon[],
|
|
82
85
|
buildResult: BuildResult,
|
|
83
|
-
opts?: {
|
|
86
|
+
opts?: {
|
|
87
|
+
cwd?: string;
|
|
88
|
+
stack?: string;
|
|
89
|
+
region?: string;
|
|
90
|
+
deep?: boolean;
|
|
91
|
+
ambient?: boolean;
|
|
92
|
+
/** Kinds the PROJECT manages, not just this stack — a region whose stack
|
|
93
|
+
* declares no security group still has a default one (#1278). */
|
|
94
|
+
ambientKinds?: string[];
|
|
95
|
+
},
|
|
84
96
|
): Promise<TakeSnapshotResult> {
|
|
85
97
|
const stack = opts?.stack;
|
|
98
|
+
const depth: ObservationDepth = opts?.deep ? "deep" : "identity";
|
|
99
|
+
// A stack declares the region it deploys to (#1261). Passing it through is
|
|
100
|
+
// what lets a multi-region estate be observed at all: the reader targets the
|
|
101
|
+
// stack's own region rather than whichever one the shell happens to be set
|
|
102
|
+
// to, so out-of-region stacks stop coming back empty.
|
|
103
|
+
const region = opts?.region;
|
|
86
104
|
const warnings: string[] = [];
|
|
87
105
|
const errors: string[] = [];
|
|
88
106
|
const snapshots: LifecycleSnapshot[] = [];
|
|
@@ -131,6 +149,7 @@ export async function takeSnapshot(
|
|
|
131
149
|
entityNames,
|
|
132
150
|
entities,
|
|
133
151
|
stack,
|
|
152
|
+
region,
|
|
134
153
|
}),
|
|
135
154
|
);
|
|
136
155
|
const { valid, dropped, warnings: validationWarnings } = validateResources(observed.resources);
|
|
@@ -149,6 +168,8 @@ export async function takeSnapshot(
|
|
|
149
168
|
}
|
|
150
169
|
|
|
151
170
|
if (plugin.listArtifacts) {
|
|
171
|
+
// No region: artifacts are registry/chart objects (docker, helm), not
|
|
172
|
+
// regional cloud resources, and `listArtifacts` takes no region.
|
|
152
173
|
const raw = await plugin.listArtifacts({ environment, entities, stack });
|
|
153
174
|
const { valid, dropped, warnings: validationWarnings } = validateResources(raw);
|
|
154
175
|
warnings.push(...validationWarnings);
|
|
@@ -168,15 +189,79 @@ export async function takeSnapshot(
|
|
|
168
189
|
continue;
|
|
169
190
|
}
|
|
170
191
|
|
|
192
|
+
// The deep read is a second pass, after identity is known to be readable
|
|
193
|
+
// (#1267). Keeping it separate means a lexicon with no deep reader still
|
|
194
|
+
// snapshots exactly as before, and a deep read that comes back empty
|
|
195
|
+
// downgrades the record rather than discarding an identity snapshot that
|
|
196
|
+
// was already good.
|
|
197
|
+
let properties: Record<string, DeepResourceObservation> | undefined;
|
|
198
|
+
let recordedDepth: ObservationDepth = "identity";
|
|
199
|
+
if (depth === "deep") {
|
|
200
|
+
if (!plugin.observeResourcesDeep) {
|
|
201
|
+
warnings.push(
|
|
202
|
+
`${plugin.name}: no deep reader — recording an identity snapshot; property questions cannot be answered from it`,
|
|
203
|
+
);
|
|
204
|
+
} else {
|
|
205
|
+
const observed = await observeDeep(plugin, {
|
|
206
|
+
environment,
|
|
207
|
+
buildOutput,
|
|
208
|
+
entities,
|
|
209
|
+
...(stack ? { stack } : {}),
|
|
210
|
+
...(region ? { region } : {}),
|
|
211
|
+
});
|
|
212
|
+
for (const [name, entry] of Object.entries(observed.unobserved)) {
|
|
213
|
+
warnings.push(`${plugin.name}: not observed deeply — ${formatUnobserved(name, entry)}`);
|
|
214
|
+
}
|
|
215
|
+
if (Object.keys(observed.resources).length > 0) {
|
|
216
|
+
properties = observed.resources;
|
|
217
|
+
recordedDepth = "deep";
|
|
218
|
+
} else {
|
|
219
|
+
warnings.push(
|
|
220
|
+
`${plugin.name}: deep read returned no properties — recording an identity snapshot`,
|
|
221
|
+
);
|
|
222
|
+
}
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
// What this estate depends on but does not manage (#1273), recorded so a
|
|
226
|
+
// replayed snapshot can answer the same questions a live read can (#1266).
|
|
227
|
+
// Without them a snapshot holds the managed resources and no route to the
|
|
228
|
+
// account's default VPC, so `internetFacing` is unanswerable from it —
|
|
229
|
+
// which would make `search --at` quietly weaker than `search --live`.
|
|
230
|
+
const dependencies = await collectDependencies(plugin, {
|
|
231
|
+
environment,
|
|
232
|
+
entities,
|
|
233
|
+
observed: resources,
|
|
234
|
+
stacks: stack ? [{ name: stack, ...(region ? { region } : {}) }] : [],
|
|
235
|
+
});
|
|
236
|
+
for (const message of dependencies.warnings) warnings.push(message);
|
|
237
|
+
// Ambient resources (#1278) are recorded too when asked for, so a replayed
|
|
238
|
+
// snapshot can answer "which of these are unused" without a live read.
|
|
239
|
+
// Without this `search --at --ambient` filters a set that was never
|
|
240
|
+
// recorded and silently returns nothing.
|
|
241
|
+
const ambient = opts?.ambient
|
|
242
|
+
? await collectAmbient(plugin, {
|
|
243
|
+
environment,
|
|
244
|
+
kinds: opts?.ambientKinds ?? [...new Set([...entities.values()].map((e) => e.entityType))],
|
|
245
|
+
observed: resources,
|
|
246
|
+
stacks: stack ? [{ name: stack, ...(region ? { region } : {}) }] : [],
|
|
247
|
+
warnings,
|
|
248
|
+
})
|
|
249
|
+
: {};
|
|
250
|
+
const withDependencies = { ...resources, ...dependencies.resources, ...ambient };
|
|
251
|
+
|
|
171
252
|
const snapshot: LifecycleSnapshot = {
|
|
172
253
|
lexicon: plugin.name,
|
|
173
254
|
environment,
|
|
174
255
|
...(stack ? { stack } : {}),
|
|
175
256
|
commit: headCommit,
|
|
176
257
|
timestamp,
|
|
177
|
-
resources,
|
|
258
|
+
resources: withDependencies,
|
|
259
|
+
...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}),
|
|
178
260
|
...(Object.keys(unobserved).length > 0 && { unobserved }),
|
|
179
261
|
...(Object.keys(artifacts).length > 0 && { artifacts }),
|
|
262
|
+
// Only written when deep. An absent field means identity, which is what
|
|
263
|
+
// every snapshot taken before #1267 was.
|
|
264
|
+
...(recordedDepth === "deep" && { depth: recordedDepth, properties }),
|
|
180
265
|
digest,
|
|
181
266
|
};
|
|
182
267
|
|
package/src/lifecycle/types.ts
CHANGED
|
@@ -1,8 +1,25 @@
|
|
|
1
1
|
import type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
2
2
|
import type { UnobservedEntity } from "../observation";
|
|
3
|
+
import type { DeepResourceObservation } from "../deep-observation";
|
|
4
|
+
import type { IREdge } from "../graph-ir";
|
|
3
5
|
|
|
4
6
|
export type { ResourceMetadata, ArtifactMetadata } from "../lexicon";
|
|
5
7
|
|
|
8
|
+
/**
|
|
9
|
+
* How much of each resource an observation actually read (#1267).
|
|
10
|
+
*
|
|
11
|
+
* `identity` is the thin path: logical name, type, physical id, status. It is
|
|
12
|
+
* what a snapshot recorded before deep reads existed, and it stays the default
|
|
13
|
+
* because a deep read costs more provider calls and a larger record.
|
|
14
|
+
*
|
|
15
|
+
* `deep` additionally carries each resource's normalized property tree, which
|
|
16
|
+
* is what a fold over topology needs — a subnet's route-table association, a
|
|
17
|
+
* security group's rules. A consumer must branch on this rather than assume:
|
|
18
|
+
* asking an `identity` snapshot a property question has no answer, and
|
|
19
|
+
* silently returning nothing would read as "no such resources".
|
|
20
|
+
*/
|
|
21
|
+
export type ObservationDepth = "identity" | "deep";
|
|
22
|
+
|
|
6
23
|
/**
|
|
7
24
|
* State snapshot for a single lexicon in an environment.
|
|
8
25
|
*/
|
|
@@ -28,6 +45,36 @@ export interface LifecycleSnapshot {
|
|
|
28
45
|
unobserved?: Record<string, UnobservedEntity>;
|
|
29
46
|
/** Artifact metadata keyed by server-side identifier (lexicon-specific). */
|
|
30
47
|
artifacts?: Record<string, ArtifactMetadata>;
|
|
48
|
+
/**
|
|
49
|
+
* How much of each resource this snapshot read (#1267). Absent means
|
|
50
|
+
* `identity` — every snapshot written before deep reads existed was thin, and
|
|
51
|
+
* treating a missing field as unknown rather than as thin would invalidate
|
|
52
|
+
* them all.
|
|
53
|
+
*/
|
|
54
|
+
depth?: ObservationDepth;
|
|
55
|
+
/**
|
|
56
|
+
* Normalized per-resource property trees, present only at `deep` depth and
|
|
57
|
+
* keyed by the same logical names as `resources`.
|
|
58
|
+
*
|
|
59
|
+
* Kept beside `resources` rather than merged into it so the thin record stays
|
|
60
|
+
* exactly what it always was: a reader that only wants identity does not have
|
|
61
|
+
* to learn a new shape, and an old snapshot and a new one parse the same way.
|
|
62
|
+
*/
|
|
63
|
+
properties?: Record<string, DeepResourceObservation>;
|
|
64
|
+
/**
|
|
65
|
+
* Relationships observed between the recorded resources (#1266).
|
|
66
|
+
*
|
|
67
|
+
* `resources` says what existed; without this a snapshot cannot say how any
|
|
68
|
+
* of it connected, so a fold over topology has nothing to traverse when the
|
|
69
|
+
* snapshot is replayed. That is the difference between a snapshot answering
|
|
70
|
+
* "which instances exist" and answering "which are reachable from the
|
|
71
|
+
* internet" — and the second is the whole reason the graph is worth
|
|
72
|
+
* recording.
|
|
73
|
+
*
|
|
74
|
+
* Absent on every snapshot written before this, which is read as "no
|
|
75
|
+
* relationships recorded" rather than "no relationships existed".
|
|
76
|
+
*/
|
|
77
|
+
edges?: IREdge[];
|
|
31
78
|
/** Build digest at snapshot time — what was declared when this snapshot was taken */
|
|
32
79
|
digest?: BuildDigest;
|
|
33
80
|
}
|