/** * Live observation without persistence — call each plugin's `describeResources()` * for an environment and collect the results as `LiveObservation[]`, ready to * project into the graph IR (`buildLiveGraphIr`, ../graph-ir.ts) for * `chant graph --live`. * * This is the read half of what `takeSnapshot` (./snapshot.ts) does before it * validates + writes to git: same per-plugin build-output/entities assembly, no * side effects. Snapshotting keeps its own copy for now; a future refactor can * fold both onto this primitive. */ import type { ObservationLexicon } from "../lexicon"; import type { BuildResult } from "../build"; import { build as buildProject } from "../build"; import { resolve as resolvePath } from "node:path"; import type { SerializerResult } from "../serializer"; import type { LiveObservation, IREdge } from "../graph-ir"; import type { ResourceMetadata } from "../lexicon"; import { mergeObservations, normalizeObservation, unobservedAll, formatUnobserved, type NormalizedObservation, } from "../observation"; import { zeroResourcesWarning } from "../live-endpoint"; import { unqualifiedKey } from "./identity"; import { isResourceDeclarable } from "../declarable"; export interface ObserveResult { observations: LiveObservation[]; warnings: string[]; errors: string[]; /** * Run-level notices from the lexicons (#1265), each said once however many * stacks or lexicons reported it — "ownership filter unavailable on this * read path" is the canonical one. Kept apart from `warnings`, which are * per-entity, so a caller can print them where a note belongs: after the * answer, not ahead of it. */ notes: string[]; } /** * Re-key a normalized observation's entities by `${stack}::${id}` (#1162) so a * bare LogicalResourceId shared across stacks (e.g. `vpc`) stays unambiguous * once the per-stack results are merged. The declared canvas qualifies the same * way (`buildDeclaredPerStack`), so the overlay join lines up. Applies to both * the OBSERVED-PRESENT and NOT-OBSERVED maps of the tri-state (#1089). */ function qualifyObservation(obs: NormalizedObservation, stackName: string): NormalizedObservation { const q = (m: Record): Record => Object.fromEntries(Object.entries(m).map(([k, v]) => [`${stackName}::${k}`, v])); return { resources: q(obs.resources), unobserved: q(obs.unobserved), queried: q(obs.queried), notes: obs.notes, // Exports are already keyed by stack (#1279); nothing to qualify. ...(obs.stackExports ? { stackExports: obs.stackExports } : {}), }; } /** * Query every plugin that implements `describeResources` for its resources in * `environment`. `owned` (default true for the managed-only diagram, epic #776) * restricts to resources carrying chant's ownership marker; a lexicon with no * marker channel logs and returns everything (its own contract). Plugins that * throw are collected into `errors` — one failing lexicon never sinks the whole * graph — and every entity they were asked about is recorded as NOT-OBSERVED * (`read-failed`, #1089) rather than dropped, so a failed read is visibly a * hole instead of a silent absence. * * `stacks` (#57) is for a multi-stack, per-component project (e.g. loomster) * where there is no single stack named after the environment — AWS's * single-stack convention (`lexicons/aws/src/plugin.ts`'s `describeResources`, * absent an explicit `stack`) queries a stack that simply doesn't exist there, * so the single-call path always observes zero nodes. When `stacks` is * present and non-empty, each observing plugin's `describeResources` is * called once per stack and the returned observations are merged. A stack entry * may be a bare name or `{ name, region?, src? }` (#1162): `src` is built * SCOPED so the deployed BARE LogicalResourceIds match (the whole-project build * disambiguates colliding names to `UsWest1Src…`, which the live ids never * carry), and a scoped stack's observed ids are qualified `${stack}::${id}` so * the same bare id in two stacks stays distinct. A bare-string stack keeps its * bare ids and the tri-state merge (#57). When `stacks` is absent or empty, behavior is * exactly the single call of before (no `stack` key at all), so a single-stack * project is unaffected. */ export async function observeResources( environment: string, plugins: ObservationLexicon[], buildResult: BuildResult, opts?: { owned?: boolean; stacks?: Array; /** Also report resources of a managed kind that nothing declares or * references (#1278). Opt-in: it asks the provider what exists rather than * resolving out from what is declared. */ ambient?: boolean; /** Where to read an entity that declares no namespace of its own (#1629). * Passed through to every lexicon's `describeResources`; one that has no * namespace-like scope ignores it. */ namespace?: string; }, ): Promise { const owned = opts?.owned ?? true; const stacks = (opts?.stacks ?? []).map((st) => (typeof st === "string" ? { name: st } : st)); const includeAmbient = opts?.ambient ?? false; // A stack's `src` (multi-stack, #1162) is built SCOPED to recover that stack's // BARE entity names — the names it actually deploys. Matching deployed bare // LogicalResourceIds against the whole-project build's DISAMBIGUATED names // (UsWest1Src…) misses every colliding resource. Cached per src. const serializers = plugins.map((p) => p.serializer); const scopedBuildCache = new Map(); const scopedBuild = async (src: string): Promise => { const key = resolvePath(src); let r = scopedBuildCache.get(key); if (!r) { r = await buildProject(key, serializers); scopedBuildCache.set(key, r); } return r; }; const observations: LiveObservation[] = []; const warnings: string[] = []; const errors: string[] = []; const notes: string[] = []; for (const plugin of plugins) { if (!plugin.describeResources) continue; // Serialized build output + declared entities for this lexicon — the scope // describeResources needs to know what to look for. const rawOutput = buildResult.outputs.get(plugin.name); const buildOutput = rawOutput === undefined ? "" : typeof rawOutput === "string" ? rawOutput : (rawOutput as SerializerResult).primary; const entityNames: string[] = []; const entities = new Map }>(); for (const [name, entity] of buildResult.entities) { if (entity.lexicon !== plugin.name) continue; // Only resource declarables have a live counterpart to observe. Outputs, // parameters and serializer directives (gcp's `defaultAnnotations`) are // build-time inputs — declared, but with nothing in any cloud to compare // against, so keeping them in the universe makes every diff report a // hole (or worse, a deletion) for a resource that cannot exist. if (!isResourceDeclarable(entity)) continue; entityNames.push(name); entities.set(name, { entityType: entity.entityType, props: (entity.props != null ? entity.props : {}) as Record, }); } try { let observed: NormalizedObservation; if (stacks.length > 0) { const parts: NormalizedObservation[] = []; for (const stack of stacks) { // Use this stack's scoped build (bare entity names) when it has a src, // so describeResources matches the deployed bare LogicalResourceIds. let stackEntityNames = entityNames; let stackBuildOutput = buildOutput; let stackEntities = entities; if (stack.src) { const sb = await scopedBuild(stack.src); stackEntityNames = []; stackEntities = new Map(); for (const [name, entity] of sb.entities) { if (entity.lexicon !== plugin.name || !isResourceDeclarable(entity)) continue; stackEntityNames.push(name); stackEntities.set(name, { entityType: entity.entityType, props: (entity.props != null ? entity.props : {}) as Record, }); } const raw = sb.outputs.get(plugin.name); stackBuildOutput = raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary; } const norm = normalizeObservation( await plugin.describeResources({ environment, buildOutput: stackBuildOutput, entityNames: stackEntityNames, entities: stackEntities, owned, stack: stack.name, region: stack.region, ...(opts?.namespace ? { namespace: opts.namespace } : {}), }), ); // Qualify ids by stack ONLY for a scoped (`src`) stack (#1162): that // is the multi-region case where the SAME bare LogicalResourceId // (e.g. `vpc`) exists in every stack, so a bare union would collide. // A bare-string stack (#57 loomster) has unique per-component ids and // is asked the whole-project entity set, so it keeps the bare-id // tri-state merge (present > not-observed > absent) that behold and // other consumers read. parts.push(stack.src ? qualifyObservation(norm, stack.name) : norm); } observed = mergeObservations(parts); } else { observed = normalizeObservation( await plugin.describeResources({ environment, buildOutput, entityNames, entities, owned, ...(opts?.namespace ? { namespace: opts.namespace } : {}), }), ); } // Run-level notices (#1265): one line per distinct note per run, // whatever the stack count. `mergeObservations` already folded the // per-stack copies; this folds across lexicons. for (const note of observed.notes) { const line = `[${plugin.name}] ${note}`; if (!notes.includes(line)) notes.push(line); } // What the estate depends on but does not declare (#1273). Read after the // managed resources, because the declared observation is the closure's // roots — there is nothing to reference out from until it exists. const dependencies = await collectDependencies(plugin, { environment, entities, observed: observed.resources, stacks, }); for (const message of dependencies.warnings) warnings.push(message); // Resources of a managed kind that nothing declares or references (#1278). // Bounded by what this lexicon's declared entities actually are, so a // project managing security groups is not made to enumerate the account. const ambient = includeAmbient ? await collectAmbient(plugin, { environment, kinds: [...new Set([...entities.values()].map((e) => e.entityType))], observed: observed.resources, stacks, warnings, }) : {}; // A resource can be reached as a dependency AND enumerated as ambient — // the default VPC an instance sits in is both. It is one resource, and // the dependency entry is the one carrying `referencedBy`, so it wins. // Matched on physical id rather than on key, because ambient keys carry a // region (#1416) and dependency keys do not, so the keys no longer // collide even when the resource is the same one. const dependedOn = new Set( Object.values(dependencies.resources) .map((m) => m.physicalId) .filter((id): id is string => typeof id === "string"), ); for (const [id, meta] of Object.entries(ambient)) { if (meta.physicalId && dependedOn.has(meta.physicalId)) continue; dependencies.resources[id] ??= meta; } pushObservation( observations, warnings, plugin.name, observed, environment, entityNames.length, dependencies, ); } catch (err) { // A thrown read is the whole-lexicon failure: every declared entity is // NOT-OBSERVED, not absent (#1089). Emitting nothing here is what made a // failed read look like "none of these exist" to every consumer. const message = err instanceof Error ? err.message : String(err); errors.push(`${plugin.name}: ${message}`); pushObservation( observations, warnings, plugin.name, { resources: {}, unobserved: unobservedAll(entityNames, "read-failed", message, entities), queried: {}, notes: [], }, environment, entityNames.length, ); } } return { observations, warnings, errors, notes }; } /** * The subset of an observation belonging to one stack. * * A scoped stack's ids are qualified `${stack}::${id}` (#1162), so the prefix is * the whole test. An unqualified observation — single-stack, or a bare-string * stack sharing one id space — has no way to be split and is returned whole, * which is what it already was. */ function scopeToStack( resources: Record, stack: string | undefined, ): Record { if (!stack) return resources; const prefix = `${stack}::`; const scoped = Object.fromEntries( Object.entries(resources) .filter(([id]) => id.startsWith(prefix)) .map(([id, meta]) => [id, meta] as const), ); // No qualified ids at all means this observation was never stack-scoped. return Object.keys(scoped).length > 0 ? scoped : resources; } /** * Ask a lexicon what exists of the kinds it manages, beyond what is declared * (#1278). Once per stack for the region, merged by physical id — the same * ambient resource seen from two stacks is one resource. * * "The same" is per region (#1416). Two stacks in one region reporting the * account's default security group is one group; two regions' default security * groups are two, whatever their ids look like, so the merge key carries the * region a resource records itself in. */ export async function collectAmbient( plugin: ObservationLexicon, opts: { environment: string; kinds: string[]; observed: Record; stacks: Array<{ name: string; region?: string; src?: string }>; warnings: string[]; }, ): Promise> { if (!plugin.observeAmbient || opts.kinds.length === 0) return {}; const found: Record = {}; const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }]; // Only a merge needs region-qualified keys, and one ref is not a merge. This // is what keeps a single-region project's ids exactly what they were, and a // recorded snapshot — always one stack, so always one ref — bare. const merging = refs.length > 1; for (const ref of refs) { try { const part = await plugin.observeAmbient({ environment: opts.environment, kinds: opts.kinds, observed: opts.observed, ...(ref.name ? { stack: ref.name } : {}), ...(ref.region ? { region: ref.region } : {}), }); for (const [id, meta] of Object.entries(part)) { found[merging ? unqualifiedKey(id, meta) : id] ??= meta; } } catch (err) { opts.warnings.push( `${plugin.name}: ambient resources not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${err instanceof Error ? err.message : String(err)}`, ); } } return found; } /** Dependencies collected across a lexicon's stacks, plus anything to report. */ export interface CollectedDependencies { resources: Record; edges: IREdge[]; warnings: string[]; } const NO_DEPENDENCIES: CollectedDependencies = { resources: {}, edges: [], warnings: [] }; /** * Ask a lexicon what its declared estate references but does not manage (#1273). * * Called once per stack, because the closure roots and the region differ per * stack, and merged by key. Dependencies are keyed by physical id and are * deliberately NOT stack-qualified: the account's default VPC route table is the * same resource whichever stack routes through it, and qualifying it would * produce one node per referrer and an edge to each. * * Best-effort. A lexicon that does not implement the hook, or one whose read * fails, contributes nothing — the managed observation is already complete and * useful on its own, and failing it because an ambient dependency could not be * read would trade a whole answer for a partial one. */ export async function collectDependencies( plugin: ObservationLexicon, opts: { environment: string; entities: Map }>; observed: Record; stacks: Array<{ name: string; region?: string; src?: string }>; }, ): Promise { if (!plugin.observeDependencies) return NO_DEPENDENCIES; const resources: Record = {}; const edges: IREdge[] = []; const warnings: string[] = []; const refs = opts.stacks.length > 0 ? opts.stacks : [{ name: undefined, region: undefined }]; for (const ref of refs) { // Only this stack's resources are the closure's roots. Handing a lexicon // the whole estate makes it resolve out from resources that live somewhere // else — for AWS that means `describe-instances` in one region with another // region's instance ids, which fails outright with InvalidInstanceID and // takes the whole read down with it. const roots = scopeToStack(opts.observed, ref.name); if (Object.keys(roots).length === 0) continue; try { const found = await plugin.observeDependencies({ environment: opts.environment, entities: opts.entities, observed: roots, ...(ref.name ? { stack: ref.name } : {}), ...(ref.region ? { region: ref.region } : {}), }); for (const [id, meta] of Object.entries(found.resources)) { // Merge referrers rather than overwrite: two stacks routing through the // same table is one node reached twice, and the reason it is here is // both of them. const existing = resources[id]; resources[id] = existing ? { ...existing, referencedBy: [...new Set([...(existing.referencedBy ?? []), ...(meta.referencedBy ?? [])])] } : meta; } edges.push(...(found.edges ?? [])); } catch (err) { const message = err instanceof Error ? err.message : String(err); warnings.push( `${plugin.name}: dependencies not read${ref.name ? ` for stack "${ref.name}"` : ""} — ${message}`, ); } } return { resources, edges, warnings }; } /** Record one lexicon's observation, warning once per unobserved entity. */ function pushObservation( observations: LiveObservation[], warnings: string[], lexicon: string, observed: NormalizedObservation, environment: string, declaredCount: number, dependencies: CollectedDependencies = NO_DEPENDENCIES, ): void { const hasResources = Object.keys(observed.resources).length > 0; const unobservedNames = Object.keys(observed.unobserved); for (const name of unobservedNames) { warnings.push(`${lexicon}: not observed — ${formatUnobserved(name, observed.unobserved[name])}`); } if (!hasResources && unobservedNames.length === 0) { // #1166 — this is exactly the "wrong endpoint" shape (AWS's // stackDoesNotExist branch returns an empty map with no #1089 hole): a // declared entity list with nothing observed and nothing explained. // Previously this fell straight through with neither an observation nor a // warning — silently indistinguishable from "nothing is deployed yet". const notice = zeroResourcesWarning(lexicon, environment, declaredCount, observed); if (notice) warnings.push(notice); return; } // Dependencies ride alongside the managed resources so they become nodes, and // carry `referencedBy` so every consumer can still tell the two apart. const hasDependencies = Object.keys(dependencies.resources).length > 0; observations.push({ lexicon, resources: hasDependencies ? { ...observed.resources, ...dependencies.resources } : observed.resources, ...(unobservedNames.length > 0 ? { unobserved: observed.unobserved } : {}), ...(dependencies.edges.length > 0 ? { edges: dependencies.edges } : {}), ...(observed.stackExports && Object.keys(observed.stackExports).length > 0 ? { stackExports: observed.stackExports } : {}), }); }