@intentius/chant-lexicon-aws 0.33.0 → 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.
- package/dist/ambient.d.ts +39 -0
- package/dist/ambient.d.ts.map +1 -0
- package/dist/deep-observe.d.ts +5 -0
- package/dist/deep-observe.d.ts.map +1 -1
- package/dist/defaults.d.ts +44 -0
- package/dist/defaults.d.ts.map +1 -0
- package/dist/dependencies.d.ts +60 -0
- package/dist/dependencies.d.ts.map +1 -0
- package/dist/generated/index.d.ts +61 -136
- package/dist/generated/index.d.ts.map +1 -1
- package/dist/integrity.json +4 -4
- package/dist/manifest.json +1 -1
- package/dist/meta.json +577 -2759
- package/dist/plugin.d.ts.map +1 -1
- package/dist/properties.d.ts +48 -0
- package/dist/properties.d.ts.map +1 -0
- package/dist/reference-catalog.d.ts.map +1 -1
- package/dist/types/index.d.ts +1206 -2296
- package/package.json +2 -2
- package/src/ambient.test.ts +71 -0
- package/src/ambient.ts +147 -0
- package/src/deep-observe.ts +9 -0
- package/src/defaults.test.ts +73 -0
- package/src/defaults.ts +97 -0
- package/src/dependencies.test.ts +208 -0
- package/src/dependencies.ts +323 -0
- package/src/generated/index.d.ts +1206 -2296
- package/src/generated/index.ts +62 -137
- package/src/generated/lexicon-aws.json +577 -2759
- package/src/plugin.ts +65 -64
- package/src/properties.test.ts +113 -0
- package/src/properties.ts +166 -0
- package/src/reference-catalog.ts +24 -3
|
@@ -0,0 +1,323 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What an AWS estate depends on but does not declare (#1273).
|
|
3
|
+
*
|
|
4
|
+
* `describeResources` is scoped to the stack. An instance placed in the
|
|
5
|
+
* account's default VPC routes through a route table nobody declared, so that
|
|
6
|
+
* table is never observed, never a node, and no fold can traverse to it. The
|
|
7
|
+
* answer used to be computed inside the lexicon and injected as an attribute —
|
|
8
|
+
* which is a conclusion no snapshot can record enough to recompute.
|
|
9
|
+
*
|
|
10
|
+
* This reports the resources instead, so the one graph fold derives the answer.
|
|
11
|
+
*
|
|
12
|
+
* The closure is bounded: start from the observed instances, follow the
|
|
13
|
+
* reference chain the catalog declares as meaningful — subnet, then route
|
|
14
|
+
* table, then gateway — and stop. Anything not reached from a declared resource
|
|
15
|
+
* along a declared reference is not a dependency, it is just the account.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
import { applyAwsEndpointArgv } from "./components/cloud-executor";
|
|
19
|
+
import type { DependencyObservation, ResourceMetadata, IREdge } from "@intentius/chant/lexicon";
|
|
20
|
+
|
|
21
|
+
/** Cloud-shaped ids are stable across a redeploy and unique per account. */
|
|
22
|
+
const nodeId = (physicalId: string): string => physicalId;
|
|
23
|
+
|
|
24
|
+
/** One ingress permission as `describe-security-groups` returns it. */
|
|
25
|
+
interface RawIpPermission {
|
|
26
|
+
IpProtocol?: string;
|
|
27
|
+
FromPort?: number;
|
|
28
|
+
ToPort?: number;
|
|
29
|
+
IpRanges?: Array<{ CidrIp?: string }>;
|
|
30
|
+
Ipv6Ranges?: Array<{ CidrIpv6?: string }>;
|
|
31
|
+
UserIdGroupPairs?: Array<{ GroupId?: string }>;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Flatten `IpPermissions` into the CloudFormation-shaped rules the fold reads.
|
|
36
|
+
*
|
|
37
|
+
* The two AWS surfaces disagree about the same concept: a template's
|
|
38
|
+
* `SecurityGroupIngress` carries `CidrIp` flat on the rule, while
|
|
39
|
+
* `describe-security-groups` nests sources under `IpRanges[]`, `Ipv6Ranges[]`
|
|
40
|
+
* and `UserIdGroupPairs[]`, and one permission can hold several. Handing the
|
|
41
|
+
* describe shape to the fold unchanged yields a rule with no recognisable
|
|
42
|
+
* source, which `normalizeIngress` renders as `?` — an ingress rule that
|
|
43
|
+
* matches no CIDR query and quietly narrows the answer.
|
|
44
|
+
*
|
|
45
|
+
* One rule per source, because that is what the flat shape means.
|
|
46
|
+
*/
|
|
47
|
+
export function toIngressRules(permissions: RawIpPermission[]): Array<Record<string, unknown>> {
|
|
48
|
+
const rules: Array<Record<string, unknown>> = [];
|
|
49
|
+
for (const permission of permissions) {
|
|
50
|
+
const base = {
|
|
51
|
+
IpProtocol: permission.IpProtocol ?? "-1",
|
|
52
|
+
...(permission.FromPort != null ? { FromPort: permission.FromPort } : {}),
|
|
53
|
+
...(permission.ToPort != null ? { ToPort: permission.ToPort } : {}),
|
|
54
|
+
};
|
|
55
|
+
for (const range of permission.IpRanges ?? []) {
|
|
56
|
+
if (range.CidrIp) rules.push({ ...base, CidrIp: range.CidrIp });
|
|
57
|
+
}
|
|
58
|
+
for (const range of permission.Ipv6Ranges ?? []) {
|
|
59
|
+
if (range.CidrIpv6) rules.push({ ...base, CidrIpv6: range.CidrIpv6 });
|
|
60
|
+
}
|
|
61
|
+
for (const pair of permission.UserIdGroupPairs ?? []) {
|
|
62
|
+
if (pair.GroupId) rules.push({ ...base, SourceSecurityGroupId: pair.GroupId });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
return rules;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
interface RawRouteTable {
|
|
69
|
+
RouteTableId?: string;
|
|
70
|
+
VpcId?: string;
|
|
71
|
+
Routes?: Array<{ GatewayId?: string; DestinationCidrBlock?: string }>;
|
|
72
|
+
Associations?: Array<{ SubnetId?: string; Main?: boolean }>;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Read the routing an observed estate depends on.
|
|
77
|
+
*
|
|
78
|
+
* Only the tables that actually serve an observed instance are reported: one
|
|
79
|
+
* reached by an explicit subnet association, or the VPC's main table standing in
|
|
80
|
+
* for a subnet with none. A route table serving nothing chant deployed is not a
|
|
81
|
+
* dependency of this estate.
|
|
82
|
+
*/
|
|
83
|
+
export async function observeAwsDependencies(options: {
|
|
84
|
+
observed: Record<string, ResourceMetadata>;
|
|
85
|
+
region?: string;
|
|
86
|
+
}): Promise<DependencyObservation> {
|
|
87
|
+
const { getRuntime } = await import("@intentius/chant/runtime-adapter");
|
|
88
|
+
const rt = getRuntime();
|
|
89
|
+
const regionArgs = options.region ? ["--region", options.region] : [];
|
|
90
|
+
const run = (args: string[]) =>
|
|
91
|
+
rt.spawn(applyAwsEndpointArgv(["aws", ...args, ...regionArgs, "--output", "json"], process.env.AWS_ENDPOINT_URL));
|
|
92
|
+
|
|
93
|
+
const resources: Record<string, ResourceMetadata> = {};
|
|
94
|
+
const edges: IREdge[] = [];
|
|
95
|
+
|
|
96
|
+
// Roots: the instances this estate manages, and where each one sits. Placement
|
|
97
|
+
// comes from the live API rather than the template, because the declared side
|
|
98
|
+
// may only carry a parameter reference to a subnet it never modelled.
|
|
99
|
+
const instances = Object.entries(options.observed).filter(
|
|
100
|
+
([, meta]) => meta.type === "AWS::EC2::Instance" && meta.physicalId,
|
|
101
|
+
);
|
|
102
|
+
if (instances.length === 0) return { resources: {}, edges: [] };
|
|
103
|
+
|
|
104
|
+
try {
|
|
105
|
+
const described = await run([
|
|
106
|
+
"ec2",
|
|
107
|
+
"describe-instances",
|
|
108
|
+
"--instance-ids",
|
|
109
|
+
...instances.map(([, meta]) => meta.physicalId as string),
|
|
110
|
+
]);
|
|
111
|
+
if (described.exitCode !== 0) return { resources: {}, edges: [] };
|
|
112
|
+
|
|
113
|
+
const placement = new Map<
|
|
114
|
+
string,
|
|
115
|
+
{ subnetId?: string; vpcId?: string; securityGroupIds: string[]; launchTemplateId?: string }
|
|
116
|
+
>();
|
|
117
|
+
for (const reservation of (JSON.parse(described.stdout).Reservations ?? []) as Array<{
|
|
118
|
+
Instances?: Array<{
|
|
119
|
+
InstanceId: string;
|
|
120
|
+
SubnetId?: string;
|
|
121
|
+
VpcId?: string;
|
|
122
|
+
SecurityGroups?: Array<{ GroupId?: string }>;
|
|
123
|
+
LaunchTemplate?: { LaunchTemplateId?: string };
|
|
124
|
+
}>;
|
|
125
|
+
}>) {
|
|
126
|
+
for (const instance of reservation.Instances ?? []) {
|
|
127
|
+
placement.set(instance.InstanceId, {
|
|
128
|
+
subnetId: instance.SubnetId,
|
|
129
|
+
vpcId: instance.VpcId,
|
|
130
|
+
securityGroupIds: (instance.SecurityGroups ?? [])
|
|
131
|
+
.map((g) => g.GroupId)
|
|
132
|
+
.filter((id): id is string => typeof id === "string"),
|
|
133
|
+
...(instance.LaunchTemplate?.LaunchTemplateId
|
|
134
|
+
? { launchTemplateId: instance.LaunchTemplate.LaunchTemplateId }
|
|
135
|
+
: {}),
|
|
136
|
+
});
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
const tablesResult = await run(["ec2", "describe-route-tables"]);
|
|
141
|
+
if (tablesResult.exitCode !== 0) return { resources: {}, edges: [] };
|
|
142
|
+
const tables = (JSON.parse(tablesResult.stdout).RouteTables ?? []) as RawRouteTable[];
|
|
143
|
+
|
|
144
|
+
// Which table serves which subnet, and which is a VPC's main table — the
|
|
145
|
+
// two ways an instance's subnet resolves to routing.
|
|
146
|
+
const bySubnet = new Map<string, RawRouteTable>();
|
|
147
|
+
const mainByVpc = new Map<string, RawRouteTable>();
|
|
148
|
+
for (const table of tables) {
|
|
149
|
+
for (const association of table.Associations ?? []) {
|
|
150
|
+
if (association.SubnetId) bySubnet.set(association.SubnetId, table);
|
|
151
|
+
if (association.Main && table.VpcId) mainByVpc.set(table.VpcId, table);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
// Security groups and launch templates the estate is guarded by (#1276).
|
|
156
|
+
// `effectiveIngress` resolved these from the declared graph alone, which is
|
|
157
|
+
// right until an instance sits in a group it did not declare — a shared
|
|
158
|
+
// group, one attached through someone else's launch template. Then the
|
|
159
|
+
// group is not a node, its rules are never read, and the instance reads as
|
|
160
|
+
// less exposed than it is. Under-reporting is the dangerous direction for a
|
|
161
|
+
// query whose whole purpose is finding what the internet can reach.
|
|
162
|
+
const declaredGroupIds = new Set(
|
|
163
|
+
Object.values(options.observed)
|
|
164
|
+
.filter((m) => m.type === "AWS::EC2::SecurityGroup" && m.physicalId)
|
|
165
|
+
.map((m) => m.physicalId as string),
|
|
166
|
+
);
|
|
167
|
+
const declaredTemplateIds = new Set(
|
|
168
|
+
Object.values(options.observed)
|
|
169
|
+
.filter((m) => m.type === "AWS::EC2::LaunchTemplate" && m.physicalId)
|
|
170
|
+
.map((m) => m.physicalId as string),
|
|
171
|
+
);
|
|
172
|
+
const groupsById = new Map<string, Record<string, unknown>>();
|
|
173
|
+
const wantedGroups = new Set<string>();
|
|
174
|
+
const templatesById = new Map<string, string[]>();
|
|
175
|
+
|
|
176
|
+
for (const [, meta] of instances) {
|
|
177
|
+
const where = placement.get(meta.physicalId as string);
|
|
178
|
+
if (!where) continue;
|
|
179
|
+
for (const id of where.securityGroupIds) if (!declaredGroupIds.has(id)) wantedGroups.add(id);
|
|
180
|
+
if (where.launchTemplateId && !declaredTemplateIds.has(where.launchTemplateId)) {
|
|
181
|
+
const versions = await run([
|
|
182
|
+
"ec2",
|
|
183
|
+
"describe-launch-template-versions",
|
|
184
|
+
"--launch-template-id",
|
|
185
|
+
where.launchTemplateId,
|
|
186
|
+
"--versions",
|
|
187
|
+
"$Latest",
|
|
188
|
+
]);
|
|
189
|
+
if (versions.exitCode === 0) {
|
|
190
|
+
const templateGroups = (
|
|
191
|
+
(JSON.parse(versions.stdout).LaunchTemplateVersions ?? []) as Array<{
|
|
192
|
+
LaunchTemplateData?: { SecurityGroupIds?: string[] };
|
|
193
|
+
}>
|
|
194
|
+
).flatMap((v) => v.LaunchTemplateData?.SecurityGroupIds ?? []);
|
|
195
|
+
templatesById.set(where.launchTemplateId, templateGroups);
|
|
196
|
+
for (const id of templateGroups) if (!declaredGroupIds.has(id)) wantedGroups.add(id);
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
if (wantedGroups.size > 0) {
|
|
202
|
+
const described = await run(["ec2", "describe-security-groups", "--group-ids", ...wantedGroups]);
|
|
203
|
+
if (described.exitCode === 0) {
|
|
204
|
+
for (const group of (JSON.parse(described.stdout).SecurityGroups ?? []) as Array<{
|
|
205
|
+
GroupId?: string;
|
|
206
|
+
VpcId?: string;
|
|
207
|
+
IpPermissions?: RawIpPermission[];
|
|
208
|
+
}>) {
|
|
209
|
+
if (!group.GroupId) continue;
|
|
210
|
+
groupsById.set(group.GroupId, {
|
|
211
|
+
GroupId: group.GroupId,
|
|
212
|
+
VpcId: group.VpcId,
|
|
213
|
+
SecurityGroupIngress: toIngressRules(group.IpPermissions ?? []),
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
const record = (
|
|
220
|
+
logicalId: string,
|
|
221
|
+
id: string,
|
|
222
|
+
type: string,
|
|
223
|
+
physicalId: string | undefined,
|
|
224
|
+
attrs: Record<string, unknown>,
|
|
225
|
+
) => {
|
|
226
|
+
const existing = resources[id];
|
|
227
|
+
resources[id] = {
|
|
228
|
+
type,
|
|
229
|
+
status: "OBSERVED",
|
|
230
|
+
...(physicalId ? { physicalId } : {}),
|
|
231
|
+
attributes: attrs,
|
|
232
|
+
ownership: "foreign",
|
|
233
|
+
referencedBy: [...new Set([...(existing?.referencedBy ?? []), logicalId])],
|
|
234
|
+
};
|
|
235
|
+
};
|
|
236
|
+
|
|
237
|
+
for (const [logicalId, meta] of instances) {
|
|
238
|
+
const where = placement.get(meta.physicalId as string);
|
|
239
|
+
if (!where) continue;
|
|
240
|
+
|
|
241
|
+
// Guarding first, and unconditionally: an instance with no internet route
|
|
242
|
+
// still has security groups, and `effectiveIngress` is asked about
|
|
243
|
+
// instances that are not internet-facing too.
|
|
244
|
+
for (const groupId of where.securityGroupIds) {
|
|
245
|
+
const attrs = groupsById.get(groupId);
|
|
246
|
+
if (!attrs) continue; // declared, or unreadable — either way not a dependency
|
|
247
|
+
record(logicalId, nodeId(groupId), "AWS::EC2::SecurityGroup", groupId, attrs);
|
|
248
|
+
edges.push({ from: logicalId, to: nodeId(groupId), kind: "ref", viaAttr: "SecurityGroupIds" });
|
|
249
|
+
}
|
|
250
|
+
const templateGroups = where.launchTemplateId ? templatesById.get(where.launchTemplateId) : undefined;
|
|
251
|
+
if (where.launchTemplateId && templateGroups) {
|
|
252
|
+
const templateNode = nodeId(where.launchTemplateId);
|
|
253
|
+
record(logicalId, templateNode, "AWS::EC2::LaunchTemplate", where.launchTemplateId, {
|
|
254
|
+
LaunchTemplateId: where.launchTemplateId,
|
|
255
|
+
LaunchTemplateData: { SecurityGroupIds: templateGroups },
|
|
256
|
+
});
|
|
257
|
+
edges.push({ from: logicalId, to: templateNode, kind: "ref", viaAttr: "LaunchTemplateId" });
|
|
258
|
+
for (const groupId of templateGroups) {
|
|
259
|
+
const attrs = groupsById.get(groupId);
|
|
260
|
+
if (!attrs) continue;
|
|
261
|
+
record(logicalId, nodeId(groupId), "AWS::EC2::SecurityGroup", groupId, attrs);
|
|
262
|
+
edges.push({ from: templateNode, to: nodeId(groupId), kind: "ref", viaAttr: "SecurityGroupIds" });
|
|
263
|
+
}
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
// Explicit subnet association first, else the VPC's main table — the two
|
|
267
|
+
// ways an instance's placement resolves to routing.
|
|
268
|
+
const table: RawRouteTable | undefined =
|
|
269
|
+
(where.subnetId ? bySubnet.get(where.subnetId) : undefined) ??
|
|
270
|
+
(where.vpcId ? mainByVpc.get(where.vpcId) : undefined);
|
|
271
|
+
if (!table?.RouteTableId) continue;
|
|
272
|
+
|
|
273
|
+
// Only routing that actually reaches the internet is worth reporting: an
|
|
274
|
+
// internal table adds a node and an edge and answers nothing.
|
|
275
|
+
const igwRoute = (table.Routes ?? []).find(
|
|
276
|
+
(route) =>
|
|
277
|
+
typeof route.GatewayId === "string" &&
|
|
278
|
+
route.GatewayId.startsWith("igw-") &&
|
|
279
|
+
(route.DestinationCidrBlock == null || route.DestinationCidrBlock === "0.0.0.0/0"),
|
|
280
|
+
);
|
|
281
|
+
if (!igwRoute?.GatewayId) continue;
|
|
282
|
+
|
|
283
|
+
const subnetNode = where.subnetId ? nodeId(where.subnetId) : undefined;
|
|
284
|
+
const tableNode = nodeId(table.RouteTableId);
|
|
285
|
+
const gatewayNode = nodeId(igwRoute.GatewayId);
|
|
286
|
+
const associationNode = `${tableNode}::assoc::${subnetNode ?? table.VpcId ?? "main"}`;
|
|
287
|
+
const routeNode = `${tableNode}::route::${gatewayNode}`;
|
|
288
|
+
|
|
289
|
+
if (subnetNode) record(logicalId, subnetNode, "AWS::EC2::Subnet", where.subnetId, { VpcId: where.vpcId });
|
|
290
|
+
record(logicalId, tableNode, "AWS::EC2::RouteTable", table.RouteTableId, { VpcId: table.VpcId });
|
|
291
|
+
record(logicalId, gatewayNode, "AWS::EC2::InternetGateway", igwRoute.GatewayId, {
|
|
292
|
+
InternetGatewayId: igwRoute.GatewayId,
|
|
293
|
+
});
|
|
294
|
+
record(logicalId, associationNode, "AWS::EC2::SubnetRouteTableAssociation", undefined, {
|
|
295
|
+
SubnetId: where.subnetId,
|
|
296
|
+
RouteTableId: table.RouteTableId,
|
|
297
|
+
});
|
|
298
|
+
record(logicalId, routeNode, "AWS::EC2::Route", undefined, {
|
|
299
|
+
RouteTableId: table.RouteTableId,
|
|
300
|
+
GatewayId: igwRoute.GatewayId,
|
|
301
|
+
DestinationCidrBlock: igwRoute.DestinationCidrBlock ?? "0.0.0.0/0",
|
|
302
|
+
});
|
|
303
|
+
|
|
304
|
+
// The chain enrichEffectiveTopology walks, reported explicitly rather
|
|
305
|
+
// than left to be reconstructed: an association and a route have no
|
|
306
|
+
// physical id of their own, so no identity index can resolve them.
|
|
307
|
+
if (subnetNode) {
|
|
308
|
+
edges.push({ from: logicalId, to: subnetNode, kind: "ref", viaAttr: "SubnetId" });
|
|
309
|
+
edges.push({ from: associationNode, to: subnetNode, kind: "ref", viaAttr: "SubnetId" });
|
|
310
|
+
}
|
|
311
|
+
edges.push({ from: associationNode, to: tableNode, kind: "ref", viaAttr: "RouteTableId" });
|
|
312
|
+
edges.push({ from: routeNode, to: tableNode, kind: "ref", viaAttr: "RouteTableId" });
|
|
313
|
+
edges.push({ from: routeNode, to: gatewayNode, kind: "ref", viaAttr: "GatewayId" });
|
|
314
|
+
}
|
|
315
|
+
} catch {
|
|
316
|
+
// Best-effort: the managed observation is complete and useful on its own,
|
|
317
|
+
// and failing it because an ambient dependency could not be read would
|
|
318
|
+
// trade a whole answer for a partial one.
|
|
319
|
+
return { resources: {}, edges: [] };
|
|
320
|
+
}
|
|
321
|
+
|
|
322
|
+
return { resources, edges };
|
|
323
|
+
}
|