1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as Effect from "effect/Effect";
3import * as Schedule from "effect/Schedule";
4import { isResolved } from "../../Diff.ts";
5import * as Provider from "../../Provider.ts";
6import { Resource } from "../../Resource.ts";
7import { createInternalTags, createTagsList, diffTags } from "../../Tags.ts";
8import type { AccountID } from "../Environment.ts";
9import { AWSEnvironment } from "../Environment.ts";
10import type { Providers } from "../Providers.ts";
11import type { RegionID } from "../Region.ts";
12
13export type EIPArn =
14 `arn:aws:ec2:${RegionID}:${AccountID}:elastic-ip/${AllocationId}`;
15
16export type AllocationId<ID extends string = string> = `eipalloc-${ID}`;
17export const AllocationId = <ID extends string>(
18 id: ID,
19): ID & AllocationId<ID> => `eipalloc-${id}` as ID & AllocationId<ID>;
20
21export interface EIPProps {
22 /**
23 * Indicates whether the Elastic IP address is for use with instances in a VPC or EC2-Classic.
24 * @default "vpc"
25 */
26 domain?: "vpc" | "standard";
27
28 /**
29 * The ID of an address pool that you own.
30 * Use this parameter to let Amazon EC2 select an address from the address pool.
31 */
32 publicIpv4Pool?: string;
33
34 /**
35 * A unique set of Availability Zones, Local Zones, or Wavelength Zones
36 * from which AWS advertises IP addresses.
37 */
38 networkBorderGroup?: string;
39
40 /**
41 * The ID of a customer-owned address pool.
42 * Use this parameter to let Amazon EC2 select an address from the address pool.
43 */
44 customerOwnedIpv4Pool?: string;
45
46 /**
47 * Tags to assign to the Elastic IP.
48 * These will be merged with alchemy auto-tags.
49 */
50 tags?: Record<string, string>;
51}
52
53export interface EIP extends Resource<
54 "AWS.EC2.EIP",
55 EIPProps,
56 {
57 /**
58 * The allocation ID for the Elastic IP address.
59 */
60 allocationId: AllocationId;
61
62 /**
63 * The Amazon Resource Name (ARN) of the Elastic IP.
64 */
65 eipArn: `arn:aws:ec2:${RegionID}:${AccountID}:elastic-ip/${string}`;
66
67 /**
68 * The Elastic IP address.
69 */
70 publicIp: string;
71
72 /**
73 * The ID of an address pool.
74 */
75 publicIpv4Pool?: string;
76
77 /**
78 * Indicates whether the Elastic IP address is for use with instances in a VPC or EC2-Classic.
79 */
80 domain: "vpc" | "standard";
81
82 /**
83 * The network border group.
84 */
85 networkBorderGroup?: string;
86
87 /**
88 * The customer-owned IP address.
89 */
90 customerOwnedIp?: string;
91
92 /**
93 * The ID of the customer-owned address pool.
94 */
95 customerOwnedIpv4Pool?: string;
96
97 /**
98 * The carrier IP address associated with the network interface.
99 */
100 carrierIp?: string;
101 },
102 never,
103 Providers
104> {}
105/**
106 * An Elastic IP address — a static, public IPv4 address allocated to your AWS
107 * account that you can attach to instances, network interfaces, or NAT
108 * gateways.
109 *
110 * Allocating an `EIP` reserves the address; you then reference its
111 * `allocationId` from the resource that should use it (for example a public
112 * `NatGateway`). The address is released back to AWS when the resource is
113 * destroyed. The pool-related properties (`publicIpv4Pool`,
114 * `networkBorderGroup`, `customerOwnedIpv4Pool`) are immutable and replace the
115 * address when changed.
116 *
117 * ### Allocating Elastic IPs
118 * By default an Elastic IP is allocated for use within a VPC (`domain: "vpc"`),
119 * which is the only domain available to modern accounts.
120 * **Example:** VPC-Scoped Elastic IP
121 * ```typescript
122 * const eip = yield* AWS.EC2.EIP("MyEip", {
123 * domain: "vpc",
124 * tags: { Name: "app-eip" },
125 * });
126 * ```
127 * This reserves a standard, Amazon-owned public IPv4 address scoped to your VPC;
128 * `domain` defaults to `"vpc"`, so it can be omitted, and `tags` help you find
129 * the address in the console and on the bill.
130 *
131 * ### Bring-Your-Own-IP and Address Pools
132 * If you have onboarded an address range to AWS (BYOIP) or use Outposts, you can
133 * draw the address from a specific pool instead of Amazon's general pool.
134 * **Example:** Allocate from a Public IPv4 (BYOIP) Pool
135 * ```typescript
136 * const eip = yield* AWS.EC2.EIP("ByoipEip", {
137 * publicIpv4Pool: "ipv4pool-ec2-0abcdef1234567890",
138 * networkBorderGroup: "us-east-1",
139 * });
140 * ```
141 * `publicIpv4Pool` selects an address from a pool you own rather than a random
142 * Amazon address, and `networkBorderGroup` restricts which zone group AWS
143 * advertises it from (useful for Local and Wavelength Zones).
144 *
145 * **Example:** Allocate from a Customer-Owned Pool (Outposts)
146 * ```typescript
147 * const eip = yield* AWS.EC2.EIP("CoIpEip", {
148 * customerOwnedIpv4Pool: "ipv4pool-coip-0abcdef1234567890",
149 * });
150 * ```
151 * `customerOwnedIpv4Pool` pulls a customer-owned IP (CoIP) from an
152 * Outposts-associated pool, for workloads that must use your own on-premises
153 * address space.
154 *
155 * ### Using an Elastic IP
156 * **Example:** Attach to a NAT Gateway
157 * ```typescript
158 * const eip = yield* AWS.EC2.EIP("NatEip", {});
159 *
160 * const natGateway = yield* AWS.EC2.NatGateway("NatGateway", {
161 * subnetId: publicSubnet.subnetId,
162 * allocationId: eip.allocationId,
163 * });
164 * ```
165 * Downstream resources consume the reserved address through its `allocationId`;
166 * here the EIP becomes the fixed public IP of a NAT gateway.
167 *
168 * @resource
169 */
170export const EIP = Resource<EIP>("AWS.EC2.EIP");
171
172export const EIPProvider = () =>
173 Provider.effect(
174 EIP,
175 Effect.gen(function* () {
176 const createTags = Effect.fn(function* (
177 id: string,
178 tags?: Record<string, string>,
179 ) {
180 return {
181 Name: id,
182 ...(yield* createInternalTags(id)),
183 ...tags,
184 };
185 });
186
187 return {
188 stables: ["allocationId", "eipArn", "publicIp"],
189
190 list: () =>
191 Effect.gen(function* () {
192 const { region, accountId } = yield* AWSEnvironment.current;
193 // describeAddresses is non-paginated and returns every Elastic IP
194 // in the account/region in a single response.
195 const result = yield* ec2.describeAddresses({});
196 return (result.Addresses ?? [])
197 .filter(
198 (a): a is ec2.Address & { AllocationId: string } =>
199 a.AllocationId != null,
200 )
201 .map((address): EIP["Attributes"] => ({
202 allocationId: address.AllocationId as AllocationId,
203 eipArn:
204 `arn:aws:ec2:${region}:${accountId}:elastic-ip/${address.AllocationId}` as EIPArn,
205 publicIp: address.PublicIp!,
206 publicIpv4Pool: address.PublicIpv4Pool,
207 domain: (address.Domain as "vpc" | "standard") ?? "vpc",
208 networkBorderGroup: address.NetworkBorderGroup,
209 customerOwnedIp: address.CustomerOwnedIp,
210 customerOwnedIpv4Pool: address.CustomerOwnedIpv4Pool,
211 carrierIp: address.CarrierIp,
212 }));
213 }),
214
215 read: Effect.fn(function* ({ output }) {
216 const { region, accountId } = yield* AWSEnvironment.current;
217
218 if (!output) return undefined;
219 const result = yield* ec2.describeAddresses({
220 AllocationIds: [output.allocationId],
221 });
222
223 const address = result.Addresses?.[0];
224 if (!address) {
225 return yield* Effect.fail(
226 new Error(`EIP ${output.allocationId} not found`),
227 );
228 }
229
230 return {
231 allocationId: address.AllocationId as AllocationId,
232 eipArn:
233 `arn:aws:ec2:${region}:${accountId}:elastic-ip/${address.AllocationId}` as EIPArn,
234 publicIp: address.PublicIp!,
235 publicIpv4Pool: address.PublicIpv4Pool,
236 domain: (address.Domain as "vpc" | "standard") ?? "vpc",
237 networkBorderGroup: address.NetworkBorderGroup,
238 customerOwnedIp: address.CustomerOwnedIp,
239 customerOwnedIpv4Pool: address.CustomerOwnedIpv4Pool,
240 carrierIp: address.CarrierIp,
241 } satisfies EIP["Attributes"];
242 }),
243
244 diff: Effect.fn(function* ({ news = {}, olds = {} }) {
245 if (!isResolved(news)) return;
246 // EIPs are immutable - any change to core properties requires replacement
247 if (
248 news.publicIpv4Pool !== olds.publicIpv4Pool ||
249 news.networkBorderGroup !== olds.networkBorderGroup ||
250 news.customerOwnedIpv4Pool !== olds.customerOwnedIpv4Pool
251 ) {
252 return { action: "replace" };
253 }
254 // Tags can be updated in-place
255 }),
256
257 reconcile: Effect.fn(function* ({ id, news = {}, output, session }) {
258 const { region, accountId } = yield* AWSEnvironment.current;
259
260 const desiredTags = yield* createTags(id, news.tags);
261
262 // Observe — try to find an existing EIP via the cached allocationId.
263 // If it was released out-of-band, fall through to allocate.
264 let address: ec2.Address | undefined;
265 if (output?.allocationId) {
266 const lookup = yield* ec2
267 .describeAddresses({ AllocationIds: [output.allocationId] })
268 .pipe(
269 Effect.catchTag("InvalidAllocationID.NotFound", () =>
270 Effect.succeed({ Addresses: [] }),
271 ),
272 );
273 address = lookup.Addresses?.[0];
274 }
275
276 // Ensure — allocate a new EIP when none was observed.
277 if (address === undefined) {
278 yield* session.note("Allocating Elastic IP address...");
279 const result = yield* ec2.allocateAddress({
280 Domain: news.domain ?? "vpc",
281 PublicIpv4Pool: news.publicIpv4Pool,
282 NetworkBorderGroup: news.networkBorderGroup,
283 CustomerOwnedIpv4Pool: news.customerOwnedIpv4Pool,
284 TagSpecifications: [
285 {
286 ResourceType: "elastic-ip",
287 Tags: createTagsList(desiredTags),
288 },
289 ],
290 DryRun: false,
291 });
292 const allocationId = result.AllocationId! as AllocationId;
293 yield* session.note(`Elastic IP allocated: ${allocationId}`);
294 const lookup = yield* ec2.describeAddresses({
295 AllocationIds: [allocationId],
296 });
297 address = lookup.Addresses?.[0];
298 if (!address) {
299 return yield* Effect.fail(
300 new Error(`EIP ${allocationId} disappeared after allocation`),
301 );
302 }
303 }
304
305 const allocationId = address.AllocationId! as AllocationId;
306
307 // Sync tags — observed cloud tags vs desired.
308 const currentTags = Object.fromEntries(
309 (address.Tags ?? []).map((t) => [t.Key!, t.Value!]),
310 ) as Record<string, string>;
311 const { removed, upsert } = diffTags(currentTags, desiredTags);
312 if (removed.length > 0) {
313 yield* ec2.deleteTags({
314 Resources: [allocationId],
315 Tags: removed.map((key) => ({ Key: key })),
316 DryRun: false,
317 });
318 }
319 if (upsert.length > 0) {
320 yield* ec2.createTags({
321 Resources: [allocationId],
322 Tags: upsert,
323 DryRun: false,
324 });
325 }
326
327 return {
328 allocationId,
329 eipArn:
330 `arn:aws:ec2:${region}:${accountId}:elastic-ip/${allocationId}` as EIPArn,
331 publicIp: address.PublicIp!,
332 publicIpv4Pool: address.PublicIpv4Pool,
333 domain: (address.Domain as "vpc" | "standard") ?? "vpc",
334 networkBorderGroup: address.NetworkBorderGroup,
335 customerOwnedIp: address.CustomerOwnedIp,
336 customerOwnedIpv4Pool: address.CustomerOwnedIpv4Pool,
337 carrierIp: address.CarrierIp,
338 } satisfies EIP["Attributes"];
339 }),
340
341 delete: Effect.fn(function* ({ output, session }) {
342 const allocationId = output.allocationId;
343
344 yield* session.note(`Releasing Elastic IP: ${allocationId}`);
345
346 yield* ec2
347 .releaseAddress({
348 AllocationId: allocationId,
349 DryRun: false,
350 })
351 .pipe(
352 Effect.catchTag(
353 "InvalidAllocationID.NotFound",
354 () => Effect.void,
355 ),
356 Effect.catchTag("AuthFailure", () => Effect.void),
357 Effect.tapError(Effect.logDebug),
358 // Retry when EIP is still in use (e.g., NAT Gateway being deleted)
359 Effect.retry({
360 while: (e) => {
361 return (
362 // TODO(sam): not sure if the API will actually throw this
363 // e._tag === "DependencyViolation" ||
364 // this throws if the address hasn't been disassociated from all resources
365 // we will retry it assuming that another resource provider is dissassociating it (e.g. a NAT Gateway resource is being deleted)
366 e._tag === "InvalidIPAddress.InUse"
367 );
368 },
369 schedule: Schedule.max([
370 Schedule.exponential(1000, 1.5),
371 Schedule.recurs(20),
372 ]).pipe(
373 Schedule.tap(({ attempt }) =>
374 session.note(
375 `EIP still in use, waiting for release... (attempt ${attempt})`,
376 ),
377 ),
378 ),
379 }),
380 );
381
382 yield* session.note(`Elastic IP ${allocationId} released`);
383 }),
384 };
385 }),
386 );