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 type { ScopedPlanStatusSession } from "../../Report.ts";
8import { isResolved } from "../../Diff.ts";
9import * as Provider from "../../Provider.ts";
10import { Resource } from "../../Resource.ts";
11import type { Providers } from "../Providers.ts";
12import type { RouteTableId } from "./RouteTable.ts";
13import type { SubnetId } from "./Subnet.ts";
14
15export type RouteTableAssociationId<ID extends string = string> =
16 `rtbassoc-${ID}`;
17export const RouteTableAssociationId = <ID extends string>(
18 id: ID,
19): ID & RouteTableAssociationId<ID> =>
20 `rtbassoc-${id}` as ID & RouteTableAssociationId<ID>;
21
22export interface RouteTableAssociationProps {
23 /**
24 * The ID of the route table.
25 * Required.
26 */
27 routeTableId: RouteTableId;
28
29 /**
30 * The ID of the subnet to associate with the route table.
31 * Either subnetId or gatewayId is required, but not both.
32 */
33 subnetId?: SubnetId;
34
35 /**
36 * The ID of the gateway (internet gateway or virtual private gateway) to associate with the route table.
37 * Either subnetId or gatewayId is required, but not both.
38 */
39 gatewayId?: string;
40}
41
42export interface RouteTableAssociation extends Resource<
43 "AWS.EC2.RouteTableAssociation",
44 RouteTableAssociationProps,
45 {
46 /**
47 * The ID of the association.
48 */
49 associationId: RouteTableAssociationId;
50
51 /**
52 * The ID of the route table.
53 */
54 routeTableId: RouteTableId;
55
56 /**
57 * The ID of the subnet (if the association is with a subnet).
58 */
59 subnetId?: SubnetId | undefined;
60
61 /**
62 * The ID of the gateway (if the association is with a gateway).
63 */
64 gatewayId?: string | undefined;
65
66 /**
67 * The state of the association.
68 */
69 associationState: {
70 state: EC2.RouteTableAssociationStateCode;
71 statusMessage?: string;
72 };
73 },
74 never,
75 Providers
76> {}
77/**
78 * Associates a {@link RouteTable} with a subnet (or a gateway), making that
79 * route table govern traffic for the associated resource. A subnet can be
80 * associated with exactly one route table at a time; multiple subnets may share
81 * the same route table.
82 *
83 * Provide exactly one of `subnetId` or `gatewayId`. Changing the subnet or
84 * gateway replaces the association, whereas pointing an existing association at
85 * a different route table is applied in place via
86 * `ReplaceRouteTableAssociation`.
87 *
88 * ### Associating Subnets
89 * Associating a subnet overrides the VPC's main route table for that subnet.
90 * This is how you make a subnet "public" (associate it with a table that has an
91 * internet-gateway route) or "private" (associate it with a NAT-gateway table).
92 *
93 * **Example:** Associate a Subnet with a Route Table
94 * ```typescript
95 * const association = yield* AWS.EC2.RouteTableAssociation("PublicSubnetAssociation", {
96 * routeTableId: publicRouteTable.routeTableId,
97 * subnetId: publicSubnet.subnetId,
98 * });
99 * ```
100 * Binds a single subnet to the route table so its instances follow that
101 * table's routes. The returned `associationId` (prefixed `rtbassoc-`) can be
102 * used to track or replace the association.
103 *
104 * **Example:** Share One Route Table Across Multiple Subnets
105 * ```typescript
106 * const subnet1Association = yield* AWS.EC2.RouteTableAssociation("PublicSubnet1Association", {
107 * routeTableId: publicRouteTable.routeTableId,
108 * subnetId: publicSubnet1.subnetId,
109 * });
110 *
111 * const subnet2Association = yield* AWS.EC2.RouteTableAssociation("PublicSubnet2Association", {
112 * routeTableId: publicRouteTable.routeTableId,
113 * subnetId: publicSubnet2.subnetId,
114 * });
115 * ```
116 * Declaring multiple associations against the same `routeTableId` gives every
117 * listed subnet identical routing — a concise way to apply one public (or
118 * private) routing policy across all subnets in a tier.
119 *
120 * ### Associating Gateways (Edge Routing)
121 * Instead of a subnet, an association can target an internet gateway or
122 * virtual private gateway via `gatewayId`. This "gateway route table
123 * association" enables edge routing, where inbound traffic is inspected or
124 * redirected (e.g. to a firewall appliance) as it enters the VPC.
125 *
126 * **Example:** Associate a Route Table with an Internet Gateway
127 * ```typescript
128 * const edgeAssociation = yield* AWS.EC2.RouteTableAssociation("EdgeAssociation", {
129 * routeTableId: ingressRouteTable.routeTableId,
130 * gatewayId: internetGateway.internetGatewayId,
131 * });
132 * ```
133 * Attaches the route table at the gateway rather than at a subnet, so traffic
134 * arriving from the internet is steered by this table — typically toward an
135 * inspection appliance before reaching its destination subnet.
136 *
137 * @resource
138 */
139export const RouteTableAssociation = Resource<RouteTableAssociation>(
140 "AWS.EC2.RouteTableAssociation",
141);
142
143export const RouteTableAssociationProvider = () =>
144 Provider.effect(
145 RouteTableAssociation,
146 Effect.gen(function* () {
147 return {
148 stables: ["associationId", "subnetId", "gatewayId"],
149
150 // Associations are embedded in describeRouteTables — each RouteTable
151 // carries an Associations[] of {subnet/gateway, routeTable,
152 // associationId}. Flatten every page's associations across the region.
153 // The implicit "main" association (the VPC default route-table binding)
154 // is skipped: it isn't a standalone resource we create or manage.
155 list: () =>
156 ec2.describeRouteTables.pages({}).pipe(
157 Stream.runCollect,
158 Effect.map((chunk) =>
159 Array.from(chunk).flatMap((page) =>
160 (page.RouteTables ?? []).flatMap((rt) =>
161 (rt.Associations ?? [])
162 .filter(
163 (
164 a,
165 ): a is EC2.RouteTableAssociation & {
166 RouteTableAssociationId: string;
167 RouteTableId: string;
168 } =>
169 a.Main !== true &&
170 a.RouteTableAssociationId != null &&
171 a.RouteTableId != null,
172 )
173 .map((a) => ({
174 associationId:
175 a.RouteTableAssociationId as RouteTableAssociationId,
176 routeTableId: a.RouteTableId as RouteTableId,
177 subnetId: a.SubnetId as SubnetId | undefined,
178 gatewayId: a.GatewayId,
179 associationState: {
180 state: a.AssociationState?.State ?? "associated",
181 statusMessage: a.AssociationState?.StatusMessage,
182 },
183 })),
184 ),
185 ),
186 ),
187 ),
188
189 diff: Effect.fn(function* ({ news, olds }) {
190 if (!isResolved(news)) return;
191 // Subnet/Gateway change requires replacement (use ReplaceRouteTableAssociation internally)
192 if (olds.subnetId !== news.subnetId) {
193 return { action: "replace" };
194 }
195 if (olds.gatewayId !== news.gatewayId) {
196 return { action: "replace" };
197 }
198 // Route table change can be done via ReplaceRouteTableAssociation
199 }),
200
201 reconcile: Effect.fn(function* ({ news, output, session }) {
202 // Observe — try the cached associationId first; if the association
203 // was disassociated out-of-band, fall through to AssociateRouteTable.
204 let observed:
205 | {
206 associationId: string;
207 routeTableId: string;
208 subnetId?: string;
209 gatewayId?: string;
210 state?: EC2.RouteTableAssociationStateCode;
211 statusMessage?: string;
212 }
213 | undefined;
214 if (output?.associationId) {
215 const lookup = yield* ec2
216 .describeRouteTables({
217 Filters: [
218 {
219 Name: "association.route-table-association-id",
220 Values: [output.associationId],
221 },
222 ],
223 })
224 .pipe(
225 Effect.catchTag("InvalidRouteTableID.NotFound", () =>
226 Effect.succeed({ RouteTables: [] }),
227 ),
228 );
229 for (const rt of lookup.RouteTables ?? []) {
230 const assoc = rt.Associations?.find(
231 (a) => a.RouteTableAssociationId === output.associationId,
232 );
233 if (assoc) {
234 observed = {
235 associationId: assoc.RouteTableAssociationId!,
236 routeTableId: assoc.RouteTableId!,
237 subnetId: assoc.SubnetId,
238 gatewayId: assoc.GatewayId,
239 state: assoc.AssociationState?.State,
240 statusMessage: assoc.AssociationState?.StatusMessage,
241 };
242 break;
243 }
244 }
245 }
246
247 // Ensure — no existing association ⇒ AssociateRouteTable.
248 if (observed === undefined) {
249 const result = yield* ec2
250 .associateRouteTable({
251 RouteTableId: news.routeTableId,
252 SubnetId: news.subnetId,
253 GatewayId: news.gatewayId,
254 DryRun: false,
255 })
256 .pipe(
257 Effect.retry({
258 while: (e) =>
259 e._tag === "InvalidRouteTableID.NotFound" ||
260 e._tag === "InvalidSubnetID.NotFound",
261 schedule: Schedule.exponential(100),
262 }),
263 );
264 const associationId =
265 result.AssociationId! as RouteTableAssociationId;
266 yield* session.note(
267 `Route table association created: ${associationId}`,
268 );
269 yield* waitForAssociationState(
270 news.routeTableId,
271 associationId,
272 "associated",
273 session,
274 );
275 return {
276 associationId,
277 routeTableId: news.routeTableId as RouteTableId,
278 subnetId: news.subnetId as SubnetId | undefined,
279 gatewayId: news.gatewayId as string | undefined,
280 associationState: {
281 state: result.AssociationState?.State ?? "associated",
282 statusMessage: result.AssociationState?.StatusMessage,
283 },
284 };
285 }
286
287 // Sync — drift in routeTableId is patched in place by
288 // ReplaceRouteTableAssociation. Subnet/gateway changes are
289 // intercepted upstream by `diff` as a replacement.
290 if (observed.routeTableId !== (news.routeTableId as string)) {
291 const result = yield* ec2.replaceRouteTableAssociation({
292 AssociationId: observed.associationId,
293 RouteTableId: news.routeTableId,
294 DryRun: false,
295 });
296 const newAssociationId =
297 result.NewAssociationId! as RouteTableAssociationId;
298 yield* session.note(
299 `Route table association replaced: ${newAssociationId}`,
300 );
301 yield* waitForAssociationState(
302 news.routeTableId,
303 newAssociationId,
304 "associated",
305 session,
306 );
307 return {
308 associationId: newAssociationId,
309 routeTableId: news.routeTableId as RouteTableId,
310 subnetId: news.subnetId as SubnetId | undefined,
311 gatewayId: news.gatewayId as string | undefined,
312 associationState: {
313 state: result.AssociationState?.State ?? "associated",
314 statusMessage: result.AssociationState?.StatusMessage,
315 },
316 };
317 }
318
319 // Already in the desired state — just report it.
320 return {
321 associationId: observed.associationId as RouteTableAssociationId,
322 routeTableId: observed.routeTableId as RouteTableId,
323 subnetId: observed.subnetId as SubnetId | undefined,
324 gatewayId: observed.gatewayId,
325 associationState: {
326 state: observed.state ?? "associated",
327 statusMessage: observed.statusMessage,
328 },
329 };
330 }),
331
332 delete: Effect.fn(function* ({ output, session }) {
333 yield* session.note(
334 `Deleting route table association: ${output.associationId}`,
335 );
336
337 // Disassociate the route table
338 yield* ec2
339 .disassociateRouteTable({
340 AssociationId: output.associationId,
341 DryRun: false,
342 })
343 .pipe(
344 Effect.tapError(Effect.log),
345 Effect.catchTag(
346 "InvalidAssociationID.NotFound",
347 () => Effect.void,
348 ),
349 );
350
351 yield* session.note(
352 `Route table association ${output.associationId} deleted successfully`,
353 );
354 }),
355 };
356 }),
357 );
358
359/**
360 * Wait for association to reach a specific state
361 */
362const waitForAssociationState = (
363 routeTableId: string,
364 associationId: string,
365 targetState:
366 | "associating"
367 | "associated"
368 | "disassociating"
369 | "disassociated",
370 session?: ScopedPlanStatusSession,
371) =>
372 Effect.retry(
373 Effect.gen(function* () {
374 const result = yield* ec2
375 .describeRouteTables({ RouteTableIds: [routeTableId] })
376 .pipe(
377 Effect.catchTag("InvalidRouteTableID.NotFound", () =>
378 Effect.succeed({
379 RouteTables: [],
380 } as ec2.DescribeRouteTablesResult),
381 ),
382 );
383
384 const routeTable = result.RouteTables?.[0];
385 if (!routeTable) {
386 return yield* Effect.fail(new Error("Route table not found"));
387 }
388
389 const association = routeTable.Associations?.find(
390 (a) => a.RouteTableAssociationId === associationId,
391 );
392
393 if (!association) {
394 // Association might not exist yet, retry
395 return yield* Effect.fail(new Error("Association not found"));
396 }
397
398 if (association.AssociationState?.State === targetState) {
399 return;
400 }
401
402 if (association.AssociationState?.State === "failed") {
403 return yield* Effect.fail(
404 new Error(
405 `Association failed: ${association.AssociationState.StatusMessage}`,
406 ),
407 );
408 }
409
410 // Still in progress, fail to trigger retry
411 return yield* Effect.fail(
412 new Error(`Association state: ${association.AssociationState?.State}`),
413 );
414 }),
415 {
416 schedule: Schedule.max([Schedule.fixed(1000), Schedule.recurs(30)]).pipe(
417 Schedule.tap(({ attempt }) =>
418 session
419 ? session.note(
420 `Waiting for association to be ${targetState}... (${attempt}s)`,
421 )
422 : Effect.void,
423 ),
424 ),
425 },
426 );