@intentius/chant 0.32.0 → 0.33.1

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.
Files changed (38) hide show
  1. package/dist/cli/handlers/graph.d.ts.map +1 -1
  2. package/dist/cli/handlers/search.d.ts +82 -0
  3. package/dist/cli/handlers/search.d.ts.map +1 -0
  4. package/dist/cli/main.d.ts.map +1 -1
  5. package/dist/cli/registry.d.ts +4 -0
  6. package/dist/cli/registry.d.ts.map +1 -1
  7. package/dist/config.d.ts +3 -0
  8. package/dist/config.d.ts.map +1 -1
  9. package/dist/graph-declared.d.ts +20 -0
  10. package/dist/graph-declared.d.ts.map +1 -0
  11. package/dist/graph-effective.d.ts +25 -0
  12. package/dist/graph-effective.d.ts.map +1 -0
  13. package/dist/graph-ir.d.ts +7 -0
  14. package/dist/graph-ir.d.ts.map +1 -1
  15. package/dist/lexicon.d.ts +9 -0
  16. package/dist/lexicon.d.ts.map +1 -1
  17. package/dist/lifecycle/observe.d.ts +14 -6
  18. package/dist/lifecycle/observe.d.ts.map +1 -1
  19. package/dist/observation.d.ts +71 -0
  20. package/dist/observation.d.ts.map +1 -1
  21. package/package.json +1 -1
  22. package/src/cli/handlers/graph.test.ts +1 -1
  23. package/src/cli/handlers/graph.ts +33 -11
  24. package/src/cli/handlers/search.test.ts +159 -0
  25. package/src/cli/handlers/search.ts +314 -0
  26. package/src/cli/main.ts +7 -0
  27. package/src/cli/registry.ts +4 -0
  28. package/src/codegen/release-wiring.test.ts +82 -0
  29. package/src/config.ts +3 -0
  30. package/src/graph-declared.ts +33 -0
  31. package/src/graph-effective.test.ts +97 -0
  32. package/src/graph-effective.ts +116 -0
  33. package/src/graph-ir.ts +7 -0
  34. package/src/lexicon.ts +6 -1
  35. package/src/lifecycle/observe.test.ts +66 -2
  36. package/src/lifecycle/observe.ts +79 -18
  37. package/src/observation.test.ts +135 -0
  38. package/src/observation.ts +151 -0
@@ -0,0 +1,97 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { enrichEffectiveTopology } from "./graph-effective";
3
+ import type { GraphIR } from "./graph-ir";
4
+
5
+ function node(id: string, kind: string, attrs: Record<string, unknown> = {}) {
6
+ return { id, kind: `AWS::EC2::${kind}`, attrs };
7
+ }
8
+ const sshRule = { IpProtocol: "tcp", FromPort: 22, ToPort: 22, CidrIp: "0.0.0.0/0" };
9
+
10
+ /**
11
+ * webServer: direct SG with SSH-open, subnet routes to an IGW → SSH-reachable
12
+ * ltServer: SG via LAUNCH TEMPLATE, same public subnet → SSH-reachable (the CLI-missed hop)
13
+ * westServer: public subnet (IGW) but its SG has NO ingress → internet-facing, not SSH-open
14
+ * privServer: private subnet (no IGW route) → not internet-facing
15
+ */
16
+ const ir: GraphIR = {
17
+ nodes: [
18
+ node("webServer", "Instance"),
19
+ node("ltServer", "Instance"),
20
+ node("westServer", "Instance"),
21
+ node("privServer", "Instance"),
22
+ node("webSg", "SecurityGroup", { SecurityGroupIngress: [sshRule, { IpProtocol: "tcp", FromPort: 80, ToPort: 80, CidrIp: "0.0.0.0/0" }] }),
23
+ node("westSg", "SecurityGroup", { SecurityGroupIngress: [] }),
24
+ node("lt", "LaunchTemplate"),
25
+ node("pubSubnet", "Subnet"),
26
+ node("westSubnet", "Subnet"),
27
+ node("privSubnet", "Subnet"),
28
+ node("pubRt", "RouteTable"),
29
+ node("westRt", "RouteTable"),
30
+ node("pubAssoc", "SubnetRouteTableAssociation"),
31
+ node("westAssoc", "SubnetRouteTableAssociation"),
32
+ node("pubRoute", "Route", { DestinationCidrBlock: "0.0.0.0/0" }),
33
+ node("westRoute", "Route", { DestinationCidrBlock: "0.0.0.0/0" }),
34
+ node("igw", "InternetGateway"),
35
+ node("westIgw", "InternetGateway"),
36
+ ] as never,
37
+ edges: [
38
+ { from: "webServer", to: "webSg", viaAttr: "SecurityGroupIds" },
39
+ { from: "webServer", to: "pubSubnet", viaAttr: "SubnetId" },
40
+ { from: "ltServer", to: "lt", viaAttr: "LaunchTemplate" },
41
+ { from: "lt", to: "webSg", viaAttr: "LaunchTemplateData" },
42
+ { from: "ltServer", to: "pubSubnet", viaAttr: "SubnetId" },
43
+ { from: "westServer", to: "westSg", viaAttr: "SecurityGroupIds" },
44
+ { from: "westServer", to: "westSubnet", viaAttr: "SubnetId" },
45
+ { from: "privServer", to: "privSubnet", viaAttr: "SubnetId" },
46
+ { from: "pubAssoc", to: "pubSubnet", viaAttr: "SubnetId" },
47
+ { from: "pubAssoc", to: "pubRt", viaAttr: "RouteTableId" },
48
+ { from: "pubRoute", to: "pubRt", viaAttr: "RouteTableId" },
49
+ { from: "pubRoute", to: "igw", viaAttr: "GatewayId" },
50
+ { from: "westAssoc", to: "westSubnet", viaAttr: "SubnetId" },
51
+ { from: "westAssoc", to: "westRt", viaAttr: "RouteTableId" },
52
+ { from: "westRoute", to: "westRt", viaAttr: "RouteTableId" },
53
+ { from: "westRoute", to: "westIgw", viaAttr: "GatewayId" },
54
+ ] as never,
55
+ groups: {},
56
+ };
57
+
58
+ describe("enrichEffectiveTopology", () => {
59
+ const enriched = enrichEffectiveTopology(ir);
60
+ const attrs = (id: string) => enriched.nodes.find((n) => n.id === id)!.attrs as Record<string, unknown>;
61
+
62
+ it("resolves the security group reached VIA a launch template (the CLI-missed hop)", () => {
63
+ expect(attrs("ltServer").effectiveIngress).toContain("tcp:22:0.0.0.0/0");
64
+ });
65
+
66
+ it("resolves a direct security group", () => {
67
+ expect(attrs("webServer").effectiveIngress).toContain("tcp:22:0.0.0.0/0");
68
+ });
69
+
70
+ it("marks instances whose subnet routes to an IGW as internetFacing", () => {
71
+ expect(attrs("webServer").internetFacing).toBe(true);
72
+ expect(attrs("ltServer").internetFacing).toBe(true);
73
+ expect(attrs("westServer").internetFacing).toBe(true);
74
+ expect(attrs("privServer").internetFacing).toBe(false);
75
+ });
76
+
77
+ it("keeps a live-supplied internetFacing (e.g. default VPC) even with no declared route", () => {
78
+ // A live enrichment marks an instance internetFacing; its subnet's routing
79
+ // is not in the declared graph (the account's default VPC). Enrichment
80
+ // must NOT overwrite that truth back to false.
81
+ const withLive: GraphIR = {
82
+ nodes: [node("defaultVpcServer", "Instance", { internetFacing: true })] as never,
83
+ edges: [] as never,
84
+ groups: {},
85
+ };
86
+ const out = enrichEffectiveTopology(withLive);
87
+ expect((out.nodes[0].attrs as Record<string, unknown>).internetFacing).toBe(true);
88
+ });
89
+
90
+ it("SSH-reachable = internetFacing AND effectiveIngress tcp:22:0.0.0.0/0 → only web + lt", () => {
91
+ const reachable = enriched.nodes.filter(
92
+ (n) => (n.attrs as Record<string, unknown>).internetFacing === true &&
93
+ ((n.attrs as Record<string, unknown>).effectiveIngress as string[] | undefined)?.includes("tcp:22:0.0.0.0/0"),
94
+ );
95
+ expect(reachable.map((n) => n.id).sort()).toEqual(["ltServer", "webServer"]);
96
+ });
97
+ });
@@ -0,0 +1,116 @@
1
+ import type { GraphIR, IRNode } from "./graph-ir";
2
+
3
+ /**
4
+ * Fold DERIVED reachability facts onto EC2 instance nodes so a single-node query
5
+ * can answer questions that are otherwise a multi-hop join with a union (#1139).
6
+ *
7
+ * Two facts, both things a live AWS-CLI sweep gets wrong because it can't cheaply
8
+ * resolve the topology:
9
+ *
10
+ * - `effectiveIngress` — the union of security-group ingress rules reachable
11
+ * from the instance, BOTH directly (`SecurityGroupIds`) AND through its launch
12
+ * template (`LaunchTemplate → LaunchTemplateData → SecurityGroupIds`). The
13
+ * launch-template hop is exactly what a CLI agent misses (it under-counts
14
+ * SSH-reachable instances). Each rule is normalized to `proto:port:cidr`
15
+ * (e.g. `tcp:22:0.0.0.0/0`) so it is precisely queryable.
16
+ * - `internetFacing` — whether the instance's subnet routes to an Internet
17
+ * Gateway (`subnet ← SubnetRouteTableAssociation → RouteTable ← Route →
18
+ * InternetGateway`). "Public subnet" means an IGW route, not
19
+ * `MapPublicIpOnLaunch`.
20
+ *
21
+ * With these, "instances SSH-reachable from the internet" is one predicate:
22
+ * `kind:EC2::Instance attr:internetFacing=true attr:effectiveIngress=tcp:22:0.0.0.0/0`
23
+ * — no hand-joined CLI sweep, no over/under-counting.
24
+ */
25
+ export function enrichEffectiveTopology(ir: GraphIR): GraphIR {
26
+ const byId = new Map(ir.nodes.map((n) => [n.id, n]));
27
+ const edges = ir.edges ?? [];
28
+ const kind = (n?: IRNode): string => n?.kind ?? "";
29
+ const isKind = (n: IRNode | undefined, suffix: string): boolean =>
30
+ !!n && (kind(n) === suffix || kind(n).endsWith("::" + suffix));
31
+ const via = (...names: string[]) => (v: string): boolean => names.includes(v);
32
+
33
+ /** Out-neighbours of `id` (edges from → to), optionally filtered by viaAttr. */
34
+ const out = (id: string, pred?: (v: string) => boolean): IRNode[] =>
35
+ edges
36
+ .filter((e) => e.from === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
37
+ .map((e) => byId.get(e.to))
38
+ .filter((x): x is IRNode => !!x);
39
+ /** In-neighbours of `id` (edges to ← from), optionally filtered by viaAttr. */
40
+ const incoming = (id: string, pred?: (v: string) => boolean): IRNode[] =>
41
+ edges
42
+ .filter((e) => e.to === id && (!pred || pred(e.viaAttr ?? e.kind ?? "")))
43
+ .map((e) => byId.get(e.from))
44
+ .filter((x): x is IRNode => !!x);
45
+
46
+ const normalizeIngress = (sg: IRNode): string[] => {
47
+ const rules = (sg.attrs as Record<string, unknown> | undefined)?.["SecurityGroupIngress"];
48
+ if (!Array.isArray(rules)) return [];
49
+ return rules.map((r) => {
50
+ const rule = r as Record<string, unknown>;
51
+ const proto = String(rule.IpProtocol ?? "-1");
52
+ const from = rule.FromPort as number | undefined;
53
+ const to = rule.ToPort as number | undefined;
54
+ const port = from == null ? "all" : from === to ? `${from}` : `${from}-${to}`;
55
+ const cidr =
56
+ (rule.CidrIp as string | undefined) ??
57
+ (rule.CidrIpv6 as string | undefined) ??
58
+ (rule.SourceSecurityGroupId ? `sg:${String(rule.SourceSecurityGroupId)}` : "?");
59
+ return `${proto}:${port}:${cidr}`;
60
+ });
61
+ };
62
+
63
+ /** Security groups reachable from an instance — direct and via launch template. */
64
+ const effectiveSgs = (inst: IRNode): IRNode[] => {
65
+ const direct = out(inst.id, via("SecurityGroupIds", "SecurityGroupId"));
66
+ const templates = out(inst.id, via("LaunchTemplate", "LaunchTemplateId"));
67
+ const viaTemplate = templates
68
+ .flatMap((lt) => out(lt.id, via("LaunchTemplateData", "SecurityGroupIds", "SecurityGroupId")))
69
+ .filter((n) => isKind(n, "SecurityGroup"));
70
+ const all = [...direct.filter((n) => isKind(n, "SecurityGroup")), ...viaTemplate];
71
+ return [...new Map(all.map((s) => [s.id, s])).values()];
72
+ };
73
+
74
+ /** The IGW an instance's subnet routes to (evidence), or undefined. */
75
+ const internetFacingVia = (inst: IRNode): string | undefined => {
76
+ for (const subnet of out(inst.id, via("SubnetId")).filter((n) => isKind(n, "Subnet"))) {
77
+ const assocs = incoming(subnet.id, via("SubnetId")).filter((a) => isKind(a, "SubnetRouteTableAssociation"));
78
+ const routeTables = assocs.flatMap((a) => out(a.id, via("RouteTableId")).filter((n) => isKind(n, "RouteTable")));
79
+ for (const rt of routeTables) {
80
+ const routes = incoming(rt.id, via("RouteTableId")).filter((n) => isKind(n, "Route"));
81
+ for (const route of routes) {
82
+ const dest = (route.attrs as Record<string, unknown> | undefined)?.["DestinationCidrBlock"];
83
+ const igw = out(route.id, via("GatewayId")).find((g) => isKind(g, "InternetGateway"));
84
+ if (igw && (dest == null || dest === "0.0.0.0/0")) {
85
+ const id = igw.id.includes("::") ? igw.id.slice(igw.id.lastIndexOf("::") + 2) : igw.id;
86
+ return `${rt.id.includes("::") ? rt.id.slice(rt.id.lastIndexOf("::") + 2) : rt.id} → ${id}`;
87
+ }
88
+ }
89
+ }
90
+ }
91
+ return undefined;
92
+ };
93
+
94
+ const nodes = ir.nodes.map((n) => {
95
+ if (!isKind(n, "Instance")) return n;
96
+ const effectiveIngress = effectiveSgs(n).flatMap(normalizeIngress);
97
+ // A live enrichment may already have set internetFacing (+ its evidence) for
98
+ // a subnet chant doesn't model declaratively (e.g. the account's default
99
+ // VPC). Keep that truth; otherwise derive it from the declared route topology.
100
+ const attrs = (n.attrs ?? {}) as Record<string, unknown>;
101
+ const liveFacing = attrs["internetFacing"] === true;
102
+ const declaredVia = internetFacingVia(n);
103
+ const via = (attrs["internetFacingVia"] as string | undefined) ?? declaredVia;
104
+ return {
105
+ ...n,
106
+ attrs: { ...attrs, effectiveIngress, internetFacing: liveFacing || !!declaredVia, ...(via ? { internetFacingVia: via } : {}) },
107
+ };
108
+ });
109
+ // Record what this pass computed, so a caller can report the graph's derived surface
110
+ // without hardcoding attribute names. Only kinds actually enriched are listed.
111
+ const enriched = nodes.some((n) => isKind(n, "Instance"));
112
+ const derivedAttrs = enriched
113
+ ? { ...(ir.derivedAttrs ?? {}), Instance: ["internetFacing", "effectiveIngress"] }
114
+ : ir.derivedAttrs;
115
+ return { ...ir, nodes, ...(derivedAttrs ? { derivedAttrs } : {}) };
116
+ }
package/src/graph-ir.ts CHANGED
@@ -191,6 +191,13 @@ export interface GraphIR {
191
191
  imports?: IRImport[];
192
192
  /** The CI/pipeline projection alongside the component graph (#989) — see {@link IRPipeline}. */
193
193
  pipeline?: IRPipeline;
194
+ /**
195
+ * Attributes chant computed rather than read back from the provider, keyed by the kind
196
+ * they were folded onto. An enrichment pass records what it derived here so callers can
197
+ * report the graph's own surface without knowing any attribute name — the set is whatever
198
+ * the passes produced, not a list maintained by hand.
199
+ */
200
+ derivedAttrs?: Record<string, string[]>;
194
201
  }
195
202
 
196
203
  /** A node is anything that serializes to a resource — not a property or output. */
package/src/lexicon.ts CHANGED
@@ -608,6 +608,9 @@ export interface LexiconPlugin {
608
608
  * convention (AWS: the stack named after `environment`).
609
609
  */
610
610
  stack?: string;
611
+ /** AWS region the stack is in (multi-region). When set, the observation
612
+ * targets this region instead of the ambient one (#1161 follow-up). */
613
+ region?: string;
611
614
  /**
612
615
  * Restrict the result to chant-owned resources (those carrying the
613
616
  * ownership marker, #119). Where a lexicon has no durable marker channel,
@@ -695,7 +698,7 @@ export interface LexiconPlugin {
695
698
  * thin to carry references (e.g. AWS CloudFormation, where it's sourced from
696
699
  * the fuller `exportResources` config).
697
700
  */
698
- enrichLiveAttrs?(options: { environment: string; stack?: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
701
+ enrichLiveAttrs?(options: { environment: string; stack?: string; stacks?: Array<string | { name: string; region?: string }>; owned?: boolean }): Promise<Record<string, Record<string, unknown>>>;
699
702
 
700
703
  /**
701
704
  * List runtime artifacts in the given environment. Opt-in.
@@ -748,6 +751,8 @@ export interface LexiconPlugin {
748
751
  * keeps its single-stack convention (AWS: the stack named after
749
752
  * `environment`). */
750
753
  stack?: string;
754
+ /** AWS region the stack is in (multi-region estates). */
755
+ region?: string;
751
756
  selector?: ResourceSelector;
752
757
  owned?: boolean;
753
758
  verbatim?: boolean;
@@ -1,9 +1,25 @@
1
- import { describe, it, expect } from "vitest";
1
+ import { describe, it, expect, vi } from "vitest";
2
2
  import { observeResources } from "./observe";
3
3
  import { observation } from "../observation";
4
4
  import type { ObservationLexicon, ResourceMetadata } from "../lexicon";
5
5
  import type { BuildResult } from "../build";
6
6
 
7
+ // The per-stack scoped build (#1162) resolves each stack's `src` through the
8
+ // real `build`. Mock it so the test controls what each src yields — a stack
9
+ // whose src is scoped reports BARE entity names, matching the deployed
10
+ // LogicalResourceIds, not the whole-project build's disambiguated names.
11
+ const scopedBuilds: Record<string, string[]> = {};
12
+ vi.mock("../build", () => ({
13
+ build: async (src: string): Promise<BuildResult> => {
14
+ const names = scopedBuilds[src] ?? [];
15
+ return {
16
+ outputs: new Map<string, string>([["aws", "{}"]]),
17
+ entities: new Map(names.map((n) => [n, { lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} }])),
18
+ errors: [],
19
+ } as unknown as BuildResult;
20
+ },
21
+ }));
22
+
7
23
  function mockBuild(): BuildResult {
8
24
  return {
9
25
  outputs: new Map<string, string>([["aws", "{}"]]),
@@ -154,7 +170,11 @@ describe("observeResources", () => {
154
170
  awsPlugin((opts) => {
155
171
  const stack = (opts as { stack?: string }).stack;
156
172
  calls.push(stack);
157
- // Different resources per stack — the multi-stack, per-component case.
173
+ // Different resources per stack — the multi-stack, per-component case
174
+ // (#57 loomster). Bare-string stacks keep BARE ids (no `src` scope), so
175
+ // the union is `db-a`+`db-b`, not stack-qualified: per-component ids are
176
+ // already unique and behold reads them bare. Qualification is a scoped
177
+ // (`src`) feature — see the per-stack src test below.
158
178
  const resources: Record<string, ResourceMetadata> =
159
179
  stack === "s1"
160
180
  ? { "db-a": { type: "AWS::RDS::DBInstance", status: "AVAILABLE" } }
@@ -169,6 +189,50 @@ describe("observeResources", () => {
169
189
  expect(Object.keys(observations[0].resources).sort()).toEqual(["db-a", "db-b"]);
170
190
  });
171
191
 
192
+ it("with per-stack src (#1162): describeResources gets each stack's SCOPED bare names, not the whole-project build's names", async () => {
193
+ const { resolve } = await import("node:path");
194
+ // The whole-project build disambiguates colliding names by module path;
195
+ // each stack's scoped src reports the bare names it actually deploys.
196
+ scopedBuilds[resolve("east/src")] = ["server", "vpc"];
197
+ scopedBuilds[resolve("west/src")] = ["server", "vpc"];
198
+
199
+ const seen: Record<string, string[]> = {};
200
+ const plugins = [
201
+ awsPlugin((opts) => {
202
+ const o = opts as { stack?: string; entityNames: string[] };
203
+ seen[o.stack ?? "?"] = o.entityNames;
204
+ // Echo one resource keyed by a bare name the deployed stack owns.
205
+ return { server: { type: "AWS::EC2::Instance", status: "AVAILABLE", physicalId: `i-${o.stack}` } };
206
+ }),
207
+ ];
208
+ // The whole-project buildResult carries DISAMBIGUATED names — proving the
209
+ // scoped path overrides them rather than falling through to these.
210
+ const wholeProject = {
211
+ outputs: new Map<string, string>([["aws", "{}"]]),
212
+ entities: new Map([
213
+ ["EastServer", { lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} }],
214
+ ["WestServer", { lexicon: "aws", entityType: "AWS::EC2::Instance", props: {} }],
215
+ ]),
216
+ errors: [],
217
+ } as unknown as BuildResult;
218
+
219
+ const { observations } = await observeResources("floci", plugins, wholeProject, {
220
+ stacks: [
221
+ { name: "east", src: "east/src" },
222
+ { name: "west", src: "west/src" },
223
+ ],
224
+ });
225
+
226
+ // Each stack saw its own scoped bare names (matching deployed ids).
227
+ expect(seen["east"]).toEqual(["server", "vpc"]);
228
+ expect(seen["west"]).toEqual(["server", "vpc"]);
229
+ // Observed nodes are stack-qualified, so the colliding `server` id is
230
+ // distinct per stack and each carries its own physical id.
231
+ expect(Object.keys(observations[0].resources).sort()).toEqual(["east::server", "west::server"]);
232
+ expect(observations[0].resources["east::server"].physicalId).toBe("i-east");
233
+ expect(observations[0].resources["west::server"].physicalId).toBe("i-west");
234
+ });
235
+
172
236
  it("with an empty stacks array — falls back to the single unstacked call", async () => {
173
237
  const calls: Array<{ stack?: string }> = [];
174
238
  const plugins = [
@@ -11,6 +11,8 @@
11
11
  */
12
12
  import type { ObservationLexicon } from "../lexicon";
13
13
  import type { BuildResult } from "../build";
14
+ import { build as buildProject } from "../build";
15
+ import { resolve as resolvePath } from "node:path";
14
16
  import type { SerializerResult } from "../serializer";
15
17
  import type { LiveObservation } from "../graph-ir";
16
18
  import {
@@ -28,6 +30,19 @@ export interface ObserveResult {
28
30
  errors: string[];
29
31
  }
30
32
 
33
+ /**
34
+ * Re-key a normalized observation's entities by `${stack}::${id}` (#1162) so a
35
+ * bare LogicalResourceId shared across stacks (e.g. `vpc`) stays unambiguous
36
+ * once the per-stack results are merged. The declared canvas qualifies the same
37
+ * way (`buildDeclaredPerStack`), so the overlay join lines up. Applies to both
38
+ * the OBSERVED-PRESENT and NOT-OBSERVED maps of the tri-state (#1089).
39
+ */
40
+ function qualifyObservation(obs: NormalizedObservation, stackName: string): NormalizedObservation {
41
+ const q = <T>(m: Record<string, T>): Record<string, T> =>
42
+ Object.fromEntries(Object.entries(m).map(([k, v]) => [`${stackName}::${k}`, v]));
43
+ return { resources: q(obs.resources), unobserved: q(obs.unobserved) };
44
+ }
45
+
31
46
  /**
32
47
  * Query every plugin that implements `describeResources` for its resources in
33
48
  * `environment`. `owned` (default true for the managed-only diagram, epic #776)
@@ -44,20 +59,39 @@ export interface ObserveResult {
44
59
  * absent an explicit `stack`) queries a stack that simply doesn't exist there,
45
60
  * so the single-call path always observes zero nodes. When `stacks` is
46
61
  * present and non-empty, each observing plugin's `describeResources` is
47
- * called once per stack (same `environment`/`entities`/`entityNames`, only
48
- * `stack` varies) and the returned resource maps are unioned a resource
49
- * appears under whichever stack contains its logical id. When `stacks` is
50
- * absent or empty, behavior is exactly the single call of before (no `stack`
51
- * key at all), so a single-stack project is unaffected.
62
+ * called once per stack and the returned observations are merged. A stack entry
63
+ * may be a bare name or `{ name, region?, src? }` (#1162): `src` is built
64
+ * SCOPED so the deployed BARE LogicalResourceIds match (the whole-project build
65
+ * disambiguates colliding names to `UsWest1Src…`, which the live ids never
66
+ * carry), and a scoped stack's observed ids are qualified `${stack}::${id}` so
67
+ * the same bare id in two stacks stays distinct. A bare-string stack keeps its
68
+ * bare ids and the tri-state merge (#57). When `stacks` is absent or empty, behavior is
69
+ * exactly the single call of before (no `stack` key at all), so a single-stack
70
+ * project is unaffected.
52
71
  */
53
72
  export async function observeResources(
54
73
  environment: string,
55
74
  plugins: ObservationLexicon[],
56
75
  buildResult: BuildResult,
57
- opts?: { owned?: boolean; stacks?: string[] },
76
+ opts?: { owned?: boolean; stacks?: Array<string | { name: string; region?: string; src?: string }> },
58
77
  ): Promise<ObserveResult> {
59
78
  const owned = opts?.owned ?? true;
60
- const stacks = opts?.stacks ?? [];
79
+ const stacks = (opts?.stacks ?? []).map((st) => (typeof st === "string" ? { name: st } : st));
80
+ // A stack's `src` (multi-stack, #1162) is built SCOPED to recover that stack's
81
+ // BARE entity names — the names it actually deploys. Matching deployed bare
82
+ // LogicalResourceIds against the whole-project build's DISAMBIGUATED names
83
+ // (UsWest1Src…) misses every colliding resource. Cached per src.
84
+ const serializers = plugins.map((p) => p.serializer);
85
+ const scopedBuildCache = new Map<string, BuildResult>();
86
+ const scopedBuild = async (src: string): Promise<BuildResult> => {
87
+ const key = resolvePath(src);
88
+ let r = scopedBuildCache.get(key);
89
+ if (!r) {
90
+ r = await buildProject(key, serializers);
91
+ scopedBuildCache.set(key, r);
92
+ }
93
+ return r;
94
+ };
61
95
  const observations: LiveObservation[] = [];
62
96
  const warnings: string[] = [];
63
97
  const errors: string[] = [];
@@ -91,18 +125,45 @@ export async function observeResources(
91
125
  if (stacks.length > 0) {
92
126
  const parts: NormalizedObservation[] = [];
93
127
  for (const stack of stacks) {
94
- parts.push(
95
- normalizeObservation(
96
- await plugin.describeResources({
97
- environment,
98
- buildOutput,
99
- entityNames,
100
- entities,
101
- owned,
102
- stack,
103
- }),
104
- ),
128
+ // Use this stack's scoped build (bare entity names) when it has a src,
129
+ // so describeResources matches the deployed bare LogicalResourceIds.
130
+ let stackEntityNames = entityNames;
131
+ let stackBuildOutput = buildOutput;
132
+ let stackEntities = entities;
133
+ if (stack.src) {
134
+ const sb = await scopedBuild(stack.src);
135
+ stackEntityNames = [];
136
+ stackEntities = new Map();
137
+ for (const [name, entity] of sb.entities) {
138
+ if (entity.lexicon !== plugin.name) continue;
139
+ stackEntityNames.push(name);
140
+ stackEntities.set(name, {
141
+ entityType: entity.entityType,
142
+ props: ("props" in entity && entity.props != null ? entity.props : {}) as Record<string, unknown>,
143
+ });
144
+ }
145
+ const raw = sb.outputs.get(plugin.name);
146
+ stackBuildOutput = raw === undefined ? "" : typeof raw === "string" ? raw : (raw as SerializerResult).primary;
147
+ }
148
+ const norm = normalizeObservation(
149
+ await plugin.describeResources({
150
+ environment,
151
+ buildOutput: stackBuildOutput,
152
+ entityNames: stackEntityNames,
153
+ entities: stackEntities,
154
+ owned,
155
+ stack: stack.name,
156
+ region: stack.region,
157
+ }),
105
158
  );
159
+ // Qualify ids by stack ONLY for a scoped (`src`) stack (#1162): that
160
+ // is the multi-region case where the SAME bare LogicalResourceId
161
+ // (e.g. `vpc`) exists in every stack, so a bare union would collide.
162
+ // A bare-string stack (#57 loomster) has unique per-component ids and
163
+ // is asked the whole-project entity set, so it keeps the bare-id
164
+ // tri-state merge (present > not-observed > absent) that behold and
165
+ // other consumers read.
166
+ parts.push(stack.src ? qualifyObservation(norm, stack.name) : norm);
106
167
  }
107
168
  observed = mergeObservations(parts);
108
169
  } else {
@@ -5,14 +5,19 @@
5
5
  import { describe, test, expect } from "vitest";
6
6
  import {
7
7
  UNOBSERVED_REASONS,
8
+ boundedConcurrently,
8
9
  formatUnobserved,
9
10
  isObservationResult,
10
11
  isUnobservedReason,
11
12
  mergeObservations,
12
13
  normalizeObservation,
13
14
  observation,
15
+ observeEntities,
14
16
  unobservedAll,
15
17
  unobservedReasonText,
18
+ type DeclaredEntity,
19
+ type EntityObservation,
20
+ type ObserverAdapter,
16
21
  } from "./observation";
17
22
  import type { ResourceMetadata } from "./lexicon";
18
23
 
@@ -94,3 +99,133 @@ describe("reason totality", () => {
94
99
  ).toBe("widget (K8s::X::Widget) — no reader for this resource kind: no mapping");
95
100
  });
96
101
  });
102
+
103
+ describe("boundedConcurrently", () => {
104
+ test("processes every item", async () => {
105
+ const seen: number[] = [];
106
+ await boundedConcurrently([1, 2, 3, 4, 5], async (n) => {
107
+ seen.push(n);
108
+ }, 2);
109
+ expect([...seen].sort((a, b) => a - b)).toEqual([1, 2, 3, 4, 5]);
110
+ });
111
+
112
+ test("never exceeds the limit in flight", async () => {
113
+ let inFlight = 0;
114
+ let peak = 0;
115
+ await boundedConcurrently(Array.from({ length: 20 }, (_, i) => i), async () => {
116
+ inFlight += 1;
117
+ peak = Math.max(peak, inFlight);
118
+ await new Promise((r) => setTimeout(r, 1));
119
+ inFlight -= 1;
120
+ }, 4);
121
+ expect(peak).toBeLessThanOrEqual(4);
122
+ expect(peak).toBeGreaterThan(1); // it did run concurrently, not serially
123
+ });
124
+
125
+ test("an empty list is a no-op", async () => {
126
+ await expect(boundedConcurrently([], async () => {})).resolves.toBeUndefined();
127
+ });
128
+ });
129
+
130
+ describe("observeEntities harness (#1201)", () => {
131
+ const entity = (name: string, type = "Fake::Resource"): DeclaredEntity => ({ name, type, props: {} });
132
+
133
+ /** A fake adapter whose `read` is table-driven by entity name. */
134
+ const adapterOf = (
135
+ reads: Record<string, EntityObservation | (() => Promise<EntityObservation>)>,
136
+ over: Partial<ObserverAdapter<{ ok: true }>> = {},
137
+ ): ObserverAdapter<{ ok: true }> => ({
138
+ bind: async () => ({ ok: true }),
139
+ classifyBindFailure: () => ({ reason: "read-failed" }),
140
+ read: async (_client, e) => {
141
+ const r = reads[e.name];
142
+ if (typeof r === "function") return r();
143
+ if (!r) throw new Error(`no fake read for ${e.name}`);
144
+ return r;
145
+ },
146
+ ...over,
147
+ });
148
+
149
+ test("routes the tri-state: present -> resources, absent -> neither, unobserved -> unobserved", async () => {
150
+ const result = await observeEntities(
151
+ [entity("a"), entity("b"), entity("c", "Fake::Odd")],
152
+ adapterOf({
153
+ a: { present: meta({ physicalId: "id-a" }) },
154
+ b: { absent: true },
155
+ c: { unobserved: { reason: "unsupported-kind", detail: "no reader" } },
156
+ }),
157
+ );
158
+ expect(Object.keys(result.resources)).toEqual(["a"]);
159
+ expect(result.resources.a.physicalId).toBe("id-a");
160
+ // absent 'b' is in neither map
161
+ expect(result.unobserved).toEqual({
162
+ c: { type: "Fake::Odd", reason: "unsupported-kind", detail: "no reader" },
163
+ });
164
+ });
165
+
166
+ test("a bind failure marks every entity NOT-OBSERVED with the typed reason and declared type", async () => {
167
+ const result = await observeEntities(
168
+ [entity("a", "AWS::S3::Bucket"), entity("b", "AWS::S3::Bucket")],
169
+ adapterOf(
170
+ {},
171
+ {
172
+ bind: async () => {
173
+ throw new Error("no creds");
174
+ },
175
+ classifyBindFailure: () => ({ reason: "no-credentials", detail: "token expired" }),
176
+ },
177
+ ),
178
+ );
179
+ expect(result.resources).toEqual({});
180
+ expect(result.unobserved).toEqual({
181
+ a: { reason: "no-credentials", type: "AWS::S3::Bucket", detail: "token expired" },
182
+ b: { reason: "no-credentials", type: "AWS::S3::Bucket", detail: "token expired" },
183
+ });
184
+ });
185
+
186
+ test("a loud refusal rethrows instead of degrading to a hole", async () => {
187
+ await expect(
188
+ observeEntities(
189
+ [entity("a")],
190
+ adapterOf(
191
+ {},
192
+ {
193
+ bind: async () => {
194
+ throw new Error("context mismatch");
195
+ },
196
+ classifyBindFailure: () => "rethrow",
197
+ },
198
+ ),
199
+ ),
200
+ ).rejects.toThrow("context mismatch");
201
+ });
202
+
203
+ test("a per-entity read throw degrades to read-failed for that one entity, not an absence", async () => {
204
+ const result = await observeEntities(
205
+ [entity("a"), entity("b")],
206
+ adapterOf({
207
+ a: () => Promise.reject(new Error("boom")),
208
+ b: { present: meta() },
209
+ }),
210
+ );
211
+ expect(Object.keys(result.resources)).toEqual(["b"]);
212
+ expect(result.unobserved?.a).toEqual({ type: "Fake::Resource", reason: "read-failed", detail: "boom" });
213
+ });
214
+
215
+ test("uses the adapter's own concurrency pool when it supplies one", async () => {
216
+ let usedPool = false;
217
+ await observeEntities(
218
+ [entity("a")],
219
+ adapterOf(
220
+ { a: { present: meta() } },
221
+ {
222
+ concurrently: async (items, fn) => {
223
+ usedPool = true;
224
+ for (const it of items) await fn(it);
225
+ },
226
+ },
227
+ ),
228
+ );
229
+ expect(usedPool).toBe(true);
230
+ });
231
+ });