@intentius/chant-lexicon-aws 0.33.1 → 0.34.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,166 @@
1
+ /**
2
+ * A managed resource's own properties (#1279).
3
+ *
4
+ * `describe-stack-resources` returns identity and status — logical id, physical
5
+ * id, type, timestamp — and nothing about the resource itself. So the managed
6
+ * observation filled `attributes` with the *stack's* outputs instead, copied
7
+ * onto every resource in the stack. Every node in a stack came out carrying the
8
+ * same `expVpcId`/`expWebIp` keys, and no node carried its own `VpcId`.
9
+ *
10
+ * That is invisible until something asks. `search --show VpcId` printed a blank
11
+ * column for six instances, which reads as "the estate has no VPCs" rather than
12
+ * "chant never read that". An agent asked which instances were outside the
13
+ * default VPC concluded all six were, because nothing in the graph said
14
+ * otherwise.
15
+ *
16
+ * A deep read (Cloud Control) answers this properly but is a per-resource call
17
+ * and is not available on every endpoint. This is the cheap middle: one describe
18
+ * per kind for the whole observation, joined back by physical id. Stack outputs
19
+ * are kept — they were the only attributes for a long time and queries lean on
20
+ * them — but a resource's own properties win a name collision, because they are
21
+ * the resource's.
22
+ */
23
+
24
+ import { applyAwsEndpointArgv } from "./components/cloud-executor";
25
+ import type { ResourceMetadata } from "@intentius/chant/lexicon";
26
+
27
+ /**
28
+ * How to read each kind in bulk, and which field joins back to the physical id.
29
+ *
30
+ * Extending this is the way to widen coverage. Deliberately batch calls: one
31
+ * `describe-instances` for every instance in the stack, not one per instance.
32
+ */
33
+ const DESCRIBE: Record<
34
+ string,
35
+ { argv: string[]; idFlag: string; key: string; id: string; nested?: string }
36
+ > = {
37
+ "AWS::EC2::Instance": {
38
+ argv: ["ec2", "describe-instances"],
39
+ idFlag: "--instance-ids",
40
+ key: "Reservations",
41
+ nested: "Instances",
42
+ id: "InstanceId",
43
+ },
44
+ "AWS::EC2::VPC": { argv: ["ec2", "describe-vpcs"], idFlag: "--vpc-ids", key: "Vpcs", id: "VpcId" },
45
+ "AWS::EC2::Subnet": {
46
+ argv: ["ec2", "describe-subnets"],
47
+ idFlag: "--subnet-ids",
48
+ key: "Subnets",
49
+ id: "SubnetId",
50
+ },
51
+ "AWS::EC2::SecurityGroup": {
52
+ argv: ["ec2", "describe-security-groups"],
53
+ idFlag: "--group-ids",
54
+ key: "SecurityGroups",
55
+ id: "GroupId",
56
+ },
57
+ };
58
+
59
+ /**
60
+ * Stamp the region each resource was observed in (#1279).
61
+ *
62
+ * The observation is already scoped per stack and each stack declares its
63
+ * region, so the reader knows this and was throwing it away. Without it the
64
+ * only route to "which region is this in" was parsing
65
+ * `Placement.AvailabilityZone` and trimming the last character — a trick that
66
+ * happens to work for EC2 and for nothing else. Region is a dimension of the
67
+ * estate, not a substring of an availability zone.
68
+ */
69
+ export function stampRegion(
70
+ resources: Record<string, ResourceMetadata>,
71
+ region?: string,
72
+ ): Record<string, ResourceMetadata> {
73
+ // Fall back to the region the call would actually have used, so a
74
+ // single-region project gets the same attribute a multi-region one does.
75
+ const value = region ?? process.env.AWS_REGION ?? process.env.AWS_DEFAULT_REGION;
76
+ if (!value) return resources;
77
+ const stamped: Record<string, ResourceMetadata> = {};
78
+ for (const [name, meta] of Object.entries(resources)) {
79
+ stamped[name] = { ...meta, attributes: { ...(meta.attributes ?? {}), region: value } };
80
+ }
81
+ return stamped;
82
+ }
83
+
84
+ /** True when this lexicon can read the kind's own properties. */
85
+ export function canDescribe(kind: string): boolean {
86
+ return kind in DESCRIBE;
87
+ }
88
+
89
+ /**
90
+ * Merge each resource's own properties into an observation, in place of nothing.
91
+ *
92
+ * Best-effort per kind, and per kind only: an endpoint that cannot answer
93
+ * `describe-subnets` still yields instance properties, and a total failure
94
+ * leaves the observation exactly as it arrived. The managed observation is
95
+ * already complete without any of this — these are additional facts about
96
+ * resources chant has already identified, so a miss costs detail, never
97
+ * correctness.
98
+ */
99
+ export async function describeOwnProperties(
100
+ resources: Record<string, ResourceMetadata>,
101
+ region?: string,
102
+ ): Promise<Record<string, ResourceMetadata>> {
103
+ // Group the physical ids to look up by kind, so each kind is one call.
104
+ const wanted = new Map<string, Map<string, string[]>>();
105
+ for (const [name, meta] of Object.entries(resources)) {
106
+ if (!meta.physicalId || !canDescribe(meta.type)) continue;
107
+ const byId = wanted.get(meta.type) ?? new Map<string, string[]>();
108
+ byId.set(meta.physicalId, [...(byId.get(meta.physicalId) ?? []), name]);
109
+ wanted.set(meta.type, byId);
110
+ }
111
+ if (wanted.size === 0) return resources;
112
+
113
+ const { getRuntime } = await import("@intentius/chant/runtime-adapter");
114
+ const rt = getRuntime();
115
+ const regionArgs = region ? ["--region", region] : [];
116
+ const merged = { ...resources };
117
+
118
+ /** One describe for a set of ids. `null` when the call itself failed. */
119
+ const read = async (spec: (typeof DESCRIBE)[string], ids: string[]) => {
120
+ const result = await rt.spawn(
121
+ applyAwsEndpointArgv(
122
+ ["aws", ...spec.argv, spec.idFlag, ...ids, ...regionArgs, "--output", "json"],
123
+ process.env.AWS_ENDPOINT_URL,
124
+ ),
125
+ );
126
+ if (result.exitCode !== 0) return null;
127
+ return (JSON.parse(result.stdout)[spec.key] ?? []) as Array<Record<string, unknown>>;
128
+ };
129
+
130
+ for (const [kind, byId] of wanted) {
131
+ const spec = DESCRIBE[kind];
132
+ try {
133
+ const ids = [...byId.keys()];
134
+ let top = await read(spec, ids);
135
+ // AWS fails the whole call on one bad id — a snapshot naming an instance
136
+ // that has since been terminated takes every other instance's properties
137
+ // down with it, and the result is indistinguishable from "the account has
138
+ // nothing to say". Retry one at a time so the damage stops at the bad id.
139
+ if (top === null && ids.length > 1) {
140
+ top = [];
141
+ for (const id of ids) top.push(...((await read(spec, [id])) ?? []));
142
+ }
143
+ if (top === null) continue;
144
+ // `describe-instances` buries instances one level down under reservations;
145
+ // the others return the resources directly.
146
+ const rows = spec.nested
147
+ ? top.flatMap((r) => (r[spec.nested as string] ?? []) as Array<Record<string, unknown>>)
148
+ : top;
149
+ for (const row of rows) {
150
+ const id = row[spec.id];
151
+ if (typeof id !== "string") continue;
152
+ for (const name of byId.get(id) ?? []) {
153
+ merged[name] = {
154
+ ...merged[name],
155
+ // Own properties last: a resource's `VpcId` outranks a stack output
156
+ // that happens to share the name.
157
+ attributes: { ...(merged[name].attributes ?? {}), ...row },
158
+ };
159
+ }
160
+ }
161
+ } catch {
162
+ continue;
163
+ }
164
+ }
165
+ return merged;
166
+ }
@@ -16,10 +16,12 @@ export const awsReferenceCatalog: ReferenceCatalog = {
16
16
  { kind: "AWS::EC2::VPC", ids: ["VpcId"] },
17
17
  { kind: "AWS::EC2::Subnet", ids: ["SubnetId"] },
18
18
  { kind: "AWS::EC2::SecurityGroup", ids: ["GroupId"] },
19
+ { kind: "AWS::EC2::NetworkInterface", ids: ["NetworkInterfaceId"] },
19
20
  { kind: "AWS::EC2::Instance", ids: ["InstanceId"] },
20
21
  { kind: "AWS::EC2::InternetGateway", ids: ["InternetGatewayId"] },
21
22
  { kind: "AWS::EC2::NatGateway", ids: ["NatGatewayId"] },
22
23
  { kind: "AWS::EC2::RouteTable", ids: ["RouteTableId"] },
24
+ { kind: "AWS::EC2::LaunchTemplate", ids: ["LaunchTemplateId", "LaunchTemplateName"] },
23
25
  { kind: "AWS::ElasticLoadBalancingV2::LoadBalancer", ids: ["LoadBalancerArn", "DNSName"] },
24
26
  { kind: "AWS::ElasticLoadBalancingV2::TargetGroup", ids: ["TargetGroupArn"] },
25
27
  { kind: "AWS::ECS::Cluster", ids: ["ClusterArn", "ClusterName"] },
@@ -32,7 +34,7 @@ export const awsReferenceCatalog: ReferenceCatalog = {
32
34
  { from: "AWS::EC2::Subnet", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
33
35
  { from: "AWS::EC2::SecurityGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
34
36
  { 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" },
37
+ { from: "AWS::EC2::Instance", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet", viaAttr: "SubnetId" },
36
38
  { from: "AWS::EC2::NatGateway", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
37
39
  { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "AvailabilityZones[].SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
38
40
  { from: "AWS::ElasticLoadBalancingV2::TargetGroup", path: "VpcId", targetKind: "AWS::EC2::VPC", relation: "containment", label: "in VPC" },
@@ -40,9 +42,28 @@ export const awsReferenceCatalog: ReferenceCatalog = {
40
42
  { from: "AWS::RDS::DBInstance", path: "DBSubnetGroup.Subnets[].SubnetIdentifier", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
41
43
 
42
44
  // ── references (→ edges) ──
43
- { from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
45
+ { from: "AWS::EC2::Instance", path: "SecurityGroups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
46
+ { from: "AWS::EC2::Instance", path: "SecurityGroupIds[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
47
+ // Security groups reached indirectly, through a launch template — the hop a
48
+ // flat describe-instances sweep misses, and the reason `effectiveIngress`
49
+ // exists as a fold rather than a passthrough.
50
+ { from: "AWS::EC2::Instance", path: "LaunchTemplate.LaunchTemplateId", targetKind: "AWS::EC2::LaunchTemplate", relation: "reference", label: "from template", viaAttr: "LaunchTemplateId" },
51
+ { from: "AWS::EC2::Instance", path: "LaunchTemplateId", targetKind: "AWS::EC2::LaunchTemplate", relation: "reference", label: "from template", viaAttr: "LaunchTemplateId" },
52
+ { from: "AWS::EC2::LaunchTemplate", path: "LaunchTemplateData.SecurityGroupIds[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
53
+ { from: "AWS::EC2::LaunchTemplate", path: "SecurityGroupIds[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "SecurityGroupIds" },
54
+ // The routing chain `internetFacing` walks. Route and association carry no
55
+ // physical id of their own, so they are only ever the `from` side.
56
+ { from: "AWS::EC2::Route", path: "RouteTableId", targetKind: "AWS::EC2::RouteTable", relation: "reference", label: "in table", viaAttr: "RouteTableId" },
57
+ { from: "AWS::EC2::SubnetRouteTableAssociation", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "reference", label: "associates", viaAttr: "SubnetId" },
58
+ { from: "AWS::EC2::SubnetRouteTableAssociation", path: "RouteTableId", targetKind: "AWS::EC2::RouteTable", relation: "reference", label: "to table", viaAttr: "RouteTableId" },
44
59
  { 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" },
60
+ // An ENI is where a security group is attached and where an instance's
61
+ // networking actually lives, so both edges are what make "unused" and
62
+ // "reachable" answerable from the graph rather than from a provider sweep.
63
+ { from: "AWS::EC2::NetworkInterface", path: "Groups[].GroupId", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg", viaAttr: "Groups" },
64
+ { from: "AWS::EC2::NetworkInterface", path: "Attachment.InstanceId", targetKind: "AWS::EC2::Instance", relation: "reference", label: "attached to", viaAttr: "Attachment" },
65
+ { from: "AWS::EC2::NetworkInterface", path: "SubnetId", targetKind: "AWS::EC2::Subnet", relation: "containment", label: "in subnet" },
66
+ { from: "AWS::EC2::Route", path: "GatewayId", targetKind: "AWS::EC2::InternetGateway", relation: "reference", label: "via", viaAttr: "GatewayId" },
46
67
  { from: "AWS::EC2::Route", path: "NatGatewayId", targetKind: "AWS::EC2::NatGateway", relation: "reference", label: "via" },
47
68
  { from: "AWS::ElasticLoadBalancingV2::LoadBalancer", path: "SecurityGroups[]", targetKind: "AWS::EC2::SecurityGroup", relation: "reference", label: "sg" },
48
69
  { from: "AWS::ElasticLoadBalancingV2::Listener", path: "LoadBalancerArn", targetKind: "AWS::ElasticLoadBalancingV2::LoadBalancer", relation: "reference", label: "on" },