import * as ec2 from "@distilled.cloud/aws/ec2"; import * as Data from "effect/Data"; import * as Effect from "effect/Effect"; import * as Schedule from "effect/Schedule"; import * as Stream from "effect/Stream"; import { isResolved } from "../../Diff.ts"; import * as Provider from "../../Provider.ts"; import { Resource } from "../../Resource.ts"; import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts"; import type { AccountID } from "../Environment.ts"; import { AWSEnvironment } from "../Environment.ts"; import type { Providers } from "../Providers.ts"; import type { RegionID } from "../Region.ts"; import type { VpcId } from "./Vpc.ts"; export type EgressOnlyInternetGatewayId = `eigw-${ID}`; export const EgressOnlyInternetGatewayId = ( id: ID, ): ID & EgressOnlyInternetGatewayId => `eigw-${id}` as ID & EgressOnlyInternetGatewayId; export type EgressOnlyInternetGatewayArn< ID extends EgressOnlyInternetGatewayId = EgressOnlyInternetGatewayId, > = `arn:aws:ec2:${RegionID}:${AccountID}:egress-only-internet-gateway/${ID}`; export interface EgressOnlyInternetGatewayProps { /** * The VPC for which to create the egress-only internet gateway. */ vpcId: VpcId; /** * Tags to assign to the egress-only internet gateway. */ tags?: Record; } export interface EgressOnlyInternetGateway extends Resource< "AWS.EC2.EgressOnlyInternetGateway", EgressOnlyInternetGatewayProps, { /** * The ID of the egress-only internet gateway. */ egressOnlyInternetGatewayId: EgressOnlyInternetGatewayId; /** * The Amazon Resource Name (ARN) of the egress-only internet gateway. */ egressOnlyInternetGatewayArn: EgressOnlyInternetGatewayArn; /** * Information about the attachment of the egress-only internet gateway. */ attachments?: Array<{ /** * The current state of the attachment. */ state: "attaching" | "attached" | "detaching" | "detached"; /** * The ID of the VPC. */ vpcId: VpcId; }>; }, never, Providers > {} /** * An egress-only internet gateway is the IPv6 counterpart to a NAT gateway: it * lets instances in a VPC initiate outbound IPv6 traffic to the internet while * preventing the internet from initiating inbound connections to them. Use it * to give private, IPv6-addressed resources outbound-only internet access. * * Unlike a NAT gateway it is free, has no bandwidth charges, and does not * require an Elastic IP — but it works for IPv6 only. It always belongs to a * VPC (`vpcId` is required); the gateway must be paired with an IPv6 * {@link Route} to actually carry traffic. * * ### Creating an Egress-Only Internet Gateway * The gateway is created and attached to `vpcId` in a single step. Because the * attachment is intrinsic, changing `vpcId` replaces the gateway rather than * moving it. * * **Example:** Basic Egress-Only Internet Gateway * ```typescript * const egressOnlyIgw = yield* AWS.EC2.EgressOnlyInternetGateway("EgressOnlyIgw", { * vpcId: myVpc.vpcId, * }); * ``` * Creates the gateway in the VPC. The resulting * `egressOnlyInternetGatewayId` (prefixed `eigw-`) is referenced from a * route's `egressOnlyInternetGatewayId` target. * * **Example:** Egress-Only Internet Gateway with Tags * ```typescript * const egressOnlyIgw = yield* AWS.EC2.EgressOnlyInternetGateway("EgressOnlyIgw", { * vpcId: myVpc.vpcId, * tags: { Name: "production-eigw" }, * }); * ``` * The `tags` map is merged with the alchemy auto-tags and can be updated in * place without replacing the gateway. * * ### Routing IPv6 Egress Traffic * A gateway alone does nothing until a private route table sends IPv6 traffic * to it. Pair it with a `::/0` {@link Route} so private, IPv6-addressed * instances can reach the internet outbound-only. * * **Example:** IPv6 Default Route to the Egress-Only Gateway * ```typescript * const egressOnlyIgw = yield* AWS.EC2.EgressOnlyInternetGateway("EgressOnlyIgw", { * vpcId: myVpc.vpcId, * }); * * const ipv6EgressRoute = yield* AWS.EC2.Route("Ipv6EgressRoute", { * routeTableId: privateRouteTable.routeTableId, * destinationIpv6CidrBlock: "::/0", * egressOnlyInternetGatewayId: egressOnlyIgw.egressOnlyInternetGatewayId, * }); * ``` * Instances in subnets associated with `privateRouteTable` can now make * outbound IPv6 connections (updates, API calls) while remaining unreachable * from the public internet. * * @resource */ export const EgressOnlyInternetGateway = Resource( "AWS.EC2.EgressOnlyInternetGateway", ); /** `DeleteEgressOnlyInternetGateway` returned `ReturnCode: false` (no error). */ class EigwDeleteFailed extends Data.TaggedError("EigwDeleteFailed")<{ readonly eigwId: string; }> {} /** The gateway is still observable after a successful delete call. */ class EigwStillExists extends Data.TaggedError("EigwStillExists")<{ readonly eigwId: string; }> {} export const EgressOnlyInternetGatewayProvider = () => Provider.effect( EgressOnlyInternetGateway, Effect.gen(function* () { const createTags = Effect.fn(function* ( id: string, tags?: Record, ) { return { Name: id, ...(yield* createInternalTags(id)), ...tags, }; }); const describeEgressOnlyInternetGateway = (eigwId: string) => ec2 .describeEgressOnlyInternetGateways({ EgressOnlyInternetGatewayIds: [eigwId], }) .pipe( Effect.map((r) => r.EgressOnlyInternetGateways?.[0]), Effect.flatMap((gw) => gw ? Effect.succeed(gw) : Effect.fail( new Error( `Egress-Only Internet Gateway ${eigwId} not found`, ), ), ), ); const toAttrs = (gw: ec2.EgressOnlyInternetGateway) => AWSEnvironment.current.pipe( Effect.map((env) => ({ egressOnlyInternetGatewayId: gw.EgressOnlyInternetGatewayId as EgressOnlyInternetGatewayId, egressOnlyInternetGatewayArn: `arn:aws:ec2:${env.region}:${env.accountId}:egress-only-internet-gateway/${gw.EgressOnlyInternetGatewayId}` as EgressOnlyInternetGatewayArn, attachments: gw.Attachments?.map((a) => ({ state: a.State as | "attaching" | "attached" | "detaching" | "detached", vpcId: a.VpcId as VpcId, })), })), ); return { stables: [ "egressOnlyInternetGatewayId", "egressOnlyInternetGatewayArn", ], list: () => Effect.gen(function* () { const env = yield* AWSEnvironment.current; const items = yield* ec2.describeEgressOnlyInternetGateways .pages({}) .pipe( Stream.runCollect, Effect.map((chunk) => Array.from(chunk).flatMap((page) => (page.EgressOnlyInternetGateways ?? []) .filter( ( gw, ): gw is ec2.EgressOnlyInternetGateway & { EgressOnlyInternetGatewayId: string; } => gw.EgressOnlyInternetGatewayId != null, ) .map((gw) => ({ egressOnlyInternetGatewayId: gw.EgressOnlyInternetGatewayId as EgressOnlyInternetGatewayId, egressOnlyInternetGatewayArn: `arn:aws:ec2:${env.region}:${env.accountId}:egress-only-internet-gateway/${gw.EgressOnlyInternetGatewayId}` as EgressOnlyInternetGatewayArn, attachments: gw.Attachments?.map((a) => ({ state: a.State as | "attaching" | "attached" | "detaching" | "detached", vpcId: a.VpcId as VpcId, })), })), ), ), ); return items satisfies EgressOnlyInternetGateway["Attributes"][]; }), read: Effect.fn(function* ({ output }) { if (!output) return undefined; const gw = yield* describeEgressOnlyInternetGateway( output.egressOnlyInternetGatewayId, ); return yield* toAttrs(gw); }), diff: Effect.fn(function* ({ news, olds }) { if (!isResolved(news)) return; // VPC change requires replacement if (news.vpcId !== olds.vpcId) { return { action: "replace" }; } // Tags can be updated in-place }), reconcile: Effect.fn(function* ({ id, news, output, session }) { const desiredTags = yield* createTags(id, news.tags); // Observe — find the EIGW via cached id, else fall through to create. let gw: ec2.EgressOnlyInternetGateway | undefined; if (output?.egressOnlyInternetGatewayId) { const lookup = yield* ec2 .describeEgressOnlyInternetGateways({ EgressOnlyInternetGatewayIds: [ output.egressOnlyInternetGatewayId, ], }) .pipe( Effect.catchTag( "InvalidEgressOnlyInternetGatewayId.NotFound", () => Effect.succeed({ EgressOnlyInternetGateways: [] }), ), ); gw = lookup.EgressOnlyInternetGateways?.[0]; } // Ensure — create the EIGW if it isn't there yet. if (gw === undefined) { yield* session.note("Creating Egress-Only Internet Gateway..."); const result = yield* ec2.createEgressOnlyInternetGateway({ VpcId: news.vpcId as string, TagSpecifications: [ { ResourceType: "egress-only-internet-gateway", Tags: createTagsList(desiredTags), }, ], DryRun: false, }); const newGwId = result.EgressOnlyInternetGateway!.EgressOnlyInternetGatewayId!; yield* session.note( `Egress-Only Internet Gateway created: ${newGwId}`, ); gw = yield* describeEgressOnlyInternetGateway(newGwId); } const eigwId = gw.EgressOnlyInternetGatewayId!; // Sync tags — observed cloud tags vs desired. const currentTags = (yield* ec2 .describeTags({ Filters: [ { Name: "resource-id", Values: [eigwId] }, { Name: "resource-type", Values: ["egress-only-internet-gateway"], }, ], }) .pipe( Effect.map( (r) => Object.fromEntries( r.Tags?.map((t) => [t.Key!, t.Value!]) ?? [], ) as Record, ), )) ?? {}; const { removed, upsert } = diffTags(currentTags, desiredTags); if (removed.length > 0) { yield* ec2.deleteTags({ Resources: [eigwId], Tags: removed.map((key) => ({ Key: key })), DryRun: false, }); } if (upsert.length > 0) { yield* ec2.createTags({ Resources: [eigwId], Tags: upsert, DryRun: false, }); } // Re-read to reflect current cloud state in the returned attributes. const final = yield* describeEgressOnlyInternetGateway(eigwId); return yield* toAttrs(final); }), delete: Effect.fn(function* ({ output, session }) { const eigwId = output.egressOnlyInternetGatewayId; yield* session.note( `Deleting Egress-Only Internet Gateway: ${eigwId}`, ); yield* ec2 .deleteEgressOnlyInternetGateway({ EgressOnlyInternetGatewayId: eigwId, DryRun: false, }) .pipe( Effect.tapError(Effect.logDebug), // `DeleteEgressOnlyInternetGateway` reports failure as // `ReturnCode: false` with NO error. Treating that as success // orphans the gateway: the engine drops the state row while the // gateway stays attached, wedging the parent VPC's delete on // DependencyViolation forever. Surface it so the retry below // re-drives the call (and the delete fails loudly if it never // takes). Effect.flatMap((result) => result.ReturnCode === false ? Effect.fail(new EigwDeleteFailed({ eigwId })) : Effect.void, ), Effect.catchTag("InvalidGatewayID.NotFound", () => Effect.void), Effect.catchTag( "InvalidEgressOnlyInternetGatewayId.NotFound", () => Effect.void, ), // Retry on dependency violations (e.g., routes still using the EIGW) Effect.retry({ while: (e) => e._tag === "DependencyViolation" || e._tag === "EigwDeleteFailed", schedule: Schedule.max([ Schedule.fixed(5000), Schedule.recurs(30), ]).pipe( Schedule.tap(({ attempt }) => session.note( `Waiting for dependencies to clear... (attempt ${attempt})`, ), ), ), }), ); // Deletion is asynchronous: wait until the gateway is actually gone // before reporting success, so the parent VPC's delete (which the // engine runs next) never races a still-attached gateway. yield* ec2 .describeEgressOnlyInternetGateways({ EgressOnlyInternetGatewayIds: [eigwId], }) .pipe( Effect.flatMap((r) => (r.EgressOnlyInternetGateways ?? []).length === 0 ? Effect.void : Effect.fail(new EigwStillExists({ eigwId })), ), Effect.retry({ while: (e) => e._tag === "EigwStillExists", schedule: Schedule.max([ Schedule.fixed(2000), Schedule.recurs(15), ]), }), Effect.catchTag( "InvalidEgressOnlyInternetGatewayId.NotFound", () => Effect.void, ), ); yield* session.note(`Egress-Only Internet Gateway ${eigwId} deleted`); }), }; }), );