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 { isResolved } from "../../Diff.ts";
7import * as Provider from "../../Provider.ts";
8import { Resource } from "../../Resource.ts";
9import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
10import type { AccountID } from "../Environment.ts";
11import { AWSEnvironment } from "../Environment.ts";
12import type { Providers } from "../Providers.ts";
13import type { RegionID } from "../Region.ts";
14import type { VpcId } from "./Vpc.ts";
15
16export type EgressOnlyInternetGatewayId<ID extends string = string> =
17 `eigw-${ID}`;
18export const EgressOnlyInternetGatewayId = <ID extends string>(
19 id: ID,
20): ID & EgressOnlyInternetGatewayId<ID> =>
21 `eigw-${id}` as ID & EgressOnlyInternetGatewayId<ID>;
22
23export type EgressOnlyInternetGatewayArn<
24 ID extends EgressOnlyInternetGatewayId = EgressOnlyInternetGatewayId,
25> = `arn:aws:ec2:${RegionID}:${AccountID}:egress-only-internet-gateway/${ID}`;
26
27export interface EgressOnlyInternetGatewayProps {
28 /**
29 * The VPC for which to create the egress-only internet gateway.
30 */
31 vpcId: VpcId;
32
33 /**
34 * Tags to assign to the egress-only internet gateway.
35 */
36 tags?: Record<string, string>;
37}
38
39export interface EgressOnlyInternetGateway extends Resource<
40 "AWS.EC2.EgressOnlyInternetGateway",
41 EgressOnlyInternetGatewayProps,
42 {
43 /**
44 * The ID of the egress-only internet gateway.
45 */
46 egressOnlyInternetGatewayId: EgressOnlyInternetGatewayId;
47
48 /**
49 * The Amazon Resource Name (ARN) of the egress-only internet gateway.
50 */
51 egressOnlyInternetGatewayArn: EgressOnlyInternetGatewayArn;
52
53 /**
54 * Information about the attachment of the egress-only internet gateway.
55 */
56 attachments?: Array<{
57 /**
58 * The current state of the attachment.
59 */
60 state: "attaching" | "attached" | "detaching" | "detached";
61 /**
62 * The ID of the VPC.
63 */
64 vpcId: VpcId;
65 }>;
66 },
67 never,
68 Providers
69> {}
70/**
71 * An egress-only internet gateway is the IPv6 counterpart to a NAT gateway: it
72 * lets instances in a VPC initiate outbound IPv6 traffic to the internet while
73 * preventing the internet from initiating inbound connections to them. Use it
74 * to give private, IPv6-addressed resources outbound-only internet access.
75 *
76 * Unlike a NAT gateway it is free, has no bandwidth charges, and does not
77 * require an Elastic IP — but it works for IPv6 only. It always belongs to a
78 * VPC (`vpcId` is required); the gateway must be paired with an IPv6
79 * {@link Route} to actually carry traffic.
80 *
81 * ### Creating an Egress-Only Internet Gateway
82 * The gateway is created and attached to `vpcId` in a single step. Because the
83 * attachment is intrinsic, changing `vpcId` replaces the gateway rather than
84 * moving it.
85 *
86 * **Example:** Basic Egress-Only Internet Gateway
87 * ```typescript
88 * const egressOnlyIgw = yield* AWS.EC2.EgressOnlyInternetGateway("EgressOnlyIgw", {
89 * vpcId: myVpc.vpcId,
90 * });
91 * ```
92 * Creates the gateway in the VPC. The resulting
93 * `egressOnlyInternetGatewayId` (prefixed `eigw-`) is referenced from a
94 * route's `egressOnlyInternetGatewayId` target.
95 *
96 * **Example:** Egress-Only Internet Gateway with Tags
97 * ```typescript
98 * const egressOnlyIgw = yield* AWS.EC2.EgressOnlyInternetGateway("EgressOnlyIgw", {
99 * vpcId: myVpc.vpcId,
100 * tags: { Name: "production-eigw" },
101 * });
102 * ```
103 * The `tags` map is merged with the alchemy auto-tags and can be updated in
104 * place without replacing the gateway.
105 *
106 * ### Routing IPv6 Egress Traffic
107 * A gateway alone does nothing until a private route table sends IPv6 traffic
108 * to it. Pair it with a `::/0` {@link Route} so private, IPv6-addressed
109 * instances can reach the internet outbound-only.
110 *
111 * **Example:** IPv6 Default Route to the Egress-Only Gateway
112 * ```typescript
113 * const egressOnlyIgw = yield* AWS.EC2.EgressOnlyInternetGateway("EgressOnlyIgw", {
114 * vpcId: myVpc.vpcId,
115 * });
116 *
117 * const ipv6EgressRoute = yield* AWS.EC2.Route("Ipv6EgressRoute", {
118 * routeTableId: privateRouteTable.routeTableId,
119 * destinationIpv6CidrBlock: "::/0",
120 * egressOnlyInternetGatewayId: egressOnlyIgw.egressOnlyInternetGatewayId,
121 * });
122 * ```
123 * Instances in subnets associated with `privateRouteTable` can now make
124 * outbound IPv6 connections (updates, API calls) while remaining unreachable
125 * from the public internet.
126 *
127 * @resource
128 */
129export const EgressOnlyInternetGateway = Resource<EgressOnlyInternetGateway>(
130 "AWS.EC2.EgressOnlyInternetGateway",
131);
132
133/** `DeleteEgressOnlyInternetGateway` returned `ReturnCode: false` (no error). */
134class EigwDeleteFailed extends Data.TaggedError("EigwDeleteFailed")<{
135 readonly eigwId: string;
136}> {}
137
138/** The gateway is still observable after a successful delete call. */
139class EigwStillExists extends Data.TaggedError("EigwStillExists")<{
140 readonly eigwId: string;
141}> {}
142
143export const EgressOnlyInternetGatewayProvider = () =>
144 Provider.effect(
145 EgressOnlyInternetGateway,
146 Effect.gen(function* () {
147 const createTags = Effect.fn(function* (
148 id: string,
149 tags?: Record<string, string>,
150 ) {
151 return {
152 Name: id,
153 ...(yield* createInternalTags(id)),
154 ...tags,
155 };
156 });
157
158 const describeEgressOnlyInternetGateway = (eigwId: string) =>
159 ec2
160 .describeEgressOnlyInternetGateways({
161 EgressOnlyInternetGatewayIds: [eigwId],
162 })
163 .pipe(
164 Effect.map((r) => r.EgressOnlyInternetGateways?.[0]),
165 Effect.flatMap((gw) =>
166 gw
167 ? Effect.succeed(gw)
168 : Effect.fail(
169 new Error(
170 `Egress-Only Internet Gateway ${eigwId} not found`,
171 ),
172 ),
173 ),
174 );
175
176 const toAttrs = (gw: ec2.EgressOnlyInternetGateway) =>
177 AWSEnvironment.current.pipe(
178 Effect.map((env) => ({
179 egressOnlyInternetGatewayId:
180 gw.EgressOnlyInternetGatewayId as EgressOnlyInternetGatewayId,
181 egressOnlyInternetGatewayArn:
182 `arn:aws:ec2:${env.region}:${env.accountId}:egress-only-internet-gateway/${gw.EgressOnlyInternetGatewayId}` as EgressOnlyInternetGatewayArn,
183 attachments: gw.Attachments?.map((a) => ({
184 state: a.State as
185 | "attaching"
186 | "attached"
187 | "detaching"
188 | "detached",
189 vpcId: a.VpcId as VpcId,
190 })),
191 })),
192 );
193
194 return {
195 stables: [
196 "egressOnlyInternetGatewayId",
197 "egressOnlyInternetGatewayArn",
198 ],
199
200 list: () =>
201 Effect.gen(function* () {
202 const env = yield* AWSEnvironment.current;
203 const items = yield* ec2.describeEgressOnlyInternetGateways
204 .pages({})
205 .pipe(
206 Stream.runCollect,
207 Effect.map((chunk) =>
208 Array.from(chunk).flatMap((page) =>
209 (page.EgressOnlyInternetGateways ?? [])
210 .filter(
211 (
212 gw,
213 ): gw is ec2.EgressOnlyInternetGateway & {
214 EgressOnlyInternetGatewayId: string;
215 } => gw.EgressOnlyInternetGatewayId != null,
216 )
217 .map((gw) => ({
218 egressOnlyInternetGatewayId:
219 gw.EgressOnlyInternetGatewayId as EgressOnlyInternetGatewayId,
220 egressOnlyInternetGatewayArn:
221 `arn:aws:ec2:${env.region}:${env.accountId}:egress-only-internet-gateway/${gw.EgressOnlyInternetGatewayId}` as EgressOnlyInternetGatewayArn,
222 attachments: gw.Attachments?.map((a) => ({
223 state: a.State as
224 | "attaching"
225 | "attached"
226 | "detaching"
227 | "detached",
228 vpcId: a.VpcId as VpcId,
229 })),
230 })),
231 ),
232 ),
233 );
234 return items satisfies EgressOnlyInternetGateway["Attributes"][];
235 }),
236
237 read: Effect.fn(function* ({ output }) {
238 if (!output) return undefined;
239 const gw = yield* describeEgressOnlyInternetGateway(
240 output.egressOnlyInternetGatewayId,
241 );
242 return yield* toAttrs(gw);
243 }),
244
245 diff: Effect.fn(function* ({ news, olds }) {
246 if (!isResolved(news)) return;
247 // VPC change requires replacement
248 if (news.vpcId !== olds.vpcId) {
249 return { action: "replace" };
250 }
251 // Tags can be updated in-place
252 }),
253
254 reconcile: Effect.fn(function* ({ id, news, output, session }) {
255 const desiredTags = yield* createTags(id, news.tags);
256
257 // Observe — find the EIGW via cached id, else fall through to create.
258 let gw: ec2.EgressOnlyInternetGateway | undefined;
259 if (output?.egressOnlyInternetGatewayId) {
260 const lookup = yield* ec2
261 .describeEgressOnlyInternetGateways({
262 EgressOnlyInternetGatewayIds: [
263 output.egressOnlyInternetGatewayId,
264 ],
265 })
266 .pipe(
267 Effect.catchTag(
268 "InvalidEgressOnlyInternetGatewayId.NotFound",
269 () => Effect.succeed({ EgressOnlyInternetGateways: [] }),
270 ),
271 );
272 gw = lookup.EgressOnlyInternetGateways?.[0];
273 }
274
275 // Ensure — create the EIGW if it isn't there yet.
276 if (gw === undefined) {
277 yield* session.note("Creating Egress-Only Internet Gateway...");
278 const result = yield* ec2.createEgressOnlyInternetGateway({
279 VpcId: news.vpcId as string,
280 TagSpecifications: [
281 {
282 ResourceType: "egress-only-internet-gateway",
283 Tags: createTagsList(desiredTags),
284 },
285 ],
286 DryRun: false,
287 });
288 const newGwId =
289 result.EgressOnlyInternetGateway!.EgressOnlyInternetGatewayId!;
290 yield* session.note(
291 `Egress-Only Internet Gateway created: ${newGwId}`,
292 );
293 gw = yield* describeEgressOnlyInternetGateway(newGwId);
294 }
295
296 const eigwId = gw.EgressOnlyInternetGatewayId!;
297
298 // Sync tags — observed cloud tags vs desired.
299 const currentTags =
300 (yield* ec2
301 .describeTags({
302 Filters: [
303 { Name: "resource-id", Values: [eigwId] },
304 {
305 Name: "resource-type",
306 Values: ["egress-only-internet-gateway"],
307 },
308 ],
309 })
310 .pipe(
311 Effect.map(
312 (r) =>
313 Object.fromEntries(
314 r.Tags?.map((t) => [t.Key!, t.Value!]) ?? [],
315 ) as Record<string, string>,
316 ),
317 )) ?? {};
318 const { removed, upsert } = diffTags(currentTags, desiredTags);
319 if (removed.length > 0) {
320 yield* ec2.deleteTags({
321 Resources: [eigwId],
322 Tags: removed.map((key) => ({ Key: key })),
323 DryRun: false,
324 });
325 }
326 if (upsert.length > 0) {
327 yield* ec2.createTags({
328 Resources: [eigwId],
329 Tags: upsert,
330 DryRun: false,
331 });
332 }
333
334 // Re-read to reflect current cloud state in the returned attributes.
335 const final = yield* describeEgressOnlyInternetGateway(eigwId);
336 return yield* toAttrs(final);
337 }),
338
339 delete: Effect.fn(function* ({ output, session }) {
340 const eigwId = output.egressOnlyInternetGatewayId;
341
342 yield* session.note(
343 `Deleting Egress-Only Internet Gateway: ${eigwId}`,
344 );
345
346 yield* ec2
347 .deleteEgressOnlyInternetGateway({
348 EgressOnlyInternetGatewayId: eigwId,
349 DryRun: false,
350 })
351 .pipe(
352 Effect.tapError(Effect.logDebug),
353 // `DeleteEgressOnlyInternetGateway` reports failure as
354 // `ReturnCode: false` with NO error. Treating that as success
355 // orphans the gateway: the engine drops the state row while the
356 // gateway stays attached, wedging the parent VPC's delete on
357 // DependencyViolation forever. Surface it so the retry below
358 // re-drives the call (and the delete fails loudly if it never
359 // takes).
360 Effect.flatMap((result) =>
361 result.ReturnCode === false
362 ? Effect.fail(new EigwDeleteFailed({ eigwId }))
363 : Effect.void,
364 ),
365 Effect.catchTag("InvalidGatewayID.NotFound", () => Effect.void),
366 Effect.catchTag(
367 "InvalidEgressOnlyInternetGatewayId.NotFound",
368 () => Effect.void,
369 ),
370 // Retry on dependency violations (e.g., routes still using the EIGW)
371 Effect.retry({
372 while: (e) =>
373 e._tag === "DependencyViolation" ||
374 e._tag === "EigwDeleteFailed",
375 schedule: Schedule.max([
376 Schedule.fixed(5000),
377 Schedule.recurs(30),
378 ]).pipe(
379 Schedule.tap(({ attempt }) =>
380 session.note(
381 `Waiting for dependencies to clear... (attempt ${attempt})`,
382 ),
383 ),
384 ),
385 }),
386 );
387
388 // Deletion is asynchronous: wait until the gateway is actually gone
389 // before reporting success, so the parent VPC's delete (which the
390 // engine runs next) never races a still-attached gateway.
391 yield* ec2
392 .describeEgressOnlyInternetGateways({
393 EgressOnlyInternetGatewayIds: [eigwId],
394 })
395 .pipe(
396 Effect.flatMap((r) =>
397 (r.EgressOnlyInternetGateways ?? []).length === 0
398 ? Effect.void
399 : Effect.fail(new EigwStillExists({ eigwId })),
400 ),
401 Effect.retry({
402 while: (e) => e._tag === "EigwStillExists",
403 schedule: Schedule.max([
404 Schedule.fixed(2000),
405 Schedule.recurs(15),
406 ]),
407 }),
408 Effect.catchTag(
409 "InvalidEgressOnlyInternetGatewayId.NotFound",
410 () => Effect.void,
411 ),
412 );
413
414 yield* session.note(`Egress-Only Internet Gateway ${eigwId} deleted`);
415 }),
416 };
417 }),
418 );