/** * Replay a recorded snapshot as if it had just been observed (#1266, #1279). * * Shared by `chant search --at` and `chant graph --at`. A snapshot already holds * what an observation is — resources with their physical ids and attributes, * and since #1266 the relationships between them — so turning it back into * `LiveObservation[]` lets a replay rejoin the live path at `buildLiveGraphIr`. * Every fold, overlay and edge reconstruction below that point is shared, and * nothing downstream needs to know which source it got. * * That sharing is the reason this is a module rather than a helper inside one * command: `graph` had no `--at` at all, so anyone wanting the raw IR of a * recorded estate had to reach for the live endpoint, and a snapshot could * answer most questions but never all of them. */ import { readEnvironmentSnapshots } from "./git"; import { unqualifiedKey } from "./identity"; import type { LiveObservation } from "../graph-ir"; import type { LifecycleSnapshot } from "./types"; /** * Rebuild observations from a recorded snapshot (#1266). * * A snapshot already holds what an observation is: resources with their * physical ids and attributes, and — since #1266 — the relationships between * them. Turning it back into `LiveObservation[]` means the replay rejoins the * live path at `buildLiveGraphIr`, and every fold, overlay and query below that * is shared. Nothing downstream needs to know which source it got. * * `latest` is the only ref for now. A specific commit is the natural extension * and the storage already supports it (`readSnapshotAt`), but "answer from what * is recorded" is the question worth settling first. */ /** * Whether this environment has a recording, without reading one. * * Used to turn "the estate could not be read" into "the estate could not be * read, and you already have a recording of it". Cheap and best-effort: a * failure here means the caller says the plain version of the message, never * that the command fails. */ export async function hasSnapshot(environment: string): Promise { try { return (await readEnvironmentSnapshots(environment)).size > 0; } catch { return false; } } export async function replaySnapshots( environment: string, ref: string, scopedStacks: Set, ): Promise<{ observations: LiveObservation[]; commit: string; timestamp: string } | { error: string; hint?: string }> { if (ref !== "latest" && ref !== "true") { return { error: `chant search --at only accepts "latest" for now, got "${ref}"`, hint: "a specific snapshot commit is not wired up yet", }; } const stored = await readEnvironmentSnapshots(environment); if (stored.size === 0) { return { error: `No snapshots found for environment "${environment}"`, hint: `Record one first: chant lifecycle snapshot ${environment}`, }; } const observations: LiveObservation[] = []; let commit = ""; let timestamp = ""; // Ambient and dependency resources are keyed by physical id and are // account-level: the default security group three stacks each recorded is one // group, not three. Managed resources are stack-qualified below and cannot // collide, so only the unqualified ones need this. // // "Account-level" is the part that needed qualifying (#1416): a resource that // records a region is regional, and two regions' copies are two resources // however equal their ids look. `unqualifiedKey` keys those by region, so the // set below still collapses one region's resource seen from two stacks and no // longer collapses two regions'. const seenUnqualified = new Set(); // One recorded stack is one region, so there is nothing to merge and nothing // to disambiguate — its ids stay exactly what they were recorded as, which is // also what `chant search --live` gives that project. const merging = stored.size > 1; // A stack's snapshot could only exclude what THAT stack manages, so a stack // declaring no security groups reported the neighbouring stack's as ambient. // The union is only knowable here, with every snapshot in hand. const managedPhysicalIds = new Set(); // Where a managed resource ends up, by physical id. A duplicate is dropped // below rather than rendered twice, and anything that pointed at the // duplicate has to be re-pointed at the survivor — dropping the node alone // would take its edges with it, since `buildLiveGraphIr` discards an edge // whose endpoints were not both observed. const managedKeyOf = new Map(); for (const content of stored.values()) { const snap = JSON.parse(content) as LifecycleSnapshot; const snapStack = snap.stack; const snapQualifies = snapStack !== undefined && scopedStacks.has(snapStack); for (const [id, meta] of Object.entries(snap.resources ?? {})) { if (!meta.ambient && !meta.referencedBy?.length && meta.physicalId) { managedPhysicalIds.add(meta.physicalId); managedKeyOf.set(meta.physicalId, snapQualifies ? `${snapStack}::${id}` : id); } } } for (const [key, content] of stored) { const snapshot = JSON.parse(content) as LifecycleSnapshot; // The storage key is `__` for a multi-stack project; the // snapshot carries its own lexicon, which is the one to trust. const lexicon = snapshot.lexicon ?? key; // A scoped stack's ids are qualified `${stack}::${id}` on the live path // (#1162), because the same bare LogicalResourceId exists in every region's // stack. A snapshot stores them bare, so a replay has to re-apply the same // rule — otherwise `server` from us-west-1 and `server` from us-west-2 // collide, and none of them join the declared canvas, which qualifies. const stack = snapshot.stack; const qualify = stack !== undefined && scopedStacks.has(stack); // Dependencies (#1273) and ambient resources (#1278) are keyed by physical // id and are NOT stack-qualified: the default VPC's route table is one // resource however many stacks route through it, and qualifying it would // split it per stack and break the edges into it. const unqualifiedByNature = (meta: { referencedBy?: string[]; ambient?: boolean }): boolean => Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0); // Region qualification (#1416) applies to exactly those, and never to a // managed resource: an unscoped project's declared canvas joins on bare // ids, so re-keying `web` to `us-east-1::web` would unjoin it from its own // declaration. const keyOf = (id: string, meta: { referencedBy?: string[]; ambient?: boolean; attributes?: Record }): string => { if (unqualifiedByNature(meta)) return merging ? unqualifiedKey(id, meta) : id; return qualify ? `${stack}::${id}` : id; }; const resources: Record = {}; // Duplicates dropped here, and where they went, so edges can follow. const merged = new Map(); for (const [id, meta] of Object.entries(snapshot.resources ?? {})) { const key = keyOf(id, meta); if (!qualify || unqualifiedByNature(meta)) { // Ambient means "nothing manages this", and being referenced means "the // estate reaches this" — neither stops it being managed. When a stack // manages the same physical resource, this entry is that resource seen // from outside, and rendering both inflates any count over it. // // The dependency half of that was missing, and it was not cosmetic: a // subnet declared by its stack was also recorded as `subnet-9af06b90` // with `referencedBy`, so an estate with 13 subnets replayed as 16 and // "which subnets have no network interfaces" answered 11 where the // truth was 8. The three extra were occupied subnets whose second copy // had no interface pointing at it — a duplicate reads as empty, because // the edges resolved to the other one. const duplicate = Boolean(meta.ambient) || Boolean(meta.referencedBy && meta.referencedBy.length > 0); if (duplicate && meta.physicalId && managedPhysicalIds.has(meta.physicalId)) { const survivor = managedKeyOf.get(meta.physicalId); if (survivor && survivor !== key) merged.set(id, survivor); continue; } // Unqualified: one resource per key, so first sighting wins and the // rest are the same resource seen again from another stack's snapshot. // The key carries the region when there is one, so "the same resource" // no longer spans regions. if (seenUnqualified.has(key)) continue; seenUnqualified.add(key); } resources[key] = meta; } const known = new Set(Object.keys(snapshot.resources ?? {})); const requalify = (id: string): string => { // A dropped duplicate's edges belong to the resource that survived it. const survivor = merged.get(id); if (survivor) return survivor; const meta = (snapshot.resources ?? {})[id]; return known.has(id) && meta ? keyOf(id, meta) : id; }; const edges = (snapshot.edges ?? []).map((e) => ({ ...e, from: requalify(e.from), to: requalify(e.to) })); observations.push({ lexicon, resources, ...(edges.length > 0 ? { edges } : {}), ...(snapshot.stackExports && Object.keys(snapshot.stackExports).length > 0 ? { stackExports: snapshot.stackExports } : {}), }); commit ||= snapshot.commit ?? ""; // Report the OLDEST timestamp across stacks: a caller asking how stale this // answer is wants the weakest link, not the freshest one. if (!timestamp || (snapshot.timestamp && snapshot.timestamp < timestamp)) { timestamp = snapshot.timestamp ?? timestamp; } } return { observations, commit, timestamp }; }