1import type * as EC2 from "@distilled.cloud/aws/ec2";
2import * as ec2 from "@distilled.cloud/aws/ec2";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7import { isResolved, somePropsAreDifferent } from "../../Diff.ts";
8import * as Provider from "../../Provider.ts";
9import { Resource } from "../../Resource.ts";
10import type { Providers } from "../Providers.ts";
11import { getDefaultVpcScope } from "./defaultVpcScope.ts";
12import type { RouteTableId } from "./RouteTable.ts";
13
14export interface RouteProps {
15 /**
16 * The ID of the route table where the route will be added.
17 * Required.
18 */
19 routeTableId: RouteTableId;
20
21 /**
22 * The IPv4 CIDR block used for the destination match.
23 * Either destinationCidrBlock, destinationIpv6CidrBlock, or destinationPrefixListId is required.
24 * @example "0.0.0.0/0"
25 */
26 destinationCidrBlock?: string;
27
28 /**
29 * The IPv6 CIDR block used for the destination match.
30 * Either destinationCidrBlock, destinationIpv6CidrBlock, or destinationPrefixListId is required.
31 * @example "::/0"
32 */
33 destinationIpv6CidrBlock?: string;
34
35 /**
36 * The ID of a prefix list used for the destination match.
37 * Either destinationCidrBlock, destinationIpv6CidrBlock, or destinationPrefixListId is required.
38 */
39 destinationPrefixListId?: string;
40
41 // ---- Target properties (exactly one required) ----
42
43 /**
44 * The ID of an internet gateway or virtual private gateway.
45 */
46 gatewayId?: string;
47
48 /**
49 * The ID of a NAT gateway.
50 */
51 natGatewayId?: string;
52
53 /**
54 * The ID of a NAT instance in your VPC.
55 * This operation fails unless exactly one network interface is attached.
56 */
57 instanceId?: string;
58
59 /**
60 * The ID of a network interface.
61 */
62 networkInterfaceId?: string;
63
64 /**
65 * The ID of a VPC peering connection.
66 */
67 vpcPeeringConnectionId?: string;
68
69 /**
70 * The ID of a transit gateway.
71 */
72 transitGatewayId?: string;
73
74 /**
75 * The ID of a local gateway.
76 */
77 localGatewayId?: string;
78
79 /**
80 * The ID of a carrier gateway.
81 * Use for Wavelength Zones only.
82 */
83 carrierGatewayId?: string;
84
85 /**
86 * The ID of an egress-only internet gateway.
87 * IPv6 traffic only.
88 */
89 egressOnlyInternetGatewayId?: string;
90
91 /**
92 * The Amazon Resource Name (ARN) of the core network.
93 */
94 coreNetworkArn?: string;
95
96 /**
97 * The ID of a VPC endpoint for Gateway Load Balancer.
98 */
99 vpcEndpointId?: string;
100}
101
102export interface Route extends Resource<
103 "AWS.EC2.Route",
104 RouteProps,
105 {
106 /**
107 * The ID of the route table that contains this route.
108 */
109 routeTableId: RouteTableId;
110 /**
111 * The IPv4 CIDR block used for the destination match.
112 */
113 destinationCidrBlock?: string | undefined;
114 /**
115 * The IPv6 CIDR block used for the destination match.
116 */
117 destinationIpv6CidrBlock?: string | undefined;
118 /**
119 * The ID of the prefix list used for the destination match.
120 */
121 destinationPrefixListId?: string | undefined;
122 /**
123 * Describes how the route was created (e.g. `CreateRoute`).
124 */
125 origin: EC2.RouteOrigin;
126 /**
127 * The state of the route (e.g. `active` or `blackhole`).
128 */
129 state: EC2.RouteState;
130 /**
131 * The ID of the internet gateway or virtual private gateway the route targets.
132 */
133 gatewayId?: string;
134 /**
135 * The ID of the NAT gateway the route targets.
136 */
137 natGatewayId?: string;
138 /**
139 * The ID of the NAT instance the route targets.
140 */
141 instanceId?: string;
142 /**
143 * The ID of the network interface the route targets.
144 */
145 networkInterfaceId?: string;
146 /**
147 * The ID of the VPC peering connection the route targets.
148 */
149 vpcPeeringConnectionId?: string;
150 /**
151 * The ID of the transit gateway the route targets.
152 */
153 transitGatewayId?: string;
154 /**
155 * The ID of the local gateway the route targets.
156 */
157 localGatewayId?: string;
158 /**
159 * The ID of the carrier gateway the route targets.
160 */
161 carrierGatewayId?: string;
162 /**
163 * The ID of the egress-only internet gateway the route targets (IPv6 only).
164 */
165 egressOnlyInternetGatewayId?: string;
166 /**
167 * The Amazon Resource Name (ARN) of the core network the route targets.
168 */
169 coreNetworkArn?: string;
170 },
171 never,
172 Providers
173> {}
174/**
175 * A single route entry inside a {@link RouteTable}. A route maps a destination
176 * to exactly one target, telling the VPC where to send packets whose address
177 * falls within the destination range.
178 *
179 * Every route has two halves:
180 * - **Destination** — exactly one of `destinationCidrBlock` (IPv4),
181 * `destinationIpv6CidrBlock` (IPv6), or `destinationPrefixListId` (a managed
182 * prefix list, e.g. for an AWS service).
183 * - **Target** — exactly one of `gatewayId` (internet/virtual private
184 * gateway), `natGatewayId`, `instanceId` (NAT instance), `networkInterfaceId`,
185 * `vpcPeeringConnectionId`, `transitGatewayId`, `localGatewayId` (Outposts),
186 * `carrierGatewayId` (Wavelength), `egressOnlyInternetGatewayId` (IPv6),
187 * `coreNetworkArn` (Cloud WAN), or `vpcEndpointId` (Gateway Load Balancer).
188 *
189 * Changing the `routeTableId` or the destination replaces the route, whereas
190 * changing only the target is applied in place via `ReplaceRoute`.
191 *
192 * ### IPv4 Routing
193 * Use an IPv4 `destinationCidrBlock` — most commonly `0.0.0.0/0` for the
194 * default route, or a narrower CIDR to route specific traffic.
195 *
196 * **Example:** Default Route to an Internet Gateway
197 * ```typescript
198 * const internetRoute = yield* AWS.EC2.Route("InternetRoute", {
199 * routeTableId: publicRouteTable.routeTableId,
200 * destinationCidrBlock: "0.0.0.0/0",
201 * gatewayId: internetGateway.internetGatewayId,
202 * });
203 * ```
204 * Sends all outbound IPv4 traffic to the internet gateway, which is what makes
205 * a subnet "public". Attach this route table to any subnet that needs inbound
206 * and outbound internet connectivity.
207 *
208 * **Example:** Default Route to a NAT Gateway
209 * ```typescript
210 * const natRoute = yield* AWS.EC2.Route("NatRoute", {
211 * routeTableId: privateRouteTable.routeTableId,
212 * destinationCidrBlock: "0.0.0.0/0",
213 * natGatewayId: natGateway.natGatewayId,
214 * });
215 * ```
216 * Lets private subnets reach the internet for outbound traffic (package
217 * updates, API calls) while blocking unsolicited inbound connections. The NAT
218 * gateway itself lives in a public subnet.
219 *
220 * **Example:** Route to a VPC Peering Connection
221 * ```typescript
222 * const peeringRoute = yield* AWS.EC2.Route("PeeringRoute", {
223 * routeTableId: routeTable.routeTableId,
224 * destinationCidrBlock: "10.1.0.0/16",
225 * vpcPeeringConnectionId: "pcx-0abc1234",
226 * });
227 * ```
228 * Routes traffic destined for the peer VPC's CIDR across a VPC peering
229 * connection. Use a narrow destination matching the remote VPC rather than
230 * `0.0.0.0/0` so only cross-VPC traffic is affected.
231 *
232 * **Example:** Route to a Transit Gateway
233 * ```typescript
234 * const transitRoute = yield* AWS.EC2.Route("TransitRoute", {
235 * routeTableId: routeTable.routeTableId,
236 * destinationCidrBlock: "172.16.0.0/12",
237 * transitGatewayId: "tgw-0abc1234",
238 * });
239 * ```
240 * Hands traffic to a transit gateway, the hub used to connect many VPCs and
241 * on-premises networks. The destination CIDR should cover the address space
242 * reachable through the transit gateway.
243 *
244 * **Example:** Route to a Network Interface or NAT Instance
245 * ```typescript
246 * const applianceRoute = yield* AWS.EC2.Route("ApplianceRoute", {
247 * routeTableId: routeTable.routeTableId,
248 * destinationCidrBlock: "0.0.0.0/0",
249 * networkInterfaceId: "eni-0abc1234",
250 * });
251 * ```
252 * Forwards traffic to a specific elastic network interface — for example a
253 * firewall or NAT instance appliance. Use `instanceId` instead when targeting
254 * a NAT instance that has exactly one network interface attached.
255 *
256 * ### IPv6 Routing
257 * IPv6 routes use `destinationIpv6CidrBlock` (e.g. `::/0` for the IPv6 default
258 * route). For outbound-only IPv6 access from private subnets, target an
259 * {@link EgressOnlyInternetGateway}.
260 *
261 * **Example:** IPv6 Egress Route to an Egress-Only Internet Gateway
262 * ```typescript
263 * const ipv6EgressRoute = yield* AWS.EC2.Route("Ipv6EgressRoute", {
264 * routeTableId: privateRouteTable.routeTableId,
265 * destinationIpv6CidrBlock: "::/0",
266 * egressOnlyInternetGatewayId: egressOnlyIgw.egressOnlyInternetGatewayId,
267 * });
268 * ```
269 * Gives IPv6-addressed instances outbound internet access while blocking
270 * inbound connections — the IPv6 equivalent of routing IPv4 through a NAT
271 * gateway.
272 *
273 * **Example:** IPv6 Internet Route to an Internet Gateway
274 * ```typescript
275 * const ipv6InternetRoute = yield* AWS.EC2.Route("Ipv6InternetRoute", {
276 * routeTableId: publicRouteTable.routeTableId,
277 * destinationIpv6CidrBlock: "::/0",
278 * gatewayId: internetGateway.internetGatewayId,
279 * });
280 * ```
281 * Provides full bidirectional IPv6 connectivity for a public subnet, since an
282 * internet gateway (unlike an egress-only gateway) allows inbound IPv6 traffic.
283 *
284 * ### Routing to AWS Services via Prefix Lists
285 * Instead of a raw CIDR, a route can match a managed prefix list — useful for
286 * AWS service ranges (e.g. an S3 gateway endpoint) where the underlying CIDRs
287 * change over time.
288 *
289 * **Example:** Prefix List Route to a Gateway VPC Endpoint
290 * ```typescript
291 * const prefixListRoute = yield* AWS.EC2.Route("S3PrefixRoute", {
292 * routeTableId: privateRouteTable.routeTableId,
293 * destinationPrefixListId: "pl-0abc1234",
294 * vpcEndpointId: "vpce-0abc1234",
295 * });
296 * ```
297 * Routes traffic for every CIDR in the prefix list to a Gateway Load Balancer
298 * VPC endpoint. AWS keeps the prefix list current, so you don't have to update
299 * the route when the service's address ranges change.
300 *
301 * @resource
302 */
303export const Route = Resource<Route>("AWS.EC2.Route");
304
305export const RouteProvider = () =>
306 Provider.effect(
307 Route,
308 Effect.gen(function* () {
309 return {
310 diff: Effect.fn(function* ({ news, olds }) {
311 if (!isResolved(news)) return;
312 // Route table change requires replacement
313 if (olds.routeTableId !== news.routeTableId) {
314 return { action: "replace" };
315 }
316
317 // Destination change requires replacement
318 if (
319 somePropsAreDifferent(olds, news, [
320 "destinationCidrBlock",
321 "destinationIpv6CidrBlock",
322 "destinationPrefixListId",
323 ])
324 ) {
325 return { action: "replace" };
326 }
327
328 // Target change can be done via ReplaceRoute (update)
329 }),
330
331 reconcile: Effect.fn(function* ({ news, session }) {
332 const dest =
333 news.destinationCidrBlock ||
334 news.destinationIpv6CidrBlock ||
335 news.destinationPrefixListId ||
336 "unknown";
337
338 const targetParams = {
339 RouteTableId: news.routeTableId,
340 DestinationCidrBlock: news.destinationCidrBlock,
341 DestinationIpv6CidrBlock: news.destinationIpv6CidrBlock,
342 DestinationPrefixListId: news.destinationPrefixListId,
343 GatewayId: news.gatewayId,
344 NatGatewayId: news.natGatewayId,
345 InstanceId: news.instanceId,
346 NetworkInterfaceId: news.networkInterfaceId,
347 VpcPeeringConnectionId: news.vpcPeeringConnectionId,
348 TransitGatewayId: news.transitGatewayId,
349 LocalGatewayId: news.localGatewayId,
350 CarrierGatewayId: news.carrierGatewayId,
351 EgressOnlyInternetGatewayId: news.egressOnlyInternetGatewayId,
352 CoreNetworkArn: news.coreNetworkArn,
353 DryRun: false,
354 } as const;
355
356 // Observe — Routes are identified by (routeTableId, destination).
357 // Look the route up in the route table to decide between create
358 // (missing) and replaceRoute (target drift).
359 const observed = yield* describeRoute(news.routeTableId, news);
360
361 // Ensure / Sync — CreateRoute when missing, ReplaceRoute swaps the
362 // target on an existing route in-place.
363 if (observed === undefined) {
364 yield* ec2
365 .createRoute({
366 ...targetParams,
367 VpcEndpointId: news.vpcEndpointId,
368 })
369 .pipe(
370 Effect.retry({
371 while: (e) => e._tag === "InvalidRouteTableID.NotFound",
372 schedule: Schedule.exponential(100),
373 }),
374 );
375 yield* session.note(`Route created: ${dest}`);
376 } else {
377 yield* ec2.replaceRoute(targetParams).pipe(
378 Effect.tapError(Effect.log),
379 Effect.retry({
380 while: (e) => e._tag === "InvalidRouteTableID.NotFound",
381 schedule: Schedule.exponential(100),
382 }),
383 );
384 yield* session.note(`Route target updated: ${dest}`);
385 }
386
387 // Re-read final state.
388 const route = yield* describeRoute(news.routeTableId, news);
389 return {
390 routeTableId: news.routeTableId,
391 destinationCidrBlock: news.destinationCidrBlock,
392 destinationIpv6CidrBlock: news.destinationIpv6CidrBlock,
393 destinationPrefixListId: news.destinationPrefixListId,
394 origin: route?.Origin ?? "CreateRoute",
395 state: route?.State ?? "active",
396 gatewayId: route?.GatewayId,
397 natGatewayId: route?.NatGatewayId,
398 instanceId: route?.InstanceId,
399 networkInterfaceId: route?.NetworkInterfaceId,
400 vpcPeeringConnectionId: route?.VpcPeeringConnectionId,
401 transitGatewayId: route?.TransitGatewayId,
402 localGatewayId: route?.LocalGatewayId,
403 carrierGatewayId: route?.CarrierGatewayId,
404 egressOnlyInternetGatewayId: route?.EgressOnlyInternetGatewayId,
405 coreNetworkArn: route?.CoreNetworkArn,
406 };
407 }),
408
409 // Routes are entries embedded inside RouteTables. Enumerate by
410 // flattening describeRouteTables -> RouteTables[].Routes[], pairing each
411 // route with its parent routeTableId. Skip the implicit local route
412 // (GatewayId === "local", Origin "CreateRouteTable") that AWS creates
413 // automatically and that this provider does not manage.
414 list: () =>
415 Effect.gen(function* () {
416 // Routes in the default VPC's main route table (the 0.0.0.0/0 IGW
417 // route AWS provisions) are default-VPC furniture; never
418 // census/nuke them. Routes in user-created route tables inside the
419 // default VPC are still listed.
420 const defaultVpc = yield* getDefaultVpcScope;
421 return yield* ec2.describeRouteTables.pages({}).pipe(
422 Stream.runCollect,
423 Effect.map((chunk) =>
424 Array.from(chunk).flatMap((page) =>
425 (page.RouteTables ?? []).flatMap((rt) => {
426 const routeTableId = rt.RouteTableId;
427 if (routeTableId === undefined) return [];
428 if (
429 defaultVpc.vpcId !== undefined &&
430 rt.VpcId === defaultVpc.vpcId &&
431 rt.Associations?.some((a) => a.Main)
432 ) {
433 return [];
434 }
435 return (rt.Routes ?? [])
436 .filter(
437 (r) =>
438 r.GatewayId !== "local" &&
439 // VPC Lattice injects service-managed routes while
440 // a VPC association is active. They cannot be
441 // deleted through EC2 and drain with the association.
442 r.GatewayId !== "VpcLattice",
443 )
444 .map((r) => ({
445 routeTableId: routeTableId as RouteTableId,
446 destinationCidrBlock: r.DestinationCidrBlock,
447 destinationIpv6CidrBlock: r.DestinationIpv6CidrBlock,
448 destinationPrefixListId: r.DestinationPrefixListId,
449 origin: r.Origin ?? "CreateRoute",
450 state: r.State ?? "active",
451 gatewayId: r.GatewayId,
452 natGatewayId: r.NatGatewayId,
453 instanceId: r.InstanceId,
454 networkInterfaceId: r.NetworkInterfaceId,
455 vpcPeeringConnectionId: r.VpcPeeringConnectionId,
456 transitGatewayId: r.TransitGatewayId,
457 localGatewayId: r.LocalGatewayId,
458 carrierGatewayId: r.CarrierGatewayId,
459 egressOnlyInternetGatewayId:
460 r.EgressOnlyInternetGatewayId,
461 coreNetworkArn: r.CoreNetworkArn,
462 }));
463 }),
464 ),
465 ),
466 );
467 }),
468
469 delete: Effect.fn(function* ({ output, session }) {
470 const dest =
471 output.destinationCidrBlock ||
472 output.destinationIpv6CidrBlock ||
473 output.destinationPrefixListId ||
474 "unknown";
475
476 yield* session.note(`Deleting route: ${dest}`);
477
478 // Delete the route
479 yield* ec2
480 .deleteRoute({
481 RouteTableId: output.routeTableId,
482 DestinationCidrBlock: output.destinationCidrBlock,
483 DestinationIpv6CidrBlock: output.destinationIpv6CidrBlock,
484 DestinationPrefixListId: output.destinationPrefixListId,
485 DryRun: false,
486 })
487 .pipe(
488 Effect.tapError(Effect.logDebug),
489 Effect.catchTag("InvalidRoute.NotFound", () => Effect.void),
490 Effect.catchTag(
491 "InvalidRouteTableID.NotFound",
492 () => Effect.void,
493 ),
494 );
495
496 yield* session.note(`Route ${dest} deleted successfully`);
497 }),
498 };
499 }),
500 );
501
502/**
503 * Find a specific route in a route table
504 */
505const describeRoute = (routeTableId: string, props: RouteProps) =>
506 Effect.gen(function* () {
507 const result = yield* ec2
508 .describeRouteTables({ RouteTableIds: [routeTableId] })
509 .pipe(
510 Effect.catchTag("InvalidRouteTableID.NotFound", () =>
511 Effect.succeed({ RouteTables: [] }),
512 ),
513 );
514
515 const routeTable = result.RouteTables?.[0];
516 if (!routeTable) {
517 return undefined;
518 }
519
520 // Find the matching route
521 const route = routeTable.Routes?.find((r) => {
522 if (props.destinationCidrBlock) {
523 return r.DestinationCidrBlock === props.destinationCidrBlock;
524 }
525 if (props.destinationIpv6CidrBlock) {
526 return r.DestinationIpv6CidrBlock === props.destinationIpv6CidrBlock;
527 }
528 if (props.destinationPrefixListId) {
529 return r.DestinationPrefixListId === props.destinationPrefixListId;
530 }
531 return false;
532 });
533
534 return route;
535 });