1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6import type { ScopedPlanStatusSession } from "../../Report.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import {
10 createAlchemyTagFilters,
11 createInternalTags,
12 createTagsList,
13 diffTags,
14} from "../../Tags.ts";
15import { AWSEnvironment, type AccountID } from "../Environment.ts";
16import type { Providers } from "../Providers.ts";
17import type { RegionID } from "../Region.ts";
18import { getDefaultVpcScope } from "./defaultVpcScope.ts";
19import { retryWhileLingeringEnis } from "./LingeringEnis.ts";
20import type { VpcId } from "./Vpc.ts";
21
22export type InternetGatewayId<ID extends string = string> = `igw-${ID}`;
23export const InternetGatewayId = <ID extends string>(
24 id: ID,
25): ID & InternetGatewayId<ID> => `igw-${id}` as ID & InternetGatewayId<ID>;
26
27export interface InternetGatewayProps {
28 /**
29 * The VPC to attach the internet gateway to.
30 * If provided, the internet gateway will be automatically attached to the VPC.
31 * Optional - you can create an unattached internet gateway and attach it later.
32 */
33 vpcId?: VpcId;
34
35 /**
36 * Tags to assign to the internet gateway.
37 * These will be merged with alchemy auto-tags (alchemy::stack, alchemy::stage, alchemy::id).
38 */
39 tags?: Record<string, string>;
40}
41
42export interface InternetGateway extends Resource<
43 "AWS.EC2.InternetGateway",
44 InternetGatewayProps,
45 {
46 /**
47 * The ID of the internet gateway.
48 */
49 internetGatewayId: InternetGatewayId;
50 /**
51 * The Amazon Resource Name (ARN) of the internet gateway.
52 */
53 internetGatewayArn: `arn:aws:ec2:${RegionID}:${AccountID}:internet-gateway/${string}`;
54 /**
55 * The ID of the VPC the internet gateway is attached to (if any).
56 */
57 vpcId?: VpcId;
58 /**
59 * The ID of the AWS account that owns the internet gateway.
60 */
61 ownerId?: string;
62 /**
63 * The attachments for the internet gateway.
64 */
65 attachments?: Array<{
66 state: "attaching" | "available" | "detaching" | "detached";
67 vpcId: string;
68 }>;
69 },
70 never,
71 Providers
72> {}
73/**
74 * An internet gateway provides a target for internet-routable traffic in a
75 * VPC, enabling bidirectional IPv4 and IPv6 connectivity between resources in
76 * your VPC and the public internet. A VPC can have at most one internet
77 * gateway attached at a time.
78 *
79 * The only inputs are the optional `vpcId` to attach to and `tags`. Attaching a
80 * gateway is not enough on its own to make a subnet public — you also need a
81 * `0.0.0.0/0` {@link Route} pointing at the gateway and a
82 * {@link RouteTableAssociation} binding the subnet to that route table.
83 *
84 * ### Creating an Internet Gateway
85 * Pass `vpcId` to create and attach the gateway in one step, or omit it to
86 * create a standalone gateway and attach it later by setting the prop. Updating
87 * `vpcId` moves the gateway between VPCs (detach then attach) without
88 * recreating it.
89 *
90 * **Example:** Internet Gateway Attached to a VPC
91 * ```typescript
92 * const internetGateway = yield* AWS.EC2.InternetGateway("InternetGateway", {
93 * vpcId: myVpc.vpcId,
94 * });
95 * ```
96 * Creates the gateway and attaches it to the VPC immediately. The resulting
97 * `internetGatewayId` (prefixed `igw-`) is what you reference from a route's
98 * `gatewayId`.
99 *
100 * **Example:** Detached Internet Gateway
101 * ```typescript
102 * const internetGateway = yield* AWS.EC2.InternetGateway("InternetGateway", {});
103 * ```
104 * Omitting `vpcId` creates an unattached gateway. This is occasionally useful
105 * when the VPC is provisioned separately; add the `vpcId` prop later to attach
106 * it.
107 *
108 * **Example:** Internet Gateway with Tags
109 * ```typescript
110 * const internetGateway = yield* AWS.EC2.InternetGateway("InternetGateway", {
111 * vpcId: myVpc.vpcId,
112 * tags: { Name: "production-igw" },
113 * });
114 * ```
115 * The `tags` map is merged with the alchemy auto-tags and can be changed in
116 * place. A `Name` tag makes the gateway easy to identify in the AWS console.
117 *
118 * ### Enabling Public Internet Access
119 * An internet gateway only carries traffic once a route table sends traffic to
120 * it and a subnet is associated with that table. The full pattern below makes a
121 * subnet public.
122 *
123 * **Example:** Internet Gateway with a Default Route
124 * ```typescript
125 * const internetGateway = yield* AWS.EC2.InternetGateway("InternetGateway", {
126 * vpcId: myVpc.vpcId,
127 * });
128 *
129 * const publicRouteTable = yield* AWS.EC2.RouteTable("PublicRouteTable", {
130 * vpcId: myVpc.vpcId,
131 * });
132 *
133 * const internetRoute = yield* AWS.EC2.Route("InternetRoute", {
134 * routeTableId: publicRouteTable.routeTableId,
135 * destinationCidrBlock: "0.0.0.0/0",
136 * gatewayId: internetGateway.internetGatewayId,
137 * });
138 * ```
139 * With the default route in place, any subnet associated with
140 * `publicRouteTable` can send and receive internet traffic. Add an analogous
141 * route with `destinationIpv6CidrBlock: "::/0"` to enable IPv6.
142 *
143 * @resource
144 */
145export const InternetGateway = Resource<InternetGateway>(
146 "AWS.EC2.InternetGateway",
147);
148
149export const InternetGatewayProvider = () =>
150 Provider.effect(
151 InternetGateway,
152 Effect.gen(function* () {
153 const findInternetGatewayByTags = Effect.fn(function* (id: string) {
154 const filters = yield* createAlchemyTagFilters(id);
155 const pages = yield* ec2.describeInternetGateways
156 .pages({ Filters: filters })
157 .pipe(Stream.runCollect);
158 return Array.from(pages)
159 .flatMap((page) => page.InternetGateways ?? [])
160 .find((candidate) => candidate.InternetGatewayId !== undefined);
161 });
162
163 return {
164 stables: ["internetGatewayId", "internetGatewayArn", "ownerId"],
165
166 list: () =>
167 Effect.gen(function* () {
168 const { accountId, region } = yield* AWSEnvironment.current;
169 // The default VPC's internet gateway is account furniture AWS
170 // provisions; never census/nuke it.
171 const defaultVpc = yield* getDefaultVpcScope;
172 return yield* ec2.describeInternetGateways.pages({}).pipe(
173 Stream.runCollect,
174 Effect.map((chunk) =>
175 Array.from(chunk).flatMap((page) =>
176 (page.InternetGateways ?? [])
177 .filter(
178 (
179 igw,
180 ): igw is ec2.InternetGateway & {
181 InternetGatewayId: string;
182 } => igw.InternetGatewayId != null,
183 )
184 .filter(
185 (igw) =>
186 defaultVpc.vpcId === undefined ||
187 !igw.Attachments?.some(
188 (a) => a.VpcId === defaultVpc.vpcId,
189 ),
190 )
191 .map((igw) => {
192 const internetGatewayId =
193 igw.InternetGatewayId as InternetGatewayId;
194 const attachedVpcId = igw.Attachments?.find(
195 (a) =>
196 a.State === "available" || a.State === "attaching",
197 )?.VpcId as VpcId | undefined;
198 return {
199 internetGatewayId,
200 internetGatewayArn:
201 `arn:aws:ec2:${region}:${accountId}:internet-gateway/${internetGatewayId}` as const,
202 vpcId: attachedVpcId,
203 ownerId: igw.OwnerId,
204 attachments: igw.Attachments?.map((a) => ({
205 state: a.State! as
206 | "attaching"
207 | "available"
208 | "detaching"
209 | "detached",
210 vpcId: a.VpcId!,
211 })),
212 };
213 }),
214 ),
215 ),
216 );
217 }),
218
219 reconcile: Effect.fn(function* ({
220 id,
221 instanceId,
222 news = {},
223 output,
224 session,
225 }) {
226 const { accountId, region } = yield* AWSEnvironment.current;
227 const alchemyTags = yield* createInternalTags(id);
228 const desiredTags = {
229 ...news.tags,
230 ...alchemyTags,
231 "alchemy::instance": instanceId,
232 };
233
234 // Observe — find the IGW via cached id, else fall through to create.
235 let igw: ec2.InternetGateway | undefined;
236 if (output?.internetGatewayId) {
237 const lookup = yield* ec2
238 .describeInternetGateways({
239 InternetGatewayIds: [output.internetGatewayId],
240 })
241 .pipe(
242 Effect.catchTag("InvalidInternetGatewayID.NotFound", () =>
243 Effect.succeed({ InternetGateways: [] }),
244 ),
245 );
246 igw = lookup.InternetGateways?.[0];
247 }
248 if (igw === undefined) {
249 // CreateInternetGateway has no idempotency token and this provider
250 // never replaces an IGW. Recover a create that succeeded before
251 // state persistence by stable stack/stage/logical-id ownership.
252 // Older interrupted creates predate the instance tag, so requiring
253 // it here would strand exactly the resources recovery must adopt.
254 igw = yield* findInternetGatewayByTags(id);
255 }
256
257 // Ensure — create the IGW if missing.
258 if (igw === undefined) {
259 const createResult = yield* ec2.createInternetGateway({
260 TagSpecifications: [
261 {
262 ResourceType: "internet-gateway",
263 Tags: createTagsList(desiredTags),
264 },
265 ],
266 DryRun: false,
267 });
268 const newIgwId = createResult.InternetGateway!
269 .InternetGatewayId! as InternetGatewayId;
270 yield* session.note(`Internet gateway created: ${newIgwId}`);
271 // EC2 can return the new ID before DescribeInternetGateways sees
272 // it. Retain and poll that ID rather than failing the reconcile
273 // after creation and losing the cleanup handle.
274 igw = yield* describeInternetGateway(newIgwId);
275 }
276
277 const internetGatewayId = igw.InternetGatewayId! as InternetGatewayId;
278
279 // Sync VPC attachment — observed attachment vs desired.
280 const attachedVpcId = igw.Attachments?.find(
281 (a) => a.State === "available" || a.State === "attaching",
282 )?.VpcId;
283 if (attachedVpcId !== news.vpcId) {
284 if (attachedVpcId) {
285 yield* ec2
286 .detachInternetGateway({
287 InternetGatewayId: internetGatewayId,
288 VpcId: attachedVpcId,
289 })
290 .pipe(
291 Effect.catchTag("Gateway.NotAttached", () => Effect.void),
292 );
293 yield* session.note(`Detached from VPC: ${attachedVpcId}`);
294 }
295 if (news.vpcId) {
296 yield* ec2
297 .attachInternetGateway({
298 InternetGatewayId: internetGatewayId,
299 VpcId: news.vpcId,
300 })
301 .pipe(
302 Effect.retry({
303 while: (e) =>
304 e._tag === "InvalidVpcID.NotFound" ||
305 e._tag === "InvalidInternetGatewayID.NotFound",
306 schedule: Schedule.max([
307 Schedule.fixed(500),
308 Schedule.recurs(10),
309 ]),
310 }),
311 );
312 yield* session.note(`Attached to VPC: ${news.vpcId}`);
313 }
314 }
315 if (news.vpcId) {
316 // AttachInternetGateway is asynchronous. Also check this when the
317 // observed attachment was already `attaching`, as can happen when
318 // a reconcile resumes after interruption. Do not publish outputs
319 // to a dependent Route until AWS reports it as available.
320 igw = yield* waitForInternetGatewayAttached(
321 internetGatewayId,
322 news.vpcId,
323 session,
324 );
325 }
326
327 // Sync tags — observed cloud tags vs desired.
328 const currentTags = Object.fromEntries(
329 (igw.Tags ?? []).map((t) => [t.Key!, t.Value!]),
330 ) as Record<string, string>;
331 const { removed, upsert } = diffTags(currentTags, desiredTags);
332 if (removed.length > 0) {
333 yield* ec2.deleteTags({
334 Resources: [internetGatewayId],
335 Tags: removed.map((key) => ({ Key: key })),
336 });
337 }
338 if (upsert.length > 0) {
339 yield* ec2.createTags({
340 Resources: [internetGatewayId],
341 Tags: upsert,
342 });
343 }
344
345 // Re-read final state.
346 const final = yield* describeInternetGateway(internetGatewayId);
347 return {
348 internetGatewayId,
349 internetGatewayArn: `arn:aws:ec2:${region}:${accountId}:internet-gateway/${internetGatewayId}`,
350 vpcId: news.vpcId,
351 ownerId: final.OwnerId,
352 attachments: final.Attachments?.map((a) => ({
353 state: a.State! as
354 | "attaching"
355 | "available"
356 | "detaching"
357 | "detached",
358 vpcId: a.VpcId!,
359 })),
360 };
361 }),
362
363 delete: Effect.fn(function* ({ output, session }) {
364 const internetGatewayId = output.internetGatewayId;
365
366 yield* session.note(
367 `Deleting internet gateway: ${internetGatewayId}`,
368 );
369
370 // Re-describe to get current attachments from AWS (don't rely on stored state)
371 // This handles cases where state is incomplete from a previous crashed run
372 const igw = yield* findInternetGateway(internetGatewayId);
373 if (igw === undefined) {
374 yield* session.note(
375 `Internet gateway ${internetGatewayId} is already absent`,
376 );
377 return;
378 }
379 const attachments = igw.Attachments ?? [];
380
381 // 1. Detach from all VPCs first. DetachInternetGateway fails with
382 // DependencyViolation ("has some mapped public address(es)") while
383 // any ENI in the VPC still holds a public IP — a just-deleted VPC
384 // Lambda's Hyperplane ENIs or draining Fargate task ENIs can hold
385 // theirs for 5-20 minutes after the owner is gone. Ride that
386 // window out on the same budget the subnet delete uses, reaping
387 // detached Lambda ENIs between attempts to accelerate the release.
388 if (attachments.length > 0) {
389 for (const attachment of attachments) {
390 yield* retryWhileLingeringEnis(
391 ec2
392 .detachInternetGateway({
393 InternetGatewayId: internetGatewayId,
394 VpcId: attachment.VpcId!,
395 })
396 .pipe(
397 Effect.tapError(Effect.logDebug),
398 Effect.catchTag("Gateway.NotAttached", () => Effect.void),
399 Effect.catchTag(
400 "InvalidInternetGatewayID.NotFound",
401 () => Effect.void,
402 ),
403 ),
404 {
405 scope: { name: "vpc-id", value: attachment.VpcId! },
406 isDependencyViolation: (e) =>
407 e._tag === "DependencyViolation",
408 session,
409 },
410 );
411 yield* session.note(`Detached from VPC: ${attachment.VpcId}`);
412 }
413 }
414
415 // 2. Delete the internet gateway
416 yield* ec2
417 .deleteInternetGateway({
418 InternetGatewayId: internetGatewayId,
419 DryRun: false,
420 })
421 .pipe(
422 Effect.tapError(Effect.logDebug),
423 Effect.catchTag(
424 "InvalidInternetGatewayID.NotFound",
425 () => Effect.void,
426 ),
427 // Retry on dependency violations. Public addresses on a
428 // draining EKS/HyperPod control plane's ENIs can take
429 // several minutes to release — 5s x 60 = ~5 min.
430 Effect.retry({
431 while: (e) => {
432 return (
433 e._tag === "DependencyViolation" ||
434 (e._tag === "ValidationError" &&
435 e.message?.includes("DependencyViolation"))
436 );
437 },
438 schedule: Schedule.max([
439 Schedule.fixed(5000),
440 Schedule.recurs(60),
441 ]).pipe(
442 Schedule.tap(({ attempt }) =>
443 session.note(
444 `Waiting for dependencies to clear... (attempt ${attempt})`,
445 ),
446 ),
447 ),
448 }),
449 );
450
451 // 3. Wait for internet gateway to be fully deleted
452 yield* waitForInternetGatewayDeleted(internetGatewayId, session);
453
454 yield* session.note(
455 `Internet gateway ${internetGatewayId} deleted successfully`,
456 );
457 }),
458 };
459 }),
460 );
461
462/**
463 * Describe an internet gateway by ID
464 */
465const findInternetGateway = (internetGatewayId: string) =>
466 Effect.gen(function* () {
467 const result = yield* ec2
468 .describeInternetGateways({ InternetGatewayIds: [internetGatewayId] })
469 .pipe(
470 Effect.catchTag("InvalidInternetGatewayID.NotFound", () =>
471 Effect.succeed({ InternetGateways: [] }),
472 ),
473 );
474
475 return result.InternetGateways?.[0];
476 });
477
478const describeInternetGateway = (internetGatewayId: string) =>
479 findInternetGateway(internetGatewayId).pipe(
480 Effect.flatMap((igw) =>
481 igw
482 ? Effect.succeed(igw)
483 : Effect.fail(new InternetGatewayNotVisible({ internetGatewayId })),
484 ),
485 Effect.retry({
486 while: (error) => error instanceof InternetGatewayNotVisible,
487 schedule: Schedule.max([Schedule.fixed(500), Schedule.recurs(10)]),
488 }),
489 );
490
491class InternetGatewayNotVisible extends Data.TaggedError(
492 "InternetGatewayNotVisible",
493)<{
494 internetGatewayId: string;
495}> {}
496
497class InternetGatewayAttachmentPending extends Data.TaggedError(
498 "InternetGatewayAttachmentPending",
499)<{
500 internetGatewayId: string;
501 vpcId: string;
502 state: string;
503}> {}
504
505const waitForInternetGatewayAttached = (
506 internetGatewayId: string,
507 vpcId: string,
508 session: ScopedPlanStatusSession,
509) =>
510 Effect.gen(function* () {
511 const igw = yield* describeInternetGateway(internetGatewayId);
512 const attachment = igw.Attachments?.find(
513 (candidate) => candidate.VpcId === vpcId,
514 );
515 if (attachment?.State === "available") {
516 return igw;
517 }
518 return yield* new InternetGatewayAttachmentPending({
519 internetGatewayId,
520 vpcId,
521 state: attachment?.State ?? "missing",
522 });
523 }).pipe(
524 Effect.retry({
525 while: (error) => error instanceof InternetGatewayAttachmentPending,
526 schedule: Schedule.max([Schedule.fixed(1000), Schedule.recurs(30)]).pipe(
527 Schedule.tap(({ attempt }) =>
528 session.note(
529 `Waiting for internet gateway attachment to become available... (${attempt}s)`,
530 ),
531 ),
532 ),
533 }),
534 );
535
536/**
537 * Wait for internet gateway to be deleted
538 */
539const waitForInternetGatewayDeleted = (
540 internetGatewayId: string,
541 session: ScopedPlanStatusSession,
542) =>
543 Effect.gen(function* () {
544 yield* Effect.retry(
545 Effect.gen(function* () {
546 const result = yield* ec2
547 .describeInternetGateways({ InternetGatewayIds: [internetGatewayId] })
548 .pipe(
549 Effect.tapError(Effect.logDebug),
550 Effect.catchTag("InvalidInternetGatewayID.NotFound", () =>
551 Effect.succeed({ InternetGateways: [] }),
552 ),
553 );
554
555 if (!result.InternetGateways || result.InternetGateways.length === 0) {
556 return; // Successfully deleted
557 }
558
559 // Still exists, fail to trigger retry
560 return yield* Effect.fail(new Error("Internet gateway still exists"));
561 }),
562 {
563 schedule: Schedule.max([
564 Schedule.fixed(2000),
565 Schedule.recurs(15),
566 ]).pipe(
567 Schedule.tap(({ attempt }) =>
568 session.note(
569 `Waiting for internet gateway deletion... (${attempt * 2}s)`,
570 ),
571 ),
572 ),
573 },
574 );
575 });