1import type * as EC2 from "@distilled.cloud/aws/ec2";
2import * as ec2 from "@distilled.cloud/aws/ec2";
3import * as Data from "effect/Data";
4import * as Effect from "effect/Effect";
5import * as Schedule from "effect/Schedule";
6import * as Stream from "effect/Stream";
7
8import type { ScopedPlanStatusSession } from "../../Report.ts";
9import { isResolved } from "../../Diff.ts";
10import * as Provider from "../../Provider.ts";
11import { Resource } from "../../Resource.ts";
12import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
13import type { AccountID } from "../Environment.ts";
14import { AWSEnvironment } from "../Environment.ts";
15import type { Providers } from "../Providers.ts";
16import type { RegionID } from "../Region.ts";
17import type { VpcId } from "./Vpc.ts";
18
19export type RouteTableId<ID extends string = string> = `rtb-${ID}`;
20export const RouteTableId = <ID extends string>(
21 id: ID,
22): ID & RouteTableId<ID> => `rtb-${id}` as ID & RouteTableId<ID>;
23
24export interface RouteTableProps {
25 /**
26 * The VPC to create the route table in.
27 * Required.
28 */
29 vpcId: VpcId;
30
31 /**
32 * Tags to assign to the route table.
33 * These will be merged with alchemy auto-tags (alchemy::stack, alchemy::stage, alchemy::id).
34 */
35 tags?: Record<string, string>;
36}
37
38export interface RouteTable extends Resource<
39 "AWS.EC2.RouteTable",
40 RouteTableProps,
41 {
42 /**
43 * The ID of the VPC the route table is in.
44 */
45 vpcId: VpcId;
46
47 /**
48 * The ID of the route table.
49 */
50 routeTableId: RouteTableId;
51
52 /**
53 * The Amazon Resource Name (ARN) of the route table.
54 */
55 routeTableArn: `arn:aws:ec2:${RegionID}:${AccountID}:route-table/${string}`;
56
57 /**
58 * The ID of the AWS account that owns the route table.
59 */
60 ownerId?: string;
61
62 /**
63 * The associations between the route table and subnets or gateways.
64 */
65 associations?: Array<{
66 /**
67 * Whether this is the main route table for the VPC.
68 */
69 main: boolean;
70 /**
71 * The ID of the association.
72 */
73 routeTableAssociationId?: string;
74 /**
75 * The ID of the route table.
76 */
77 routeTableId?: string;
78 /**
79 * The ID of the subnet (if the association is with a subnet).
80 */
81 subnetId?: string;
82 /**
83 * The ID of the gateway (if the association is with a gateway).
84 */
85 gatewayId?: string;
86 /**
87 * The state of the association.
88 */
89 associationState?: {
90 state: EC2.RouteTableAssociationStateCode;
91 statusMessage?: string;
92 };
93 }>;
94
95 /**
96 * The routes in the route table.
97 */
98 routes?: Array<{
99 /**
100 * The IPv4 CIDR block used for the destination match.
101 */
102 destinationCidrBlock?: string;
103 /**
104 * The IPv6 CIDR block used for the destination match.
105 */
106 destinationIpv6CidrBlock?: string;
107 /**
108 * The prefix of the AWS service.
109 */
110 destinationPrefixListId?: string;
111 /**
112 * The ID of the egress-only internet gateway.
113 */
114 egressOnlyInternetGatewayId?: string;
115 /**
116 * The ID of the gateway (internet gateway or virtual private gateway).
117 */
118 gatewayId?: string;
119 /**
120 * The ID of the NAT instance.
121 */
122 instanceId?: string;
123 /**
124 * The ID of AWS account that owns the NAT instance.
125 */
126 instanceOwnerId?: string;
127 /**
128 * The ID of the NAT gateway.
129 */
130 natGatewayId?: string;
131 /**
132 * The ID of the transit gateway.
133 */
134 transitGatewayId?: string;
135 /**
136 * The ID of the local gateway.
137 */
138 localGatewayId?: string;
139 /**
140 * The ID of the carrier gateway.
141 */
142 carrierGatewayId?: string;
143 /**
144 * The ID of the network interface.
145 */
146 networkInterfaceId?: string;
147 /**
148 * Describes how the route was created.
149 */
150 origin: EC2.RouteOrigin;
151 /**
152 * The state of the route.
153 */
154 state: EC2.RouteState;
155 /**
156 * The ID of the VPC peering connection.
157 */
158 vpcPeeringConnectionId?: string;
159 /**
160 * The Amazon Resource Name (ARN) of the core network.
161 */
162 coreNetworkArn?: string;
163 }>;
164
165 /**
166 * Any virtual private gateway (VGW) propagating routes.
167 */
168 propagatingVgws?: Array<{
169 gatewayId: string;
170 }>;
171 },
172 never,
173 Providers
174> {}
175/**
176 * A VPC route table holds a set of routes that determine where network
177 * traffic from associated subnets (or gateways) is directed. Create one
178 * route table per routing domain — typically a "public" table whose default
179 * route points at an {@link InternetGateway}, and one or more "private" tables
180 * whose default route points at a NAT gateway.
181 *
182 * A route table is little more than a container: it owns a `vpcId` and tags,
183 * while the actual routing behaviour is supplied by separate {@link Route}
184 * resources and applied to subnets by {@link RouteTableAssociation} resources.
185 *
186 * ### Creating a Route Table
187 * The only required input is the `vpcId` the table belongs to. Changing
188 * `vpcId` later replaces the route table, since a table cannot move between
189 * VPCs.
190 *
191 * **Example:** Basic Route Table
192 * ```typescript
193 * const routeTable = yield* AWS.EC2.RouteTable("PublicRouteTable", {
194 * vpcId: myVpc.vpcId,
195 * });
196 * ```
197 * Creates an empty route table in the given VPC. It starts with only the
198 * implicit `local` route (managed by AWS) until you add your own
199 * {@link Route} resources.
200 *
201 * **Example:** Route Table with Tags
202 * ```typescript
203 * const routeTable = yield* AWS.EC2.RouteTable("PrivateRouteTable", {
204 * vpcId: myVpc.vpcId,
205 * tags: { Name: "private-rt", Tier: "private" },
206 * });
207 * ```
208 * The `tags` map is merged with the alchemy auto-tags (`alchemy::stack`,
209 * `alchemy::stage`, `alchemy::id`) and can be updated in place without
210 * replacing the table. Use the `Name` tag to label the table in the AWS
211 * console.
212 *
213 * ### Building a Public Routing Domain
214 * A route table only directs traffic once you attach routes to it and
215 * associate it with subnets. The pattern below wires a public subnet to the
216 * internet: an {@link InternetGateway}, a default {@link Route} pointing at it,
217 * and a {@link RouteTableAssociation} binding the subnet to the table.
218 *
219 * **Example:** Route Table, Internet Route, and Subnet Association
220 * ```typescript
221 * const internetGateway = yield* AWS.EC2.InternetGateway("InternetGateway", {
222 * vpcId: myVpc.vpcId,
223 * });
224 *
225 * const publicRouteTable = yield* AWS.EC2.RouteTable("PublicRouteTable", {
226 * vpcId: myVpc.vpcId,
227 * });
228 *
229 * const internetRoute = yield* AWS.EC2.Route("InternetRoute", {
230 * routeTableId: publicRouteTable.routeTableId,
231 * destinationCidrBlock: "0.0.0.0/0",
232 * gatewayId: internetGateway.internetGatewayId,
233 * });
234 *
235 * const association = yield* AWS.EC2.RouteTableAssociation("PublicSubnetAssociation", {
236 * routeTableId: publicRouteTable.routeTableId,
237 * subnetId: publicSubnet.subnetId,
238 * });
239 * ```
240 * Any subnet associated with this table now reaches the public internet via
241 * the `0.0.0.0/0` route. Multiple subnets can share the same route table by
242 * declaring additional associations — a common way to give every public
243 * subnet in a VPC identical routing.
244 *
245 * @resource
246 */
247export const RouteTable = Resource<RouteTable>("AWS.EC2.RouteTable");
248
249export const RouteTableProvider = () =>
250 Provider.effect(
251 RouteTable,
252 Effect.gen(function* () {
253 return {
254 stables: ["routeTableId", "ownerId", "routeTableArn", "vpcId"],
255
256 list: () =>
257 Effect.gen(function* () {
258 const { accountId, region } = yield* AWSEnvironment.current;
259 return yield* ec2.describeRouteTables.pages({}).pipe(
260 Stream.runCollect,
261 Effect.map((chunk) =>
262 Array.from(chunk).flatMap((page) =>
263 (page.RouteTables ?? [])
264 .filter(
265 (rt): rt is ec2.RouteTable & { RouteTableId: string } =>
266 rt.RouteTableId != null &&
267 // Every VPC has an implicit main route table. AWS does
268 // not allow deleting it directly; deleting the VPC
269 // removes it automatically.
270 !rt.Associations?.some((assoc) => assoc.Main === true),
271 )
272 .map((rt) => {
273 const routeTableId = rt.RouteTableId as RouteTableId;
274 return {
275 routeTableId,
276 routeTableArn:
277 `arn:aws:ec2:${region}:${accountId}:route-table/${routeTableId}` as `arn:aws:ec2:${RegionID}:${AccountID}:route-table/${string}`,
278 vpcId: rt.VpcId as VpcId,
279 ownerId: rt.OwnerId,
280 associations: rt.Associations?.map((assoc) => ({
281 main: assoc.Main ?? false,
282 routeTableAssociationId:
283 assoc.RouteTableAssociationId,
284 routeTableId: assoc.RouteTableId,
285 subnetId: assoc.SubnetId,
286 gatewayId: assoc.GatewayId,
287 associationState: assoc.AssociationState
288 ? {
289 state: assoc.AssociationState.State!,
290 statusMessage:
291 assoc.AssociationState.StatusMessage,
292 }
293 : undefined,
294 })),
295 routes: rt.Routes?.map((route) => ({
296 destinationCidrBlock: route.DestinationCidrBlock,
297 destinationIpv6CidrBlock:
298 route.DestinationIpv6CidrBlock,
299 destinationPrefixListId:
300 route.DestinationPrefixListId,
301 egressOnlyInternetGatewayId:
302 route.EgressOnlyInternetGatewayId,
303 gatewayId: route.GatewayId,
304 instanceId: route.InstanceId,
305 instanceOwnerId: route.InstanceOwnerId,
306 natGatewayId: route.NatGatewayId,
307 transitGatewayId: route.TransitGatewayId,
308 localGatewayId: route.LocalGatewayId,
309 carrierGatewayId: route.CarrierGatewayId,
310 networkInterfaceId: route.NetworkInterfaceId,
311 origin: route.Origin!,
312 state: route.State!,
313 vpcPeeringConnectionId: route.VpcPeeringConnectionId,
314 coreNetworkArn: route.CoreNetworkArn,
315 })),
316 propagatingVgws: rt.PropagatingVgws?.map((vgw) => ({
317 gatewayId: vgw.GatewayId!,
318 })),
319 };
320 }),
321 ),
322 ),
323 );
324 }),
325
326 diff: Effect.fn(function* ({ news, olds }) {
327 if (!isResolved(news)) return;
328 // VpcId change requires replacement
329 if (olds.vpcId !== news.vpcId) {
330 return { action: "replace" };
331 }
332 // Tags can be updated in-place
333 }),
334
335 reconcile: Effect.fn(function* ({ id, news, output, session }) {
336 const { accountId, region } = yield* AWSEnvironment.current;
337 const alchemyTags = yield* createInternalTags(id);
338 const desiredTags = { ...alchemyTags, ...news.tags };
339
340 // Observe — find the route table via cached id, else fall through
341 // to create.
342 let routeTable: ec2.RouteTable | undefined;
343 if (output?.routeTableId) {
344 const lookup = yield* ec2
345 .describeRouteTables({ RouteTableIds: [output.routeTableId] })
346 .pipe(
347 Effect.catchTag("InvalidRouteTableID.NotFound", () =>
348 Effect.succeed({ RouteTables: [] }),
349 ),
350 );
351 routeTable = lookup.RouteTables?.[0];
352 }
353
354 // Ensure — create the route table when missing.
355 if (routeTable === undefined) {
356 const createResult = yield* ec2
357 .createRouteTable({
358 VpcId: news.vpcId,
359 TagSpecifications: [
360 {
361 ResourceType: "route-table",
362 Tags: createTagsList(desiredTags),
363 },
364 ],
365 DryRun: false,
366 })
367 .pipe(
368 Effect.retry({
369 while: (e) => e._tag === "InvalidVpcID.NotFound",
370 schedule: Schedule.max([
371 Schedule.fixed(500),
372 Schedule.recurs(10),
373 ]),
374 }),
375 );
376 const newId = createResult.RouteTable!
377 .RouteTableId! as RouteTableId;
378 yield* session.note(`Route table created: ${newId}`);
379 routeTable = yield* describeRouteTable(newId, session);
380 }
381
382 const routeTableId = routeTable.RouteTableId! as RouteTableId;
383
384 // Sync tags — observed cloud tags vs desired.
385 const currentTags = Object.fromEntries(
386 (routeTable.Tags ?? []).map((t) => [t.Key!, t.Value!]),
387 ) as Record<string, string>;
388 const { removed, upsert } = diffTags(currentTags, desiredTags);
389 if (removed.length > 0) {
390 yield* ec2.deleteTags({
391 Resources: [routeTableId],
392 Tags: removed.map((key) => ({ Key: key })),
393 });
394 }
395 if (upsert.length > 0) {
396 yield* ec2.createTags({
397 Resources: [routeTableId],
398 Tags: upsert,
399 });
400 }
401
402 // Re-read final state.
403 const final = yield* describeRouteTable(routeTableId, session);
404 return {
405 routeTableId,
406 routeTableArn:
407 `arn:aws:ec2:${region}:${accountId}:route-table/${routeTableId}` as `arn:aws:ec2:${RegionID}:${AccountID}:route-table/${string}`,
408 vpcId: news.vpcId as VpcId,
409 ownerId: final.OwnerId,
410 associations: final.Associations?.map((assoc) => ({
411 main: assoc.Main ?? false,
412 routeTableAssociationId: assoc.RouteTableAssociationId,
413 routeTableId: assoc.RouteTableId,
414 subnetId: assoc.SubnetId,
415 gatewayId: assoc.GatewayId,
416 associationState: assoc.AssociationState
417 ? {
418 state: assoc.AssociationState.State!,
419 statusMessage: assoc.AssociationState.StatusMessage,
420 }
421 : undefined,
422 })),
423 routes: final.Routes?.map((route) => ({
424 destinationCidrBlock: route.DestinationCidrBlock,
425 destinationIpv6CidrBlock: route.DestinationIpv6CidrBlock,
426 destinationPrefixListId: route.DestinationPrefixListId,
427 egressOnlyInternetGatewayId: route.EgressOnlyInternetGatewayId,
428 gatewayId: route.GatewayId,
429 instanceId: route.InstanceId,
430 instanceOwnerId: route.InstanceOwnerId,
431 natGatewayId: route.NatGatewayId,
432 transitGatewayId: route.TransitGatewayId,
433 localGatewayId: route.LocalGatewayId,
434 carrierGatewayId: route.CarrierGatewayId,
435 networkInterfaceId: route.NetworkInterfaceId,
436 origin: route.Origin!,
437 state: route.State!,
438 vpcPeeringConnectionId: route.VpcPeeringConnectionId,
439 coreNetworkArn: route.CoreNetworkArn,
440 })),
441 propagatingVgws: final.PropagatingVgws?.map((vgw) => ({
442 gatewayId: vgw.GatewayId!,
443 })),
444 };
445 }),
446
447 delete: Effect.fn(function* ({ output, session }) {
448 const routeTableId = output.routeTableId;
449
450 yield* session.note(`Deleting route table: ${routeTableId}`);
451
452 // 1. Attempt to delete route table
453 yield* ec2
454 .deleteRouteTable({
455 RouteTableId: routeTableId,
456 DryRun: false,
457 })
458 .pipe(
459 Effect.tapError(Effect.logDebug),
460 Effect.catchTag(
461 "InvalidRouteTableID.NotFound",
462 () => Effect.void,
463 ),
464 // Retry on dependency violations (associations still being deleted)
465 Effect.retry({
466 // DependencyViolation means there are still dependent resources
467 while: (e) => {
468 return e._tag === "DependencyViolation";
469 },
470 schedule: Schedule.max([
471 Schedule.fixed(3000),
472 Schedule.recurs(10),
473 ]).pipe(
474 Schedule.tap(({ attempt }) =>
475 session.note(
476 `Waiting for dependencies to clear... (attempt ${attempt})`,
477 ),
478 ),
479 ),
480 }),
481 );
482
483 // 2. Wait for route table to be fully deleted
484 yield* waitForRouteTableDeleted(routeTableId, session);
485
486 yield* session.note(
487 `Route table ${routeTableId} deleted successfully`,
488 );
489 }),
490 };
491 }),
492 );
493
494/**
495 * Describe a route table by ID
496 */
497const describeRouteTable = (
498 routeTableId: string,
499 _session?: ScopedPlanStatusSession,
500) =>
501 Effect.gen(function* () {
502 const result = yield* ec2
503 .describeRouteTables({ RouteTableIds: [routeTableId] })
504 .pipe(
505 Effect.catchTag("InvalidRouteTableID.NotFound", () =>
506 Effect.succeed({ RouteTables: [] }),
507 ),
508 );
509
510 const routeTable = result.RouteTables?.[0];
511 if (!routeTable) {
512 // createRouteTable can return before the new ID is visible to
513 // describeRouteTables. Keep the ID in this reconcile and retry the
514 // observation instead of failing after create and losing the only
515 // handle the engine has for cleanup.
516 return yield* new RouteTableNotVisible({ routeTableId });
517 }
518 return routeTable;
519 }).pipe(
520 Effect.retry({
521 while: (error) => error instanceof RouteTableNotVisible,
522 schedule: Schedule.max([Schedule.fixed(500), Schedule.recurs(10)]),
523 }),
524 );
525
526class RouteTableNotVisible extends Data.TaggedError("RouteTableNotVisible")<{
527 routeTableId: string;
528}> {}
529
530/**
531 * Wait for route table to be deleted
532 */
533const waitForRouteTableDeleted = (
534 routeTableId: string,
535 session: ScopedPlanStatusSession,
536) =>
537 Effect.gen(function* () {
538 yield* Effect.retry(
539 Effect.gen(function* () {
540 const result = yield* ec2
541 .describeRouteTables({ RouteTableIds: [routeTableId] })
542 .pipe(
543 Effect.tapError(Effect.logDebug),
544 Effect.catchTag("InvalidRouteTableID.NotFound", () =>
545 Effect.succeed({ RouteTables: [] }),
546 ),
547 );
548
549 if (!result.RouteTables || result.RouteTables.length === 0) {
550 return; // Successfully deleted
551 }
552
553 // Still exists, fail to trigger retry
554 return yield* Effect.fail(new Error("Route table still exists"));
555 }),
556 {
557 schedule: Schedule.max([
558 Schedule.fixed(2000),
559 Schedule.recurs(10),
560 ]).pipe(
561 Schedule.tap(({ attempt }) =>
562 session.note(
563 `Waiting for route table deletion... (${attempt * 2}s)`,
564 ),
565 ),
566 ),
567 },
568 );
569 });