@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
|
@@ -133,11 +133,31 @@ function compareMetadata(
|
|
|
133
133
|
return changes;
|
|
134
134
|
}
|
|
135
135
|
|
|
136
|
+
/**
|
|
137
|
+
* Value equality that does not care what order a provider listed the keys in.
|
|
138
|
+
*
|
|
139
|
+
* This compared with `JSON.stringify`, which is key-order sensitive. That held
|
|
140
|
+
* while observed attributes were flat strings, and broke the moment they carried
|
|
141
|
+
* nested objects (#1279): a provider returning `{AvailabilityZone, Tenancy}` on
|
|
142
|
+
* one read and `{Tenancy, AvailabilityZone}` on the next made an unchanged
|
|
143
|
+
* instance drift on every single run. Order is not a fact about the resource,
|
|
144
|
+
* and reporting it as drift is exactly the noise this module exists to remove.
|
|
145
|
+
*
|
|
146
|
+
* Arrays stay order-sensitive — for a list, order is part of the value.
|
|
147
|
+
*/
|
|
136
148
|
function shallowEqual(a: unknown, b: unknown): boolean {
|
|
137
149
|
if (a === b) return true;
|
|
138
150
|
if (a == null || b == null) return false;
|
|
139
151
|
if (typeof a !== "object" || typeof b !== "object") return false;
|
|
140
|
-
return
|
|
152
|
+
return canonical(a) === canonical(b);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** JSON with object keys sorted at every depth, so equal values stringify equally. */
|
|
156
|
+
function canonical(value: unknown): string {
|
|
157
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
158
|
+
if (Array.isArray(value)) return `[${value.map(canonical).join(",")}]`;
|
|
159
|
+
const entries = Object.entries(value as Record<string, unknown>).sort(([x], [y]) => (x < y ? -1 : x > y ? 1 : 0));
|
|
160
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonical(v)}`).join(",")}}`;
|
|
141
161
|
}
|
|
142
162
|
|
|
143
163
|
/** The delta between two saved snapshots (#822): a two-way observed diff. */
|
|
@@ -227,8 +247,28 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
227
247
|
// same as `unowned`/`foreign` — composing with #1168's tri-state precedent:
|
|
228
248
|
// an incomplete read never earns the more confident classification.
|
|
229
249
|
const runtimeChildNames = new Set<string>();
|
|
250
|
+
const dependencyNames = new Set<string>();
|
|
230
251
|
for (const name of observedNowNames) {
|
|
231
252
|
if (declared.has(name)) continue;
|
|
253
|
+
// A referenced dependency (#1273) is observed only because something
|
|
254
|
+
// declared points at it — an account's default VPC route table, a shared
|
|
255
|
+
// subnet. Offering it as a delete/adopt candidate is wrong: it is not
|
|
256
|
+
// yours, and it changes on its own, so counting it as drift is noise.
|
|
257
|
+
// Same treatment as a runtime child, for the same reason, arrived at from
|
|
258
|
+
// the other direction — a child is something declared created, a
|
|
259
|
+
// dependency is something declared relies on.
|
|
260
|
+
if ((observedNow[name]?.referencedBy?.length ?? 0) > 0) {
|
|
261
|
+
dependencyNames.add(name);
|
|
262
|
+
continue;
|
|
263
|
+
}
|
|
264
|
+
// Ambient (#1278): observed because it exists, not because anything points
|
|
265
|
+
// at it. Reported so a caller can ask about it — "which of these are
|
|
266
|
+
// unused" is the whole point — but never counted as drift, since chant
|
|
267
|
+
// neither created it nor tracks its changes.
|
|
268
|
+
if (observedNow[name]?.ambient) {
|
|
269
|
+
dependencyNames.add(name);
|
|
270
|
+
continue;
|
|
271
|
+
}
|
|
232
272
|
const chain = observedNow[name]?.ownerChain;
|
|
233
273
|
if (chain?.root === "declared") {
|
|
234
274
|
runtimeChildNames.add(name);
|
|
@@ -256,7 +296,10 @@ export function diffLive(input: DiffLiveInput): LiveDiffResult {
|
|
|
256
296
|
// happened to record the same name (e.g. a StatefulSet's stable pod
|
|
257
297
|
// identity) must not turn its ordinary churn into `driftedSinceSnapshot`.
|
|
258
298
|
for (const name of observedNowNames) {
|
|
259
|
-
|
|
299
|
+
// Referenced dependencies (#1273) are excluded for the same reason runtime
|
|
300
|
+
// children are: they are observed to complete the picture, not to be
|
|
301
|
+
// governed, and their ordinary churn is somebody else's.
|
|
302
|
+
if (runtimeChildNames.has(name) || dependencyNames.has(name)) continue;
|
|
260
303
|
const now = observedNow[name];
|
|
261
304
|
const then = observedThenMap[name];
|
|
262
305
|
if (!then) {
|
package/src/lifecycle/observe.ts
CHANGED
|
@@ -14,7 +14,8 @@ import type { BuildResult } from "../build";
|
|
|
14
14
|
import { build as buildProject } from "../build";
|
|
15
15
|
import { resolve as resolvePath } from "node:path";
|
|
16
16
|
import type { SerializerResult } from "../serializer";
|
|
17
|
-
import type { LiveObservation } from "../graph-ir";
|
|
17
|
+
import type { LiveObservation, IREdge } from "../graph-ir";
|
|
18
|
+
import type { ResourceMetadata } from "../lexicon";
|
|
18
19
|
import {
|
|
19
20
|
mergeObservations,
|
|
20
21
|
normalizeObservation,
|
|
@@ -73,10 +74,18 @@ export async function observeResources(
|
|
|
73
74
|
environment: string,
|
|
74
75
|
plugins: ObservationLexicon[],
|
|
75
76
|
buildResult: BuildResult,
|
|
76
|
-
opts?: {
|
|
77
|
+
opts?: {
|
|
78
|
+
owned?: boolean;
|
|
79
|
+
stacks?: Array<string | { name: string; region?: string; src?: string }>;
|
|
80
|
+
/** Also report resources of a managed kind that nothing declares or
|
|
81
|
+
* references (#1278). Opt-in: it asks the provider what exists rather than
|
|
82
|
+
* resolving out from what is declared. */
|
|
83
|
+
ambient?: boolean;
|
|
84
|
+
},
|
|
77
85
|
): Promise<ObserveResult> {
|
|
78
86
|
const owned = opts?.owned ?? true;
|
|
79
87
|
const stacks = (opts?.stacks ?? []).map((st) => (typeof st === "string" ? { name: st } : st));
|
|
88
|
+
const includeAmbient = opts?.ambient ?? false;
|
|
80
89
|
// A stack's `src` (multi-stack, #1162) is built SCOPED to recover that stack's
|
|
81
90
|
// BARE entity names — the names it actually deploys. Matching deployed bare
|
|
82
91
|
// LogicalResourceIds against the whole-project build's DISAMBIGUATED names
|
|
@@ -177,7 +186,38 @@ export async function observeResources(
|
|
|
177
186
|
}),
|
|
178
187
|
);
|
|
179
188
|
}
|
|
180
|
-
|
|
189
|
+
// What the estate depends on but does not declare (#1273). Read after the
|
|
190
|
+
// managed resources, because the declared observation is the closure's
|
|
191
|
+
// roots — there is nothing to reference out from until it exists.
|
|
192
|
+
const dependencies = await collectDependencies(plugin, {
|
|
193
|
+
environment,
|
|
194
|
+
entities,
|
|
195
|
+
observed: observed.resources,
|
|
196
|
+
stacks,
|
|
197
|
+
});
|
|
198
|
+
for (const message of dependencies.warnings) warnings.push(message);
|
|
199
|
+
// Resources of a managed kind that nothing declares or references (#1278).
|
|
200
|
+
// Bounded by what this lexicon's declared entities actually are, so a
|
|
201
|
+
// project managing security groups is not made to enumerate the account.
|
|
202
|
+
const ambient = includeAmbient
|
|
203
|
+
? await collectAmbient(plugin, {
|
|
204
|
+
environment,
|
|
205
|
+
kinds: [...new Set([...entities.values()].map((e) => e.entityType))],
|
|
206
|
+
observed: observed.resources,
|
|
207
|
+
stacks,
|
|
208
|
+
warnings,
|
|
209
|
+
})
|
|
210
|
+
: {};
|
|
211
|
+
for (const [id, meta] of Object.entries(ambient)) dependencies.resources[id] ??= meta;
|
|
212
|
+
pushObservation(
|
|
213
|
+
observations,
|
|
214
|
+
warnings,
|
|
215
|
+
plugin.name,
|
|
216
|
+
observed,
|
|
217
|
+
environment,
|
|
218
|
+
entityNames.length,
|
|
219
|
+
dependencies,
|
|
220
|
+
);
|
|
181
221
|
} catch (err) {
|
|
182
222
|
// A thrown read is the whole-lexicon failure: every declared entity is
|
|
183
223
|
// NOT-OBSERVED, not absent (#1089). Emitting nothing here is what made a
|
|
@@ -201,6 +241,141 @@ export async function observeResources(
|
|
|
201
241
|
return { observations, warnings, errors };
|
|
202
242
|
}
|
|
203
243
|
|
|
244
|
+
/**
|
|
245
|
+
* The subset of an observation belonging to one stack.
|
|
246
|
+
*
|
|
247
|
+
* A scoped stack's ids are qualified `${stack}::${id}` (#1162), so the prefix is
|
|
248
|
+
* the whole test. An unqualified observation — single-stack, or a bare-string
|
|
249
|
+
* stack sharing one id space — has no way to be split and is returned whole,
|
|
250
|
+
* which is what it already was.
|
|
251
|
+
*/
|
|
252
|
+
function scopeToStack(
|
|
253
|
+
resources: Record<string, ResourceMetadata>,
|
|
254
|
+
stack: string | undefined,
|
|
255
|
+
): Record<string, ResourceMetadata> {
|
|
256
|
+
if (!stack) return resources;
|
|
257
|
+
const prefix = `${stack}::`;
|
|
258
|
+
const scoped = Object.fromEntries(
|
|
259
|
+
Object.entries(resources)
|
|
260
|
+
.filter(([id]) => id.startsWith(prefix))
|
|
261
|
+
.map(([id, meta]) => [id, meta] as const),
|
|
262
|
+
);
|
|
263
|
+
// No qualified ids at all means this observation was never stack-scoped.
|
|
264
|
+
return Object.keys(scoped).length > 0 ? scoped : resources;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Ask a lexicon what exists of the kinds it manages, beyond what is declared
|
|
269
|
+
* (#1278). Once per stack for the region, merged by physical id — the same
|
|
270
|
+
* ambient resource seen from two stacks is one resource.
|
|
271
|
+
*/
|
|
272
|
+
export async function collectAmbient(
|
|
273
|
+
plugin: ObservationLexicon,
|
|
274
|
+
opts: {
|
|
275
|
+
environment: string;
|
|
276
|
+
kinds: string[];
|
|
277
|
+
observed: Record<string, ResourceMetadata>;
|
|
278
|
+
stacks: Array<{ name: string; region?: string; src?: string }>;
|
|
279
|
+
warnings: string[];
|
|
280
|
+
},
|
|
281
|
+
): Promise<Record<string, ResourceMetadata>> {
|
|
282
|
+
if (!plugin.observeAmbient || opts.kinds.length === 0) return {};
|
|
283
|
+
const found: Record<string, ResourceMetadata> = {};
|
|
284
|
+
const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }];
|
|
285
|
+
for (const ref of refs) {
|
|
286
|
+
try {
|
|
287
|
+
const part = await plugin.observeAmbient({
|
|
288
|
+
environment: opts.environment,
|
|
289
|
+
kinds: opts.kinds,
|
|
290
|
+
observed: opts.observed,
|
|
291
|
+
...(ref.name ? { stack: ref.name } : {}),
|
|
292
|
+
...(ref.region ? { region: ref.region } : {}),
|
|
293
|
+
});
|
|
294
|
+
for (const [id, meta] of Object.entries(part)) found[id] ??= meta;
|
|
295
|
+
} catch (err) {
|
|
296
|
+
opts.warnings.push(
|
|
297
|
+
`${plugin.name}: ambient resources not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${err instanceof Error ? err.message : String(err)}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
}
|
|
301
|
+
return found;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
/** Dependencies collected across a lexicon's stacks, plus anything to report. */
|
|
305
|
+
export interface CollectedDependencies {
|
|
306
|
+
resources: Record<string, ResourceMetadata>;
|
|
307
|
+
edges: IREdge[];
|
|
308
|
+
warnings: string[];
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
const NO_DEPENDENCIES: CollectedDependencies = { resources: {}, edges: [], warnings: [] };
|
|
312
|
+
|
|
313
|
+
/**
|
|
314
|
+
* Ask a lexicon what its declared estate references but does not manage (#1273).
|
|
315
|
+
*
|
|
316
|
+
* Called once per stack, because the closure roots and the region differ per
|
|
317
|
+
* stack, and merged by key. Dependencies are keyed by physical id and are
|
|
318
|
+
* deliberately NOT stack-qualified: the account's default VPC route table is the
|
|
319
|
+
* same resource whichever stack routes through it, and qualifying it would
|
|
320
|
+
* produce one node per referrer and an edge to each.
|
|
321
|
+
*
|
|
322
|
+
* Best-effort. A lexicon that does not implement the hook, or one whose read
|
|
323
|
+
* fails, contributes nothing — the managed observation is already complete and
|
|
324
|
+
* useful on its own, and failing it because an ambient dependency could not be
|
|
325
|
+
* read would trade a whole answer for a partial one.
|
|
326
|
+
*/
|
|
327
|
+
export async function collectDependencies(
|
|
328
|
+
plugin: ObservationLexicon,
|
|
329
|
+
opts: {
|
|
330
|
+
environment: string;
|
|
331
|
+
entities: Map<string, { entityType: string; props: Record<string, unknown> }>;
|
|
332
|
+
observed: Record<string, ResourceMetadata>;
|
|
333
|
+
stacks: Array<{ name: string; region?: string; src?: string }>;
|
|
334
|
+
},
|
|
335
|
+
): Promise<CollectedDependencies> {
|
|
336
|
+
if (!plugin.observeDependencies) return NO_DEPENDENCIES;
|
|
337
|
+
|
|
338
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
339
|
+
const edges: IREdge[] = [];
|
|
340
|
+
const warnings: string[] = [];
|
|
341
|
+
const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }];
|
|
342
|
+
|
|
343
|
+
for (const ref of refs) {
|
|
344
|
+
// Only this stack's resources are the closure's roots. Handing a lexicon
|
|
345
|
+
// the whole estate makes it resolve out from resources that live somewhere
|
|
346
|
+
// else — for AWS that means `describe-instances` in one region with another
|
|
347
|
+
// region's instance ids, which fails outright with InvalidInstanceID and
|
|
348
|
+
// takes the whole read down with it.
|
|
349
|
+
const roots = scopeToStack(opts.observed, ref.name);
|
|
350
|
+
if (Object.keys(roots).length === 0) continue;
|
|
351
|
+
try {
|
|
352
|
+
const found = await plugin.observeDependencies({
|
|
353
|
+
environment: opts.environment,
|
|
354
|
+
entities: opts.entities,
|
|
355
|
+
observed: roots,
|
|
356
|
+
...(ref.name ? { stack: ref.name } : {}),
|
|
357
|
+
...(ref.region ? { region: ref.region } : {}),
|
|
358
|
+
});
|
|
359
|
+
for (const [id, meta] of Object.entries(found.resources)) {
|
|
360
|
+
// Merge referrers rather than overwrite: two stacks routing through the
|
|
361
|
+
// same table is one node reached twice, and the reason it is here is
|
|
362
|
+
// both of them.
|
|
363
|
+
const existing = resources[id];
|
|
364
|
+
resources[id] = existing
|
|
365
|
+
? { ...existing, referencedBy: [...new Set([...(existing.referencedBy ?? []), ...(meta.referencedBy ?? [])])] }
|
|
366
|
+
: meta;
|
|
367
|
+
}
|
|
368
|
+
edges.push(...(found.edges ?? []));
|
|
369
|
+
} catch (err) {
|
|
370
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
371
|
+
warnings.push(
|
|
372
|
+
`${plugin.name}: dependencies not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${message}`,
|
|
373
|
+
);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
return { resources, edges, warnings };
|
|
377
|
+
}
|
|
378
|
+
|
|
204
379
|
/** Record one lexicon's observation, warning once per unobserved entity. */
|
|
205
380
|
function pushObservation(
|
|
206
381
|
observations: LiveObservation[],
|
|
@@ -209,6 +384,7 @@ function pushObservation(
|
|
|
209
384
|
observed: NormalizedObservation,
|
|
210
385
|
environment: string,
|
|
211
386
|
declaredCount: number,
|
|
387
|
+
dependencies: CollectedDependencies = NO_DEPENDENCIES,
|
|
212
388
|
): void {
|
|
213
389
|
const hasResources = Object.keys(observed.resources).length > 0;
|
|
214
390
|
const unobservedNames = Object.keys(observed.unobserved);
|
|
@@ -225,9 +401,15 @@ function pushObservation(
|
|
|
225
401
|
if (notice) warnings.push(notice);
|
|
226
402
|
return;
|
|
227
403
|
}
|
|
404
|
+
// Dependencies ride alongside the managed resources so they become nodes, and
|
|
405
|
+
// carry `referencedBy` so every consumer can still tell the two apart.
|
|
406
|
+
const hasDependencies = Object.keys(dependencies.resources).length > 0;
|
|
228
407
|
observations.push({
|
|
229
408
|
lexicon,
|
|
230
|
-
resources:
|
|
409
|
+
resources: hasDependencies
|
|
410
|
+
? { ...observed.resources, ...dependencies.resources }
|
|
411
|
+
: observed.resources,
|
|
231
412
|
...(unobservedNames.length > 0 ? { unobserved: observed.unobserved } : {}),
|
|
413
|
+
...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}),
|
|
232
414
|
});
|
|
233
415
|
}
|
|
@@ -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
|
+
});
|