@intentius/chant-lexicon-aws 0.15.3 → 0.16.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.
@@ -0,0 +1,54 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { resolveTemplateAttrs } from "./live-attrs";
3
+ import { reconstructEdges } from "@intentius/chant/graph-refs";
4
+ import type { IRNode } from "@intentius/chant/graph-ir";
5
+ import { awsReferenceCatalog } from "./reference-catalog";
6
+ import type { ExportedTemplate } from "@intentius/chant/lexicon";
7
+
8
+ const tmpl = (resources: Array<{ logicalId: string; type: string; properties: Record<string, unknown> }>): ExportedTemplate =>
9
+ ({ resources, parameters: [], outputs: {} }) as unknown as ExportedTemplate;
10
+
11
+ describe("resolveTemplateAttrs", () => {
12
+ it("resolves {Ref} and {Fn::GetAtt} intrinsics to bare logical ids", () => {
13
+ const attrs = resolveTemplateAttrs(
14
+ tmpl([
15
+ { logicalId: "Subnet", type: "AWS::EC2::Subnet", properties: { VpcId: { Ref: "Vpc" }, CidrBlock: "10.0.1.0/24" } },
16
+ { logicalId: "Listener", type: "AWS::ElasticLoadBalancingV2::Listener", properties: { LoadBalancerArn: { "Fn::GetAtt": ["Alb", "Arn"] } } },
17
+ ]),
18
+ );
19
+ expect(attrs.Subnet).toEqual({ VpcId: "Vpc", CidrBlock: "10.0.1.0/24" });
20
+ expect(attrs.Listener).toEqual({ LoadBalancerArn: "Alb" });
21
+ });
22
+
23
+ it("resolves intrinsics nested in arrays and objects", () => {
24
+ const attrs = resolveTemplateAttrs(
25
+ tmpl([
26
+ { logicalId: "Inst", type: "AWS::EC2::Instance", properties: { SecurityGroupIds: [{ Ref: "SgA" }, { Ref: "SgB" }], Nested: { SubnetId: { Ref: "Sn" } } } },
27
+ ]),
28
+ );
29
+ expect(attrs.Inst.SecurityGroupIds).toEqual(["SgA", "SgB"]);
30
+ expect(attrs.Inst.Nested).toEqual({ SubnetId: "Sn" });
31
+ });
32
+ });
33
+
34
+ describe("live edges from a CloudFormation template (#784)", () => {
35
+ it("reconstructs containment + edges from resolved template attrs", () => {
36
+ const template = tmpl([
37
+ { logicalId: "Vpc", type: "AWS::EC2::VPC", properties: { CidrBlock: "10.0.0.0/16" } },
38
+ { logicalId: "Subnet", type: "AWS::EC2::Subnet", properties: { VpcId: { Ref: "Vpc" } } },
39
+ { logicalId: "Sg", type: "AWS::EC2::SecurityGroup", properties: { VpcId: { Ref: "Vpc" } } },
40
+ { logicalId: "Inst", type: "AWS::EC2::Instance", properties: { SubnetId: { Ref: "Subnet" }, SecurityGroupIds: [{ Ref: "Sg" }] } },
41
+ ]);
42
+ const attrsById = resolveTemplateAttrs(template);
43
+ const nodes: IRNode[] = template.resources.map((r) => ({ id: r.logicalId, kind: r.type, lexicon: "aws", attrs: attrsById[r.logicalId] }));
44
+
45
+ const { edges, containment, dangling } = reconstructEdges(nodes, awsReferenceCatalog);
46
+ // containment: subnet and sg are inside the VPC; the instance is inside the subnet
47
+ expect(containment).toContainEqual({ child: "Subnet", parent: "Vpc", label: "in VPC" });
48
+ expect(containment).toContainEqual({ child: "Sg", parent: "Vpc", label: "in VPC" });
49
+ expect(containment).toContainEqual({ child: "Inst", parent: "Subnet", label: "in subnet" });
50
+ // edge: the instance references the security group
51
+ expect(edges).toContainEqual({ from: "Inst", to: "Sg", kind: "ref", viaAttr: "sg" });
52
+ expect(dangling).toEqual([]);
53
+ });
54
+ });
@@ -0,0 +1,40 @@
1
+ import type { ExportedTemplate } from "@intentius/chant/lexicon";
2
+
3
+ /**
4
+ * Resolve a live CloudFormation template's per-resource properties into flat
5
+ * attributes for `chant graph --live` edge reconstruction (#784).
6
+ *
7
+ * `describeResources()` returns thin metadata (stack outputs), so the reference
8
+ * resolver (#778) had nothing to match on. `exportResources()` returns the
9
+ * deployed template, where resources reference each other by **logical id** via
10
+ * `{Ref: LogicalId}` / `{Fn::GetAtt: [LogicalId, …]}`. Logical id == the IR node
11
+ * id (both keyed by the CloudFormation logical name), so resolving those
12
+ * intrinsics to the bare logical-id string turns each reference into a value the
13
+ * resolver matches directly (it indexes every node by its own id).
14
+ */
15
+ function resolveIntrinsics(v: unknown): unknown {
16
+ if (Array.isArray(v)) return v.map(resolveIntrinsics);
17
+ if (v && typeof v === "object") {
18
+ const o = v as Record<string, unknown>;
19
+ const keys = Object.keys(o);
20
+ if (keys.length === 1 && typeof o.Ref === "string") return o.Ref;
21
+ if (keys.length === 1 && o["Fn::GetAtt"] !== undefined) {
22
+ const g = o["Fn::GetAtt"];
23
+ if (Array.isArray(g) && typeof g[0] === "string") return g[0]; // ["LogicalId", "Attr"]
24
+ if (typeof g === "string") return g.split(".")[0]; // "LogicalId.Attr"
25
+ }
26
+ const out: Record<string, unknown> = {};
27
+ for (const [k, val] of Object.entries(o)) out[k] = resolveIntrinsics(val);
28
+ return out;
29
+ }
30
+ return v;
31
+ }
32
+
33
+ /** logical id → resolved properties (references become bare logical-id strings). */
34
+ export function resolveTemplateAttrs(template: ExportedTemplate): Record<string, Record<string, unknown>> {
35
+ const out: Record<string, Record<string, unknown>> = {};
36
+ for (const r of template.resources) {
37
+ out[r.logicalId] = resolveIntrinsics(r.properties) as Record<string, unknown>;
38
+ }
39
+ return out;
40
+ }
@@ -54,7 +54,14 @@ export function capabilityParams(capabilities: string[]): Record<string, string>
54
54
  return out;
55
55
  }
56
56
 
57
- /** First `<tag>…</tag>` text in a CFN XML response. */
57
+ /**
58
+ * First `<tag>…</tag>` text in a CFN XML response.
59
+ *
60
+ * Assumes flat scalar fields — the CloudFormation Query API returns simple
61
+ * `<StackStatus>…</StackStatus>` style leaves, so `[^<]*` is sufficient. It does
62
+ * NOT handle nested tags or XML entities in the value; if a field ever carries
63
+ * either, replace this with a real XML parser.
64
+ */
58
65
  export function xmlField(xml: string, tag: string): string | undefined {
59
66
  return xml.match(new RegExp(`<${tag}>([^<]*)</${tag}>`))?.[1];
60
67
  }
@@ -1,14 +1,11 @@
1
- import { exec } from "node:child_process";
2
- import { promisify } from "node:util";
3
- import { safeHeartbeat, sleep } from "@intentius/chant/op";
4
-
5
- const execAsync = promisify(exec);
1
+ import { emulatorLifecycle } from "@intentius/chant/op";
6
2
 
7
3
  const DEFAULT_NAME = "chant-floci";
8
4
  const DEFAULT_PORT = 4566;
9
5
  const DEFAULT_IMAGE = "floci/floci:latest";
10
6
  const DEFAULT_REGION = "us-east-1";
11
7
  const DEFAULT_READY_SERVICE = "cloudformation";
8
+ const DOCKER_SOCK = ["-v", "/var/run/docker.sock:/var/run/docker.sock"] as const;
12
9
 
13
10
  export interface FlociUpArgs {
14
11
  /** Container name. Default: `chant-floci`. */
@@ -34,30 +31,9 @@ export interface FlociDownArgs {
34
31
  name?: string;
35
32
  }
36
33
 
37
- /** `docker ps -q -f name=<name>` non-empty stdout means the container is running. */
38
- export function flociExistsCommand(name: string): string {
39
- return `docker ps -q -f name=${name}`;
40
- }
41
-
42
- /** Build the `docker run` command that boots Floci. */
43
- export function flociRunCommand(args: FlociUpArgs): string {
44
- const name = args.name ?? DEFAULT_NAME;
45
- const port = args.port ?? DEFAULT_PORT;
46
- const image = args.image ?? DEFAULT_IMAGE;
47
- const parts = ["docker", "run", "-d", "--rm", "--name", name, "-p", `${port}:4566`];
48
- if (args.dockerSocket) parts.push("-v", "/var/run/docker.sock:/var/run/docker.sock");
49
- parts.push(image);
50
- return parts.join(" ");
51
- }
52
-
53
- /** Build the `docker rm -f` command. */
54
- export function flociRmCommand(name: string): string {
55
- return `docker rm -f ${name}`;
56
- }
57
-
58
- /** The Floci health endpoint URL for a host port. */
59
- export function flociHealthUrl(port: number): string {
60
- return `http://localhost:${port}/_localstack/health`;
34
+ /** True once the health body reports the required service (e.g. `"cloudformation"`). */
35
+ export function isFlociReady(healthBody: string, service: string): boolean {
36
+ return healthBody.includes(`"${service}"`);
61
37
  }
62
38
 
63
39
  /** The AWS env vars that point the `aws` CLI / SDK at a local Floci endpoint. */
@@ -70,9 +46,32 @@ export function flociEnv(port: number, region: string): Record<string, string> {
70
46
  };
71
47
  }
72
48
 
73
- /** True once the health body reports the required service (e.g. `"cloudformation"`). */
74
- export function isFlociReady(healthBody: string, service: string): boolean {
75
- return healthBody.includes(`"${service}"`);
49
+ // AWS runs the LocalStack-compatible floci/floci image, so readiness is gated on
50
+ // a service (`cloudformation`) appearing in `/_localstack/health` unlike the
51
+ // bespoke az/gcp fakes. Shared lifecycle: emulatorLifecycle (#746).
52
+ const flociFor = (readyService: string) =>
53
+ emulatorLifecycle({
54
+ name: DEFAULT_NAME,
55
+ image: DEFAULT_IMAGE,
56
+ containerPort: DEFAULT_PORT,
57
+ healthPath: "/_localstack/health",
58
+ ready: (body) => isFlociReady(body, readyService),
59
+ });
60
+
61
+ const builders = flociFor(DEFAULT_READY_SERVICE);
62
+
63
+ export const flociExistsCommand = builders.existsCommand;
64
+ export const flociRmCommand = builders.rmCommand;
65
+ export const flociHealthUrl = builders.healthUrl;
66
+
67
+ /** Build the `docker run` command that boots Floci. */
68
+ export function flociRunCommand(args: FlociUpArgs = {}): string {
69
+ return builders.runCommand({
70
+ name: args.name,
71
+ port: args.port,
72
+ image: args.image,
73
+ extraArgs: args.dockerSocket ? [...DOCKER_SOCK] : [],
74
+ });
76
75
  }
77
76
 
78
77
  /**
@@ -81,69 +80,28 @@ export function isFlociReady(healthBody: string, service: string): boolean {
81
80
  * Idempotent: reuses a running container of the same name. Waits for the health
82
81
  * endpoint to report `readyService`, then sets `AWS_ENDPOINT_URL` + test creds in
83
82
  * the process environment so a following `nativeApply`/`cfn-deploy` targets the
84
- * emulator. Env injection assumes the in-process **local executor**; under a
83
+ * emulator. Env injection assumes the in-process local executor; under a
85
84
  * distributed Temporal worker, pass the endpoint explicitly instead.
86
- *
87
- * Uses longInfra profile — 20m timeout, heartbeat every poll (the image may pull).
88
85
  */
89
- export async function flociUp(args: FlociUpArgs, signal?: AbortSignal): Promise<{ endpoint: string }> {
90
- const name = args.name ?? DEFAULT_NAME;
91
- const port = args.port ?? DEFAULT_PORT;
86
+ export async function flociUp(args: FlociUpArgs = {}, signal?: AbortSignal): Promise<{ endpoint: string }> {
92
87
  const region = args.region ?? DEFAULT_REGION;
93
88
  const service = args.readyService ?? DEFAULT_READY_SERVICE;
94
- const timeoutMs = args.timeoutMs ?? 60_000;
95
- const intervalMs = args.intervalMs ?? 2_000;
96
-
97
- let running = false;
98
- try {
99
- const { stdout } = await execAsync(flociExistsCommand(name), { signal });
100
- running = Boolean(stdout.trim());
101
- } catch {
102
- // `docker ps` failed — assume not running and try to start it.
103
- }
104
-
105
- if (running) {
106
- console.log(`Floci container "${name}" already running — reusing`);
107
- } else {
108
- await execAsync(flociRunCommand({ ...args, name, port }), { signal });
109
- }
110
-
111
- const url = flociHealthUrl(port);
112
- const deadline = Date.now() + timeoutMs;
113
- let ready = false;
114
- while (Date.now() < deadline) {
115
- if (signal?.aborted) throw new Error("flociUp aborted");
116
- safeHeartbeat({ step: "flociUp", container: name });
117
- try {
118
- const res = await fetch(url, { signal });
119
- if (res.ok && isFlociReady(await res.text(), service)) {
120
- ready = true;
121
- break;
122
- }
123
- } catch {
124
- // Not up yet (connection refused / non-2xx) — retry.
125
- }
126
- await sleep(intervalMs, signal);
127
- }
128
- if (!ready) {
129
- throw new Error(`Floci "${name}" did not become ready within ${timeoutMs}ms`);
130
- }
131
-
132
- const env = flociEnv(port, region);
133
- Object.assign(process.env, env);
134
- console.log(`Floci ready on ${env.AWS_ENDPOINT_URL} (service: ${service})`);
135
- return { endpoint: env.AWS_ENDPOINT_URL };
89
+ const { endpoint } = await flociFor(service).up(
90
+ {
91
+ name: args.name,
92
+ port: args.port,
93
+ image: args.image,
94
+ timeoutMs: args.timeoutMs,
95
+ intervalMs: args.intervalMs,
96
+ extraArgs: args.dockerSocket ? [...DOCKER_SOCK] : [],
97
+ },
98
+ signal,
99
+ );
100
+ Object.assign(process.env, flociEnv(args.port ?? DEFAULT_PORT, region));
101
+ return { endpoint };
136
102
  }
137
103
 
138
- /**
139
- * Stop and remove the local Floci emulator container. A no-op success when the
140
- * container is already gone. Uses fastIdempotent profile — 5m timeout.
141
- */
142
- export async function flociDown(args: FlociDownArgs, signal?: AbortSignal): Promise<void> {
143
- const name = args.name ?? DEFAULT_NAME;
144
- try {
145
- await execAsync(flociRmCommand(name), { signal });
146
- } catch {
147
- // Already removed (`--rm` on exit, or never started) — treat as success.
148
- }
104
+ /** Stop and remove the local Floci emulator container (no-op if already gone). */
105
+ export async function flociDown(args: FlociDownArgs = {}, signal?: AbortSignal): Promise<void> {
106
+ return builders.down(args, signal);
149
107
  }
package/src/plugin.ts CHANGED
@@ -14,6 +14,8 @@ import { readFileSync } from "fs";
14
14
  import { join, dirname } from "path";
15
15
  import { fileURLToPath } from "url";
16
16
  import { awsSerializer } from "./serializer";
17
+ import { awsReferenceCatalog } from "./reference-catalog";
18
+ import { resolveTemplateAttrs } from "./live-attrs";
17
19
  import { CFParser } from "./import/parser";
18
20
  import { CFGenerator } from "./import/generator";
19
21
  import { parseStackTemplate } from "./import/live-export";
@@ -31,6 +33,8 @@ export const awsPlugin: LexiconPlugin = {
31
33
  serializer: awsSerializer,
32
34
  // Audit rule metadata for this lexicon's WAW* checks (#687).
33
35
  auditCatalog: () => awsAuditCatalog,
36
+ // Live edge reconstruction for `chant graph --live` (#778).
37
+ referenceCatalog: awsReferenceCatalog,
34
38
 
35
39
  lintRules(): LintRule[] {
36
40
  const rulesDir = join(dirname(fileURLToPath(import.meta.url)), "lint", "rules");
@@ -580,6 +584,15 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
580
584
  return parseStackTemplate(parsed.TemplateBody, options.selector, options.owned);
581
585
  },
582
586
 
587
+ // Live attribute enrichment for `chant graph --live` edge reconstruction (#784).
588
+ // describe-stack-resources is too thin; the deployed template (exportResources)
589
+ // carries the references. Resolve its `{Ref}`/`{Fn::GetAtt}` intrinsics to bare
590
+ // logical ids so the reference resolver matches them.
591
+ async enrichLiveAttrs(options: { environment: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>> {
592
+ const template = await this.exportResources!({ environment: options.environment, owned: options.owned });
593
+ return resolveTemplateAttrs(template);
594
+ },
595
+
583
596
  mcpTools() {
584
597
  return [
585
598
  {
@@ -0,0 +1,74 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { reconstructEdges, containmentGroups } from "@intentius/chant/graph-refs";
3
+ import type { IRNode } from "@intentius/chant/graph-ir";
4
+ import { awsReferenceCatalog } from "./reference-catalog";
5
+
6
+ const n = (id: string, kind: string, attrs: Record<string, unknown>): IRNode => ({ id, kind, lexicon: "aws", attrs });
7
+
8
+ // A canonical 3-tier VPC (managed subgraph): internet → ALB (public subnets) →
9
+ // ECS service (private subnets) → RDS, with SG attachments. Attributes are the
10
+ // per-resource describe/export shape the catalog targets.
11
+ const nodes: IRNode[] = [
12
+ n("vpc", "AWS::EC2::VPC", { VpcId: "vpc-1" }),
13
+ n("pubA", "AWS::EC2::Subnet", { SubnetId: "subnet-pa", VpcId: "vpc-1" }),
14
+ n("pubB", "AWS::EC2::Subnet", { SubnetId: "subnet-pb", VpcId: "vpc-1" }),
15
+ n("privA", "AWS::EC2::Subnet", { SubnetId: "subnet-qa", VpcId: "vpc-1" }),
16
+ n("privB", "AWS::EC2::Subnet", { SubnetId: "subnet-qb", VpcId: "vpc-1" }),
17
+ n("albSg", "AWS::EC2::SecurityGroup", { GroupId: "sg-alb", VpcId: "vpc-1" }),
18
+ n("appSg", "AWS::EC2::SecurityGroup", { GroupId: "sg-app", VpcId: "vpc-1", IpPermissions: [{ UserIdGroupPairs: [{ GroupId: "sg-alb" }] }] }),
19
+ n("alb", "AWS::ElasticLoadBalancingV2::LoadBalancer", { LoadBalancerArn: "arn-alb", AvailabilityZones: [{ SubnetId: "subnet-pa" }, { SubnetId: "subnet-pb" }], SecurityGroups: ["sg-alb"] }),
20
+ n("tg", "AWS::ElasticLoadBalancingV2::TargetGroup", { TargetGroupArn: "arn-tg", VpcId: "vpc-1" }),
21
+ n("listener", "AWS::ElasticLoadBalancingV2::Listener", { LoadBalancerArn: "arn-alb", DefaultActions: [{ TargetGroupArn: "arn-tg" }] }),
22
+ n("cluster", "AWS::ECS::Cluster", { ClusterArn: "arn-cl" }),
23
+ n("taskdef", "AWS::ECS::TaskDefinition", { TaskDefinitionArn: "arn-td" }),
24
+ n("svc", "AWS::ECS::Service", { ServiceArn: "arn-svc", ClusterArn: "arn-cl", TaskDefinition: "arn-td", LoadBalancers: [{ TargetGroupArn: "arn-tg" }], NetworkConfiguration: { AwsvpcConfiguration: { Subnets: ["subnet-qa", "subnet-qb"], SecurityGroups: ["sg-app"] } } }),
25
+ n("rds", "AWS::RDS::DBInstance", { DBInstanceArn: "arn-rds", DBSubnetGroup: { Subnets: [{ SubnetIdentifier: "subnet-qa" }, { SubnetIdentifier: "subnet-qb" }] }, VpcSecurityGroups: [{ VpcSecurityGroupId: "sg-app" }] }),
26
+ ];
27
+
28
+ describe("awsReferenceCatalog — golden 3-tier VPC", () => {
29
+ const { edges, containment, dangling } = reconstructEdges(nodes, awsReferenceCatalog);
30
+ const hasEdge = (from: string, to: string, via?: string) =>
31
+ edges.some((e) => e.from === from && e.to === to && (via === undefined || e.viaAttr === via));
32
+ const hasCont = (child: string, parent: string) =>
33
+ containment.some((c) => c.child === child && c.parent === parent);
34
+
35
+ it("reconstructs the topology edges", () => {
36
+ expect(hasEdge("svc", "cluster", "in cluster")).toBe(true);
37
+ expect(hasEdge("svc", "taskdef", "runs")).toBe(true);
38
+ expect(hasEdge("svc", "tg", "registered in")).toBe(true);
39
+ expect(hasEdge("svc", "appSg", "sg")).toBe(true);
40
+ expect(hasEdge("listener", "alb", "on")).toBe(true);
41
+ expect(hasEdge("listener", "tg", "forwards to")).toBe(true);
42
+ expect(hasEdge("alb", "albSg", "sg")).toBe(true);
43
+ expect(hasEdge("appSg", "albSg", "allows")).toBe(true);
44
+ expect(hasEdge("rds", "appSg", "sg")).toBe(true);
45
+ });
46
+
47
+ it("reconstructs network containment (→ #779 boundaries), not as edges", () => {
48
+ for (const s of ["pubA", "pubB", "privA", "privB", "albSg", "appSg", "tg"]) expect(hasCont(s, "vpc")).toBe(true);
49
+ expect(hasCont("alb", "pubA")).toBe(true);
50
+ expect(hasCont("alb", "pubB")).toBe(true);
51
+ expect(hasCont("svc", "privA")).toBe(true);
52
+ expect(hasCont("rds", "privB")).toBe(true);
53
+ // containment is never an edge
54
+ expect(edges.some((e) => e.to === "vpc")).toBe(false);
55
+ });
56
+
57
+ it("has no dangling references — every target is in the observed set", () => {
58
+ expect(dangling).toEqual([]);
59
+ });
60
+
61
+ it("reference edges point holder → referenced", () => {
62
+ // e.g. the service references the cluster, not the reverse
63
+ expect(hasEdge("cluster", "svc")).toBe(false);
64
+ });
65
+
66
+ it("containment groups nest VPC → subnets → resources (#779)", () => {
67
+ const groups = containmentGroups(containment);
68
+ // the VPC contains its subnets, SGs, and target group
69
+ expect(groups.vpc).toEqual(expect.arrayContaining(["pubA", "pubB", "privA", "privB", "albSg", "appSg", "tg"]));
70
+ // public subnets contain the ALB; private subnets contain the service + RDS
71
+ expect(groups.pubA).toContain("alb");
72
+ expect(groups.privA).toEqual(expect.arrayContaining(["svc", "rds"]));
73
+ });
74
+ });
@@ -0,0 +1,82 @@
1
+ import type { ReferenceCatalog } from "@intentius/chant/lexicon";
2
+
3
+ /**
4
+ * AWS reference catalog (#778, epic #776 v1) — how observed AWS resources
5
+ * reference each other, so `chant graph --live` can reconstruct the topology.
6
+ *
7
+ * Keyed to the per-resource describe/export attribute shape (`VpcId`,
8
+ * `SecurityGroups[].GroupId`, `TargetGroupArn`, …), not the thin
9
+ * `describe-stack-resources` metadata. Enough to draw the canonical 3-tier VPC
10
+ * (internet → ALB → ECS service → RDS, with SG attachments). `containment`
11
+ * relations (subnet ∈ VPC) feed #779's boundary boxes; `reference` relations are
12
+ * edges.
13
+ */
14
+ export const awsReferenceCatalog: ReferenceCatalog = {
15
+ identities: [
16
+ { kind: "AWS::EC2::VPC", ids: ["VpcId"] },
17
+ { kind: "AWS::EC2::Subnet", ids: ["SubnetId"] },
18
+ { kind: "AWS::EC2::SecurityGroup", ids: ["GroupId"] },
19
+ { kind: "AWS::EC2::Instance", ids: ["InstanceId"] },
20
+ { kind: "AWS::EC2::InternetGateway", ids: ["InternetGatewayId"] },
21
+ { kind: "AWS::EC2::NatGateway", ids: ["NatGatewayId"] },
22
+ { kind: "AWS::EC2::RouteTable", ids: ["RouteTableId"] },
23
+ { kind: "AWS::ElasticLoadBalancingV2::LoadBalancer", ids: ["LoadBalancerArn", "DNSName"] },
24
+ { kind: "AWS::ElasticLoadBalancingV2::TargetGroup", ids: ["TargetGroupArn"] },
25
+ { kind: "AWS::ECS::Cluster", ids: ["ClusterArn", "ClusterName"] },
26
+ { kind: "AWS::ECS::Service", ids: ["ServiceArn"] },
27
+ { kind: "AWS::ECS::TaskDefinition", ids: ["TaskDefinitionArn"] },
28
+ { kind: "AWS::RDS::DBInstance", ids: ["DBInstanceArn", "Endpoint.Address"] },
29
+ ],
30
+ refs: [
31
+ // ── containment (→ boundary boxes, #779) ──
32
+ { from: "AWS::EC2::Subnet", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
33
+ { from: "AWS::EC2::SecurityGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
34
+ { from: "AWS::EC2::RouteTable", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
35
+ { from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
36
+ { from: "AWS::EC2::NatGateway", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
37
+ { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "AvailabilityZones[].SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
38
+ { from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
39
+ { from: "AWS::ECS::Service", path: "NetworkConfiguration.AwsvpcConfiguration.Subnets[]", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
40
+ { from: "AWS::RDS::DBInstance", path: "DBSubnetGroup.Subnets[].SubnetIdentifier", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
41
+
42
+ // ── references (→ edges) ──
43
+ { from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
44
+ { from: "AWS::EC2::SecurityGroup", path: "IpPermissions[].UserIdGroupPairs[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "allows" },
45
+ { from: "AWS::EC2::Route", path: "GatewayId", targetKind: "AWS::EC2::InternetGateway", relation: "reference", label: "via" },
46
+ { from: "AWS::EC2::Route", path: "NatGatewayId", targetKind: "AWS::EC2::NatGateway", relation: "reference", label: "via" },
47
+ { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "SecurityGroups[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
48
+ { from: "AWS::ElasticLoadBalancingV2::Listener", path: "LoadBalancerArn", targetKind: "AWS::ElasticLoadBalancingV2::LoadBalancer", relation: "reference", label: "on" },
49
+ { from: "AWS::ElasticLoadBalancingV2::Listener", path: "DefaultActions[].TargetGroupArn", targetKind: "AWS::ElasticLoadBalancingV2::TargetGroup", relation: "reference", label: "forwards to" },
50
+ { from: "AWS::ECS::Service", path: "ClusterArn", targetKind: "AWS::ECS::Cluster", relation: "reference", label: "in cluster" },
51
+ { from: "AWS::ECS::Service", path: "TaskDefinition", targetKind: "AWS::ECS::TaskDefinition", relation: "reference", label: "runs" },
52
+ { from: "AWS::ECS::Service", path: "LoadBalancers[].TargetGroupArn", targetKind: "AWS::ElasticLoadBalancingV2::TargetGroup", relation: "reference", label: "registered in" },
53
+ { from: "AWS::ECS::Service", path: "NetworkConfiguration.AwsvpcConfiguration.SecurityGroups[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
54
+ { from: "AWS::RDS::DBInstance", path: "VpcSecurityGroups[].VpcSecurityGroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
55
+
56
+ // ── CloudFormation *template* property paths (#784) ──
57
+ // `enrichLiveAttrs` resolves the deployed template's `{Ref}`/`{Fn::GetAtt}`
58
+ // intrinsics to bare logical ids (= node ids), so these match by node id; the
59
+ // property names are the CFN template shape (SecurityGroupIds, Cluster, …),
60
+ // which differs from the describe/SDK shape above. No targetKind needed —
61
+ // logical ids are unique.
62
+ { from: "AWS::EC2::Subnet", path: "VpcId", relation: "containment", label: "in VPC" },
63
+ { from: "AWS::EC2::SecurityGroup", path: "VpcId", relation: "containment", label: "in VPC" },
64
+ { from: "AWS::EC2::RouteTable", path: "VpcId", relation: "containment", label: "in VPC" },
65
+ { from: "AWS::EC2::Instance", path: "SubnetId", relation: "containment", label: "in subnet" },
66
+ { from: "AWS::EC2::Instance", path: "SecurityGroupIds[]", relation: "reference", label: "sg" },
67
+ { from: "AWS::EC2::NatGateway", path: "SubnetId", relation: "containment", label: "in subnet" },
68
+ { from: "AWS::EC2::Route", path: "RouteTableId", relation: "reference", label: "in" },
69
+ { from: "AWS::EC2::Route", path: "GatewayId", relation: "reference", label: "via" },
70
+ { from: "AWS::EC2::Route", path: "NatGatewayId", relation: "reference", label: "via" },
71
+ { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "Subnets[]", relation: "containment", label: "in subnet" },
72
+ { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "SecurityGroups[]", relation: "reference", label: "sg" },
73
+ { from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", relation: "containment", label: "in VPC" },
74
+ { from: "AWS::ElasticLoadBalancingV2::Listener", path: "LoadBalancerArn", relation: "reference", label: "on" },
75
+ { from: "AWS::ElasticLoadBalancingV2::Listener", path: "DefaultActions[].TargetGroupArn", relation: "reference", label: "forwards to" },
76
+ { from: "AWS::ECS::Service", path: "Cluster", relation: "reference", label: "in cluster" },
77
+ { from: "AWS::ECS::Service", path: "TaskDefinition", relation: "reference", label: "runs" },
78
+ { from: "AWS::ECS::Service", path: "LoadBalancers[].TargetGroupArn", relation: "reference", label: "registered in" },
79
+ { from: "AWS::RDS::DBInstance", path: "DBSubnetGroupName", relation: "reference", label: "subnets" },
80
+ { from: "AWS::RDS::DBInstance", path: "VPCSecurityGroups[]", relation: "reference", label: "sg" },
81
+ ],
82
+ };