@intentius/chant-lexicon-aws 0.32.0 → 0.33.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/src/plugin.ts CHANGED
@@ -36,6 +36,59 @@ export { stackDoesNotExist } from "./stack-errors";
36
36
  * Provides serializer, lint rules, template detection,
37
37
  * import parsing, and code generation for AWS CloudFormation.
38
38
  */
39
+ /**
40
+ * Resolve `internetFacing` per instance LOGICAL id from LIVE route tables — a
41
+ * subnet is internet-facing iff its route table (an explicit association, else
42
+ * the VPC's main table) has a default route to an Internet Gateway. This covers
43
+ * the account's default VPC, whose routing chant does not model declaratively
44
+ * (the instance references it through a parameter). Best-effort: any failure
45
+ * returns what it has, so search still works on the declared topology.
46
+ */
47
+ async function liveInternetFacing(regionArgs: string[], stackName: string): Promise<Record<string, string>> {
48
+ const { getRuntime } = await import("@intentius/chant/runtime-adapter");
49
+ const rt = getRuntime();
50
+ const run = (args: string[]) =>
51
+ rt.spawn(applyAwsEndpointArgv(["aws", ...args, ...regionArgs, "--output", "json"], process.env.AWS_ENDPOINT_URL));
52
+ const out: Record<string, string> = {};
53
+ try {
54
+ const res = await run(["cloudformation", "describe-stack-resources", "--stack-name", stackName]);
55
+ if (res.exitCode !== 0) return out;
56
+ const stackRes = (JSON.parse(res.stdout).StackResources ?? []) as Array<{ LogicalResourceId: string; ResourceType: string; PhysicalResourceId: string }>;
57
+ const instByLogical = new Map<string, string>();
58
+ for (const r of stackRes) if (r.ResourceType === "AWS::EC2::Instance") instByLogical.set(r.LogicalResourceId, r.PhysicalResourceId);
59
+ if (instByLogical.size === 0) return out;
60
+
61
+ const di = await run(["ec2", "describe-instances", "--instance-ids", ...instByLogical.values()]);
62
+ if (di.exitCode !== 0) return out;
63
+ const locByInst = new Map<string, { subnet?: string; vpc?: string }>();
64
+ for (const rsv of (JSON.parse(di.stdout).Reservations ?? []) as Array<{ Instances?: Array<{ InstanceId: string; SubnetId?: string; VpcId?: string }> }>)
65
+ for (const i of rsv.Instances ?? []) locByInst.set(i.InstanceId, { subnet: i.SubnetId, vpc: i.VpcId });
66
+
67
+ const rtRes = await run(["ec2", "describe-route-tables"]);
68
+ if (rtRes.exitCode !== 0) return out;
69
+ const subnetIgw = new Map<string, string>();
70
+ const vpcMainIgw = new Map<string, string>();
71
+ for (const t of (JSON.parse(rtRes.stdout).RouteTables ?? []) as Array<{ RouteTableId?: string; VpcId?: string; Routes?: Array<{ GatewayId?: string }>; Associations?: Array<{ SubnetId?: string; Main?: boolean }> }>) {
72
+ const igwRoute = (t.Routes ?? []).find((r) => typeof r.GatewayId === "string" && r.GatewayId.startsWith("igw-"));
73
+ if (!igwRoute) continue;
74
+ const ev = `${t.RouteTableId ?? "rtb"} → ${igwRoute.GatewayId}`;
75
+ for (const a of t.Associations ?? []) {
76
+ if (a.SubnetId) subnetIgw.set(a.SubnetId, ev);
77
+ if (a.Main && t.VpcId) vpcMainIgw.set(t.VpcId, ev);
78
+ }
79
+ }
80
+
81
+ for (const [logical, instId] of instByLogical) {
82
+ const loc = locByInst.get(instId);
83
+ const ev = loc ? ((loc.subnet && subnetIgw.get(loc.subnet)) || (loc.vpc && vpcMainIgw.get(loc.vpc))) : undefined;
84
+ if (ev) out[logical] = ev;
85
+ }
86
+ } catch {
87
+ /* best-effort */
88
+ }
89
+ return out;
90
+ }
91
+
39
92
  export const awsPlugin: LexiconPlugin = {
40
93
  name: "aws",
41
94
  serializer: awsSerializer,
@@ -532,12 +585,15 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
532
585
  buildOutput: string;
533
586
  entityNames: string[];
534
587
  stack?: string;
588
+ region?: string;
535
589
  owned?: boolean;
536
590
  }): Promise<ObservationResult> {
537
591
  const { getRuntime } = await import("@intentius/chant/runtime-adapter");
538
592
  const { observation, unobservedAll } = await import("@intentius/chant/observation");
539
593
  const rt = getRuntime();
540
594
  const resources: Record<string, ResourceMetadata> = {};
595
+ // Multi-region estates: target this stack's region, not the ambient one.
596
+ const regionArgs = options.region ? ["--region", options.region] : [];
541
597
 
542
598
  if (options.owned) {
543
599
  // describe-stack-resources does not return tags, so ownership cannot be
@@ -559,6 +615,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
559
615
  const listResult = await rt.spawn(applyAwsEndpointArgv([
560
616
  "aws", "cloudformation", "describe-stack-resources",
561
617
  "--stack-name", stackName,
618
+ ...regionArgs,
562
619
  "--output", "json",
563
620
  ], process.env.AWS_ENDPOINT_URL));
564
621
 
@@ -607,6 +664,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
607
664
  const describeResult = await rt.spawn(applyAwsEndpointArgv([
608
665
  "aws", "cloudformation", "describe-stacks",
609
666
  "--stack-name", stackName,
667
+ ...regionArgs,
610
668
  "--output", "json",
611
669
  ], process.env.AWS_ENDPOINT_URL));
612
670
 
@@ -712,6 +770,7 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
712
770
  async exportResources(options: {
713
771
  environment: string;
714
772
  stack?: string;
773
+ region?: string;
715
774
  selector?: ResourceSelector;
716
775
  owned?: boolean;
717
776
  }): Promise<ExportedTemplate> {
@@ -721,10 +780,12 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
721
780
  // Same stack-name convention as describeResources: an explicit multi-stack
722
781
  // stack name (#932), else the stack named after the environment.
723
782
  const stackName = options.stack ?? `${options.environment}`;
783
+ const regionArgs = options.region ? ["--region", options.region] : [];
724
784
 
725
785
  const result = await rt.spawn(applyAwsEndpointArgv([
726
786
  "aws", "cloudformation", "get-template",
727
787
  "--stack-name", stackName,
788
+ ...regionArgs,
728
789
  "--template-stage", "Original",
729
790
  "--output", "json",
730
791
  ], process.env.AWS_ENDPOINT_URL));
@@ -750,9 +811,34 @@ aws cloudformation wait stack-update-complete --stack-name my-app-prod`,
750
811
  // describe-stack-resources is too thin; the deployed template (exportResources)
751
812
  // carries the references. Resolve its `{Ref}`/`{Fn::GetAtt}` intrinsics to bare
752
813
  // logical ids so the reference resolver matches them.
753
- async enrichLiveAttrs(options: { environment: string; stack?: string; owned?: boolean }): Promise<Record<string, Record<string, unknown>>> {
754
- const template = await this.exportResources!({ environment: options.environment, stack: options.stack, owned: options.owned });
755
- return resolveTemplateAttrs(template);
814
+ async enrichLiveAttrs(options: { environment: string; stack?: string; stacks?: Array<string | { name: string; region?: string }>; owned?: boolean }): Promise<Record<string, Record<string, unknown>>> {
815
+ // Multi-stack (#1161): a project declaring ChantConfig.stacks passes them
816
+ // here; enrich per-stack and merge. Otherwise the single-stack convention.
817
+ const stackRefs = options.stacks && options.stacks.length > 0
818
+ ? options.stacks.map((st) => (typeof st === "string" ? { name: st } : st))
819
+ : [{ name: options.stack ?? options.environment }];
820
+ const merged: Record<string, Record<string, unknown>> = {};
821
+ const multi = options.stacks && options.stacks.length > 0;
822
+ for (const ref of stackRefs) {
823
+ try {
824
+ const template = await this.exportResources!({ environment: options.environment, stack: ref.name, region: ref.region, owned: options.owned });
825
+ const attrs = resolveTemplateAttrs(template);
826
+ // Stack-qualify keys to match the observed node ids (#1162).
827
+ for (const [logicalId, v] of Object.entries(attrs)) {
828
+ merged[multi ? `${ref.name}::${logicalId}` : logicalId] = v;
829
+ }
830
+ // Resolve internetFacing from live route tables (covers the default VPC),
831
+ // carrying the route-table → IGW evidence so search can justify the match.
832
+ const facing = await liveInternetFacing(ref.region ? ["--region", ref.region] : [], ref.name);
833
+ for (const [logicalId, via] of Object.entries(facing)) {
834
+ const key = multi ? `${ref.name}::${logicalId}` : logicalId;
835
+ merged[key] = { ...merged[key], internetFacing: true, internetFacingVia: via };
836
+ }
837
+ } catch {
838
+ // A stack that isn't deployed yet contributes no live attrs — skip it.
839
+ }
840
+ }
841
+ return merged;
756
842
  },
757
843
 
758
844
  mcpTools() {