1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Option from "effect/Option";
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 {
13 createAlchemyTagFilters,
14 createInternalTags,
15 createTagsList,
16 diffTags,
17} from "../../Tags.ts";
18import type { AccountID } from "../Environment.ts";
19import { AWSEnvironment } from "../Environment.ts";
20import type { Providers } from "../Providers.ts";
21import type { RegionID } from "../Region.ts";
22import type { AllocationId } from "./EIP.ts";
23import type { SubnetId } from "./Subnet.ts";
24
25export type NatGatewayId<ID extends string = string> = `nat-${ID}`;
26export const NatGatewayId = <ID extends string>(
27 id: ID,
28): ID & NatGatewayId<ID> => `nat-${id}` as ID & NatGatewayId<ID>;
29
30export type NatGatewayArn =
31 `arn:aws:ec2:${RegionID}:${AccountID}:natgateway/${NatGatewayId}`;
32
33export interface NatGatewayProps {
34 /**
35 * The subnet in which to create the NAT gateway.
36 * For public NAT gateways, this must be a public subnet.
37 */
38 subnetId: SubnetId;
39
40 /**
41 * The allocation ID of the Elastic IP address for the gateway.
42 * Required for public NAT gateways.
43 */
44 allocationId?: AllocationId;
45
46 /**
47 * Indicates whether the NAT gateway supports public or private connectivity.
48 * @default "public"
49 */
50 connectivityType?: ec2.ConnectivityType;
51
52 /**
53 * The private IPv4 address to assign to the NAT gateway.
54 * If you don't provide an address, a private IPv4 address will be automatically assigned.
55 */
56 privateIpAddress?: string;
57
58 /**
59 * Secondary allocation IDs for additional private IP addresses.
60 * Only valid for private NAT gateways.
61 */
62 secondaryAllocationIds?: AllocationId[];
63
64 /**
65 * Secondary private IPv4 addresses.
66 * Only valid for private NAT gateways.
67 */
68 secondaryPrivateIpAddresses?: string[];
69
70 /**
71 * The number of secondary private IPv4 addresses to assign.
72 * Only valid for private NAT gateways.
73 */
74 secondaryPrivateIpAddressCount?: number;
75
76 /**
77 * Tags to assign to the NAT gateway.
78 */
79 tags?: Record<string, string>;
80}
81
82export interface NatGateway extends Resource<
83 "AWS.EC2.NatGateway",
84 NatGatewayProps,
85 {
86 /**
87 * The ID of the NAT gateway.
88 */
89 natGatewayId: NatGatewayId;
90
91 /**
92 * The Amazon Resource Name (ARN) of the NAT gateway.
93 */
94 natGatewayArn: `arn:aws:ec2:${RegionID}:${AccountID}:natgateway/${string}`;
95
96 /**
97 * The ID of the subnet in which the NAT gateway is located.
98 */
99 subnetId: SubnetId;
100
101 /**
102 * The ID of the VPC in which the NAT gateway is located.
103 */
104 vpcId: string;
105
106 /**
107 * The current state of the NAT gateway.
108 */
109 state: ec2.NatGatewayState;
110
111 /**
112 * The connectivity type of the NAT gateway.
113 */
114 connectivityType: ec2.ConnectivityType;
115
116 /**
117 * The Elastic IP address associated with the NAT gateway (for public NAT gateways).
118 */
119 publicIp?: string;
120
121 /**
122 * The private IP address associated with the NAT gateway.
123 */
124 privateIp?: string;
125
126 /**
127 * Information about the IP addresses and network interface associated with the NAT gateway.
128 */
129 natGatewayAddresses?: Array<{
130 allocationId?: string;
131 networkInterfaceId?: string;
132 privateIp?: string;
133 publicIp?: string;
134 associationId?: string;
135 isPrimary?: boolean;
136 failureMessage?: string;
137 status?: ec2.NatGatewayAddressStatus;
138 }>;
139
140 /**
141 * If the NAT gateway could not be created, specifies the error code for the failure.
142 */
143 failureCode?: string;
144
145 /**
146 * If the NAT gateway could not be created, specifies the error message for the failure.
147 */
148 failureMessage?: string;
149
150 /**
151 * The date and time the NAT gateway was created.
152 */
153 createTime?: string;
154
155 /**
156 * The date and time the NAT gateway was deleted, if applicable.
157 */
158 deleteTime?: string;
159 },
160 never,
161 Providers
162> {}
163/**
164 * A NAT gateway that lets instances in a private subnet reach the internet
165 * (and other AWS services) while preventing unsolicited inbound connections.
166 *
167 * The gateway lives in the subnet given by `subnetId`, and its
168 * `connectivityType` decides how it connects: a `"public"` gateway must sit in a
169 * *public* subnet and requires an Elastic IP via `allocationId`, while a
170 * `"private"` gateway has no public address and is used for VPC-to-VPC routing.
171 * A NAT gateway only carries traffic once a `Route` sends `0.0.0.0/0` from the
172 * private subnet's route table to it. Core properties (`subnetId`,
173 * `connectivityType`, `allocationId`) are immutable, so changing them replaces
174 * the gateway.
175 *
176 * ### Public NAT Gateways
177 * Public gateways translate private addresses to a stable public IP, so they
178 * must be placed in a public subnet (one with a route to an internet gateway)
179 * and given an Elastic IP allocation.
180 * **Example:** Public NAT Gateway with an Elastic IP
181 * ```typescript
182 * const eip = yield* AWS.EC2.EIP("NatEip", {});
183 *
184 * const natGateway = yield* AWS.EC2.NatGateway("NatGateway", {
185 * subnetId: publicSubnet.subnetId,
186 * allocationId: eip.allocationId,
187 * connectivityType: "public",
188 * tags: { Name: "production-nat" },
189 * });
190 * ```
191 * Allocating the EIP first and passing its `allocationId` gives the gateway a
192 * fixed public IP. `connectivityType` defaults to `"public"`, so it can be
193 * omitted; this is the standard way to give private instances outbound internet
194 * access.
195 *
196 * ### Private NAT Gateways
197 * Private gateways have no public IP and route traffic between VPCs or to
198 * on-premises networks without exposing it to the internet.
199 * **Example:** Private NAT Gateway with a Fixed Private IP
200 * ```typescript
201 * const natGateway = yield* AWS.EC2.NatGateway("PrivateNat", {
202 * subnetId: privateSubnet.subnetId,
203 * connectivityType: "private",
204 * privateIpAddress: "10.0.10.10",
205 * });
206 * ```
207 * Omitting `allocationId` and setting `connectivityType: "private"` creates a
208 * gateway with no public address; `privateIpAddress` pins it to a specific
209 * address in the subnet instead of letting AWS choose one automatically.
210 *
211 * **Example:** Private NAT Gateway with Secondary Addresses
212 * ```typescript
213 * const natGateway = yield* AWS.EC2.NatGateway("ScaledNat", {
214 * subnetId: privateSubnet.subnetId,
215 * connectivityType: "private",
216 * secondaryPrivateIpAddressCount: 3,
217 * });
218 * ```
219 * Secondary private addresses — via `secondaryPrivateIpAddressCount`,
220 * `secondaryPrivateIpAddresses`, or `secondaryAllocationIds` — raise the number
221 * of simultaneous connections a private gateway can sustain to busy
222 * destinations, which is only valid for private gateways.
223 *
224 * ### Routing Private Traffic
225 * **Example:** Default Route Through the NAT Gateway
226 * ```typescript
227 * const natRoute = yield* AWS.EC2.Route("NatRoute", {
228 * routeTableId: privateRouteTable.routeTableId,
229 * destinationCidrBlock: "0.0.0.0/0",
230 * natGatewayId: natGateway.natGatewayId,
231 * });
232 * ```
233 * Without a route the gateway is inert; this entry sends all outbound traffic
234 * from the private subnet's route table through the gateway so private instances
235 * can reach the internet.
236 *
237 * @resource
238 */
239export const NatGateway = Resource<NatGateway>("AWS.EC2.NatGateway");
240
241export const NatGatewayProvider = () =>
242 Provider.effect(
243 NatGateway,
244 Effect.gen(function* () {
245 const createTags = Effect.fn(function* (
246 id: string,
247 tags?: Record<string, string>,
248 ) {
249 return {
250 Name: id,
251 ...(yield* createInternalTags(id)),
252 ...tags,
253 };
254 });
255
256 const describeNatGateway = (natGatewayId: string) =>
257 ec2.describeNatGateways({ NatGatewayIds: [natGatewayId] }).pipe(
258 Effect.map((r) => r.NatGateways?.[0]),
259 Effect.flatMap((gw) =>
260 gw
261 ? Effect.succeed(gw)
262 : Effect.fail(new Error(`NAT Gateway ${natGatewayId} not found`)),
263 ),
264 );
265
266 const toAttrs = Effect.fn(function* (gw: ec2.NatGateway) {
267 const { accountId, region } = yield* AWSEnvironment.current;
268 const primaryAddress =
269 gw.NatGatewayAddresses?.find((a) => a.IsPrimary) ??
270 gw.NatGatewayAddresses?.[0];
271 return {
272 natGatewayId: gw.NatGatewayId as NatGatewayId,
273 natGatewayArn:
274 `arn:aws:ec2:${region}:${accountId}:natgateway/${gw.NatGatewayId}` as NatGatewayArn,
275 subnetId: gw.SubnetId as SubnetId,
276 vpcId: gw.VpcId!,
277 state: gw.State!,
278 connectivityType: gw.ConnectivityType!,
279 publicIp: primaryAddress?.PublicIp,
280 privateIp: primaryAddress?.PrivateIp,
281 natGatewayAddresses: gw.NatGatewayAddresses?.map((a) => ({
282 allocationId: a.AllocationId,
283 networkInterfaceId: a.NetworkInterfaceId,
284 privateIp: a.PrivateIp,
285 publicIp: a.PublicIp,
286 associationId: a.AssociationId,
287 isPrimary: a.IsPrimary,
288 failureMessage: a.FailureMessage,
289 status: a.Status,
290 })),
291 failureCode: gw.FailureCode,
292 failureMessage: gw.FailureMessage,
293 createTime:
294 gw.CreateTime instanceof Date
295 ? gw.CreateTime.toISOString()
296 : (gw.CreateTime as string | undefined),
297 deleteTime:
298 gw.DeleteTime instanceof Date
299 ? gw.DeleteTime.toISOString()
300 : (gw.DeleteTime as string | undefined),
301 } satisfies NatGateway["Attributes"];
302 });
303
304 // Find NAT Gateway by alchemy tags when we don't have the ID
305 const findNatGatewayByTags = Effect.fn(function* (id: string) {
306 const filters = yield* createAlchemyTagFilters(id);
307 // Find a NAT Gateway that's not deleted and has matching tags
308 return yield* ec2.describeNatGateways
309 .items({ Filter: filters })
310 .pipe(Stream.runHead, Effect.map(Option.getOrUndefined));
311 });
312
313 return {
314 stables: ["natGatewayId", "natGatewayArn", "vpcId"],
315
316 read: Effect.fn(function* ({ id, output }) {
317 if (output) {
318 // We have the NAT Gateway ID, use it directly
319 return yield* toAttrs(
320 yield* describeNatGateway(output.natGatewayId),
321 );
322 }
323
324 // No output - try to find by tags (recovery from incomplete create)
325 const gw = yield* findNatGatewayByTags(id);
326 if (gw) {
327 return yield* toAttrs(gw);
328 }
329
330 // Not found
331 return undefined;
332 }),
333
334 list: () =>
335 Effect.gen(function* () {
336 // describeNatGateways enumerates every NAT gateway in the
337 // account/region; paginate exhaustively and drop deleted ones.
338 const pages = yield* ec2.describeNatGateways
339 .pages({})
340 .pipe(Stream.runCollect);
341 const gateways = Array.from(pages).flatMap((page) =>
342 (page.NatGateways ?? []).filter(
343 (gw): gw is ec2.NatGateway & { NatGatewayId: string } =>
344 gw.NatGatewayId != null && gw.State !== "deleted",
345 ),
346 );
347 return yield* Effect.forEach(gateways, (gw) => toAttrs(gw));
348 }),
349
350 diff: Effect.fn(function* ({ news, olds }) {
351 if (!isResolved(news)) return;
352 // NAT Gateway is mostly immutable - any change to core properties requires replacement
353 if (
354 news.subnetId !== olds.subnetId ||
355 news.connectivityType !== olds.connectivityType ||
356 news.allocationId !== olds.allocationId
357 ) {
358 return { action: "replace" };
359 }
360 // Tags can be updated in-place
361 }),
362
363 reconcile: Effect.fn(function* ({ id, news, output, session }) {
364 const desiredTags = yield* createTags(id, news.tags);
365
366 // Observe — try the cached id first; if missing, search by alchemy
367 // tags so an interrupted create can recover before re-running the
368 // API. We treat a non-deleted match as ours because diff/replace
369 // already covers immutable property changes upstream.
370 let gw: ec2.NatGateway | undefined;
371 if (output?.natGatewayId) {
372 const lookup = yield* ec2
373 .describeNatGateways({ NatGatewayIds: [output.natGatewayId] })
374 .pipe(
375 Effect.catchTag("NatGatewayNotFound", () =>
376 Effect.succeed({ NatGateways: [] }),
377 ),
378 );
379 gw = lookup.NatGateways?.[0];
380 } else {
381 gw = yield* findNatGatewayByTags(id);
382 }
383
384 // Treat a deleted/deleting NAT as if it doesn't exist so we recreate.
385 if (
386 gw &&
387 (gw.State === "deleted" ||
388 gw.State === "deleting" ||
389 gw.State === "failed")
390 ) {
391 gw = undefined;
392 }
393
394 // Ensure — create the NAT gateway when missing.
395 if (gw === undefined) {
396 yield* session.note("Creating NAT Gateway...");
397 const result = yield* ec2.createNatGateway({
398 SubnetId: news.subnetId as string,
399 AllocationId: news.allocationId as string | undefined,
400 ConnectivityType: news.connectivityType ?? "public",
401 PrivateIpAddress: news.privateIpAddress,
402 SecondaryAllocationIds: news.secondaryAllocationIds as
403 | string[]
404 | undefined,
405 SecondaryPrivateIpAddresses: news.secondaryPrivateIpAddresses,
406 SecondaryPrivateIpAddressCount:
407 news.secondaryPrivateIpAddressCount,
408 TagSpecifications: [
409 {
410 ResourceType: "natgateway",
411 Tags: createTagsList(desiredTags),
412 },
413 ],
414 DryRun: false,
415 });
416 const natGatewayId = result.NatGateway!.NatGatewayId!;
417 yield* session.note(`NAT Gateway created: ${natGatewayId}`);
418 gw = yield* waitForNatGatewayAvailable(natGatewayId, session);
419 }
420
421 const natGatewayId = gw.NatGatewayId!;
422
423 // Sync tags — observed cloud tags vs desired.
424 const currentTags =
425 (yield* ec2
426 .describeTags({
427 Filters: [
428 { Name: "resource-id", Values: [natGatewayId] },
429 { Name: "resource-type", Values: ["natgateway"] },
430 ],
431 })
432 .pipe(
433 Effect.map(
434 (r) =>
435 Object.fromEntries(
436 r.Tags?.map((t) => [t.Key!, t.Value!]) ?? [],
437 ) as Record<string, string>,
438 ),
439 )) ?? {};
440 const { removed, upsert } = diffTags(currentTags, desiredTags);
441 if (removed.length > 0) {
442 yield* ec2.deleteTags({
443 Resources: [natGatewayId],
444 Tags: removed.map((key) => ({ Key: key })),
445 DryRun: false,
446 });
447 }
448 if (upsert.length > 0) {
449 yield* ec2.createTags({
450 Resources: [natGatewayId],
451 Tags: upsert,
452 DryRun: false,
453 });
454 }
455
456 // Re-read final state.
457 const final = yield* describeNatGateway(natGatewayId);
458 return yield* toAttrs(final);
459 }),
460
461 delete: Effect.fn(function* ({ output, session }) {
462 const natGatewayId = output.natGatewayId;
463
464 yield* session.note(`Deleting NAT Gateway: ${natGatewayId}`);
465
466 yield* ec2
467 .deleteNatGateway({
468 NatGatewayId: natGatewayId,
469 DryRun: false,
470 })
471 .pipe(Effect.catchTag("NatGatewayNotFound", () => Effect.void));
472
473 // Wait for NAT Gateway to be deleted
474 yield* waitForNatGatewayDeleted(natGatewayId, session);
475
476 yield* session.note(`NAT Gateway ${natGatewayId} deleted`);
477 }),
478 };
479 }),
480 );
481
482// Retryable error: NAT Gateway is still pending
483class NatGatewayPending extends Data.TaggedError("NatGatewayPending")<{
484 natGatewayId: string;
485 state: string;
486}> {}
487
488// Terminal error: NAT Gateway creation failed
489class NatGatewayFailed extends Data.TaggedError("NatGatewayFailed")<{
490 natGatewayId: string;
491 failureCode?: string;
492 failureMessage?: string;
493}> {}
494
495// Terminal error: NAT Gateway not found
496class NatGatewayNotFound extends Data.TaggedError("NatGatewayNotFound")<{
497 natGatewayId: string;
498}> {}
499
500/**
501 * Wait for NAT Gateway to be in available state
502 */
503const waitForNatGatewayAvailable = (
504 natGatewayId: string,
505 session: ScopedPlanStatusSession,
506) =>
507 Effect.gen(function* () {
508 const result = yield* ec2.describeNatGateways({
509 NatGatewayIds: [natGatewayId],
510 });
511 const gw = result.NatGateways?.[0];
512
513 if (!gw) {
514 return yield* new NatGatewayNotFound({ natGatewayId });
515 }
516
517 if (gw.State === "available") {
518 return gw;
519 }
520
521 if (gw.State === "failed") {
522 return yield* new NatGatewayFailed({
523 natGatewayId,
524 failureCode: gw.FailureCode,
525 failureMessage: gw.FailureMessage,
526 });
527 }
528
529 // Still pending - this is the only retryable case
530 return yield* new NatGatewayPending({ natGatewayId, state: gw.State! });
531 }).pipe(
532 Effect.tapError(Effect.logDebug),
533 Effect.retry({
534 while: (e) => e._tag === "NatGatewayPending",
535 schedule: Schedule.max([Schedule.fixed(5000), Schedule.recurs(60)]).pipe(
536 Schedule.tap(({ attempt }) =>
537 session.note(
538 `Waiting for NAT Gateway to be available... (${attempt * 5}s)`,
539 ),
540 ),
541 ),
542 }),
543 );
544
545// Retryable error: NAT Gateway is still deleting
546class NatGatewayDeleting extends Data.TaggedError("NatGatewayDeleting")<{
547 natGatewayId: string;
548 state: string;
549}> {}
550
551/**
552 * Wait for NAT Gateway to be deleted
553 */
554const waitForNatGatewayDeleted = (
555 natGatewayId: string,
556 session: ScopedPlanStatusSession,
557) =>
558 Effect.gen(function* () {
559 const result = yield* ec2
560 .describeNatGateways({ NatGatewayIds: [natGatewayId] })
561 .pipe(
562 Effect.catchTag("NatGatewayNotFound", () =>
563 Effect.succeed({ NatGateways: [] }),
564 ),
565 );
566
567 const gw = result.NatGateways?.[0];
568
569 if (!gw || gw.State === "deleted") {
570 return; // Successfully deleted
571 }
572
573 yield* Effect.logDebug(gw);
574
575 // Still deleting - this is the only retryable case
576 return yield* new NatGatewayDeleting({ natGatewayId, state: gw.State! });
577 }).pipe(
578 Effect.tapError(Effect.logDebug),
579 Effect.retry({
580 while: (e) => e._tag === "NatGatewayDeleting",
581 schedule: Schedule.max([Schedule.fixed(5000), Schedule.recurs(60)]).pipe(
582 Schedule.tap(({ attempt }) =>
583 session.note(`Waiting for NAT Gateway deletion... (${attempt * 5}s)`),
584 ),
585 ),
586 }),
587 );