1import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
2import * as Data from "effect/Data";
3import * as Effect from "effect/Effect";
4import * as Schedule from "effect/Schedule";
5import * as Stream from "effect/Stream";
6
7/** Internal: the ALB is still visible after `deleteLoadBalancer`. */
8class LoadBalancerStillDeleting extends Data.TaggedError(
9 "LoadBalancerStillDeleting",
10)<{}> {}
11import { deepEqual, isResolved } from "../../Diff.ts";
12import type { Input } from "../../Input.ts";
13import { createPhysicalName } from "../../PhysicalName.ts";
14import * as Provider from "../../Provider.ts";
15import { Resource } from "../../Resource.ts";
16import type { Providers } from "../Providers.ts";
17import { createInternalTags, diffTags } from "../../Tags.ts";
18import type { AccountID } from "../Environment.ts";
19import type { SecurityGroupId } from "../EC2/SecurityGroup.ts";
20import type { SubnetId } from "../EC2/Subnet.ts";
21import type { RegionID } from "../Region.ts";
22
23export type LoadBalancerName = string;
24export type LoadBalancerArn =
25 `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:loadbalancer/${string}`;
26
27export interface LoadBalancerProps {
28 /** The load balancer name. If omitted, a unique name is generated. Changing it replaces the load balancer. */
29 name?: string;
30 /**
31 * Whether the load balancer is internet-facing or internal. Changing it
32 * replaces the load balancer.
33 * @default "internet-facing"
34 */
35 scheme?: "internal" | "internet-facing";
36 /**
37 * The load balancer type. Changing it replaces the load balancer.
38 * @default "application"
39 */
40 type?: "application" | "network" | "gateway";
41 /**
42 * The subnets to attach. Mutually exclusive with {@link subnetMappings}.
43 * Updated in place via `setSubnets`.
44 */
45 subnets?: Input<SubnetId[]>;
46 /**
47 * Per-subnet mappings for static/EIP addresses (Network Load Balancers).
48 * Mutually exclusive with {@link subnets}. Updated in place via `setSubnets`.
49 */
50 subnetMappings?: {
51 subnetId: Input<SubnetId>;
52 /** The allocation ID of an Elastic IP (NLB). */
53 allocationId?: string;
54 /** A private IPv4 address from the subnet (internal NLB). */
55 privateIPv4Address?: string;
56 /** An IPv6 address from the subnet (dualstack NLB). */
57 iPv6Address?: string;
58 /** A source NAT IPv6 prefix. */
59 sourceNatIpv6Prefix?: string;
60 }[];
61 /** The security groups to attach. Updated in place via `setSecurityGroups`. */
62 securityGroups?: Input<SecurityGroupId[]>;
63 /** The IP address type (`ipv4`, `dualstack`, ...). Updated in place via `setIpAddressType`. */
64 ipAddressType?: string;
65 /** The ID of the customer-owned IPv4 pool (Outposts). Changing it replaces the load balancer. */
66 customerOwnedIpv4Pool?: string;
67 /** Whether to prefix-delegate IPv6 for source NAT (`on`/`off`). */
68 enablePrefixForIpv6SourceNat?: "on" | "off";
69 /**
70 * Whether to enforce security-group inbound rules on PrivateLink traffic
71 * (`on`/`off`). Carried by `setSecurityGroups`.
72 */
73 enforceSecurityGroupInboundRulesOnPrivateLinkTraffic?: "on" | "off";
74 /** Raw load-balancer attributes (idle timeout, deletion protection, access logs, ...). */
75 attributes?: Record<string, string>;
76 /** Tags to apply to the load balancer. */
77 tags?: Record<string, string>;
78}
79
80export interface LoadBalancer extends Resource<
81 "AWS.ELBv2.LoadBalancer",
82 LoadBalancerProps,
83 {
84 /** The ARN of the load balancer. */
85 loadBalancerArn: LoadBalancerArn;
86 /** The name of the load balancer. */
87 loadBalancerName: LoadBalancerName;
88 /** The public DNS name of the load balancer. */
89 dnsName: string;
90 /** The Route 53 hosted zone ID for alias records targeting the load balancer. */
91 canonicalHostedZoneId: string;
92 /** The ID of the VPC the load balancer resides in. */
93 vpcId: string;
94 /** Whether the load balancer is `internet-facing` or `internal`. */
95 scheme: string;
96 /** The load balancer type (`application`, `network`, or `gateway`). */
97 type: string;
98 /** The IDs of the security groups attached to the load balancer. */
99 securityGroups: string[];
100 /** The IDs of the subnets the load balancer spans. */
101 subnets: string[];
102 /** The tags applied to the load balancer. */
103 tags: Record<string, string>;
104 },
105 never,
106 Providers
107> {}
108
109/**
110 * An ELBv2 (Application / Network / Gateway) load balancer.
111 * ### Creating a Load Balancer
112 * **Example:** Internet-facing Application Load Balancer
113 * ```typescript
114 * const lb = yield* LoadBalancer("web", {
115 * type: "application",
116 * scheme: "internet-facing",
117 * subnets: [subnet1.subnetId, subnet2.subnetId],
118 * securityGroups: [sg.groupId],
119 * });
120 * ```
121 *
122 * **Example:** Network Load Balancer with static EIPs
123 * ```typescript
124 * const nlb = yield* LoadBalancer("edge", {
125 * type: "network",
126 * scheme: "internet-facing",
127 * subnetMappings: [
128 * { subnetId: subnet1.subnetId, allocationId: eip1.allocationId },
129 * { subnetId: subnet2.subnetId, allocationId: eip2.allocationId },
130 * ],
131 * });
132 * ```
133 *
134 * ### Attributes
135 * **Example:** Idle timeout and deletion protection
136 * ```typescript
137 * const lb = yield* LoadBalancer("web", {
138 * type: "application",
139 * subnets: [subnet1.subnetId, subnet2.subnetId],
140 * attributes: {
141 * "idle_timeout.timeout_seconds": "120",
142 * "deletion_protection.enabled": "true",
143 * },
144 * });
145 * ```
146 *
147 * @resource
148 */
149export const LoadBalancer = Resource<LoadBalancer>("AWS.ELBv2.LoadBalancer");
150
151export const LoadBalancerProvider = () =>
152 Provider.effect(
153 LoadBalancer,
154 Effect.gen(function* () {
155 const toName = (id: string, props: { name?: string } = {}) =>
156 props.name
157 ? Effect.succeed(props.name)
158 : createPhysicalName({ id, maxLength: 32, lowercase: true });
159
160 return {
161 stables: [
162 "loadBalancerArn",
163 "loadBalancerName",
164 "dnsName",
165 "canonicalHostedZoneId",
166 "vpcId",
167 ],
168 diff: Effect.fn(function* ({ id, olds, news }) {
169 if (!isResolved(news)) return;
170 const oldName = yield* toName(id, olds ?? {});
171 const newName = yield* toName(id, news ?? {});
172 if (oldName !== newName) {
173 return { action: "replace" } as const;
174 }
175 // Only scheme, type and customerOwnedIpv4Pool are immutable.
176 // subnets, securityGroups and ipAddressType are mutated in place
177 // during reconcile (setSubnets / setSecurityGroups / setIpAddressType).
178 if (
179 !deepEqual(
180 {
181 scheme: olds.scheme ?? "internet-facing",
182 type: olds.type ?? "application",
183 customerOwnedIpv4Pool: olds.customerOwnedIpv4Pool,
184 },
185 {
186 scheme: news.scheme ?? "internet-facing",
187 type: news.type ?? "application",
188 customerOwnedIpv4Pool: news.customerOwnedIpv4Pool,
189 },
190 )
191 ) {
192 return { action: "replace" } as const;
193 }
194 }),
195 read: Effect.fn(function* ({ output }) {
196 if (!output) {
197 return undefined;
198 }
199 const described = yield* elbv2
200 .describeLoadBalancers({
201 LoadBalancerArns: [output.loadBalancerArn],
202 })
203 .pipe(
204 Effect.catchTag("LoadBalancerNotFoundException", () =>
205 Effect.succeed(undefined),
206 ),
207 );
208 const loadBalancer = described?.LoadBalancers?.[0];
209 if (!loadBalancer?.LoadBalancerArn) {
210 return undefined;
211 }
212 return {
213 ...output,
214 dnsName: loadBalancer.DNSName!,
215 canonicalHostedZoneId: loadBalancer.CanonicalHostedZoneId!,
216 vpcId: loadBalancer.VpcId!,
217 scheme: loadBalancer.Scheme!,
218 type: loadBalancer.Type!,
219 securityGroups: loadBalancer.SecurityGroups ?? [],
220 subnets:
221 loadBalancer.AvailabilityZones?.flatMap((zone) =>
222 zone.SubnetId ? [zone.SubnetId] : [],
223 ) ?? [],
224 };
225 }),
226 list: () =>
227 Effect.gen(function* () {
228 // Enumerate every load balancer in the account/region, paginating
229 // exhaustively.
230 const loadBalancers = yield* elbv2.describeLoadBalancers
231 .pages({})
232 .pipe(
233 Stream.runCollect,
234 Effect.map((chunk) =>
235 Array.from(chunk).flatMap((page) => page.LoadBalancers ?? []),
236 ),
237 );
238 const owned = loadBalancers.filter(
239 (lb): lb is elbv2.LoadBalancer & { LoadBalancerArn: string } =>
240 lb.LoadBalancerArn != null,
241 );
242 if (owned.length === 0) {
243 return [];
244 }
245
246 // describeTags accepts at most 20 ARNs per call, so batch.
247 const batches: (typeof owned)[] = [];
248 for (let i = 0; i < owned.length; i += 20) {
249 batches.push(owned.slice(i, i + 20));
250 }
251 const tagDescriptions = yield* Effect.forEach(
252 batches,
253 (batch) =>
254 elbv2
255 .describeTags({
256 ResourceArns: batch.map((lb) => lb.LoadBalancerArn),
257 })
258 .pipe(Effect.map((res) => res.TagDescriptions ?? [])),
259 { concurrency: 5 },
260 );
261 const tagsByArn = new Map(
262 tagDescriptions
263 .flat()
264 .flatMap((desc) =>
265 desc.ResourceArn
266 ? ([
267 [
268 desc.ResourceArn,
269 Object.fromEntries(
270 (desc.Tags ?? [])
271 .filter(
272 (t): t is { Key: string; Value: string } =>
273 typeof t.Key === "string" &&
274 typeof t.Value === "string",
275 )
276 .map((t) => [t.Key, t.Value]),
277 ),
278 ],
279 ] as const)
280 : [],
281 ),
282 );
283
284 return owned.map((lb) => ({
285 loadBalancerArn: lb.LoadBalancerArn as LoadBalancerArn,
286 loadBalancerName: lb.LoadBalancerName!,
287 dnsName: lb.DNSName!,
288 canonicalHostedZoneId: lb.CanonicalHostedZoneId!,
289 vpcId: lb.VpcId!,
290 scheme: lb.Scheme!,
291 type: lb.Type!,
292 securityGroups: lb.SecurityGroups ?? [],
293 subnets:
294 lb.AvailabilityZones?.flatMap((zone) =>
295 zone.SubnetId ? [zone.SubnetId] : [],
296 ) ?? [],
297 tags: tagsByArn.get(lb.LoadBalancerArn) ?? {},
298 }));
299 }),
300 reconcile: Effect.fn(function* ({ id, news, session }) {
301 const name = yield* toName(id, news);
302 const desiredTags = {
303 ...(yield* createInternalTags(id)),
304 ...news.tags,
305 };
306
307 // Observe — look up by deterministic name.
308 let described = yield* elbv2
309 .describeLoadBalancers({
310 Names: [name],
311 })
312 .pipe(
313 Effect.catchTag("LoadBalancerNotFoundException", () =>
314 Effect.succeed(undefined),
315 ),
316 );
317 let loadBalancer = described?.LoadBalancers?.[0];
318
319 const subnetMappings = news.subnetMappings?.map((m) => ({
320 SubnetId: m.subnetId as string,
321 AllocationId: m.allocationId,
322 PrivateIPv4Address: m.privateIPv4Address,
323 IPv6Address: m.iPv6Address,
324 SourceNatIpv6Prefix: m.sourceNatIpv6Prefix,
325 }));
326
327 // Ensure — create if missing. The replacement axes (scheme, type,
328 // customerOwnedIpv4Pool) are handled by diff so we don't need to
329 // deal with mismatches here.
330 if (!loadBalancer?.LoadBalancerArn) {
331 const created = yield* elbv2.createLoadBalancer({
332 Name: name,
333 Scheme: news.scheme ?? "internet-facing",
334 Type: news.type ?? "application",
335 Subnets: news.subnets as string[] | undefined,
336 SubnetMappings: subnetMappings,
337 SecurityGroups: news.securityGroups as string[] | undefined,
338 IpAddressType: news.ipAddressType,
339 CustomerOwnedIpv4Pool: news.customerOwnedIpv4Pool,
340 EnablePrefixForIpv6SourceNat: news.enablePrefixForIpv6SourceNat,
341 Tags: Object.entries(desiredTags).map(([Key, Value]) => ({
342 Key,
343 Value,
344 })),
345 });
346 loadBalancer = created.LoadBalancers?.[0];
347 if (!loadBalancer?.LoadBalancerArn) {
348 return yield* Effect.die(
349 new Error("createLoadBalancer returned no load balancer"),
350 );
351 }
352 }
353
354 const loadBalancerArn =
355 loadBalancer.LoadBalancerArn as LoadBalancerArn;
356
357 // Sync subnets — diff observed against desired. Only applies to
358 // application/network LBs that manage subnets in place.
359 const observedSubnets =
360 loadBalancer.AvailabilityZones?.flatMap((z) =>
361 z.SubnetId ? [z.SubnetId] : [],
362 ) ?? [];
363 if (news.subnets) {
364 const desiredSubnets = news.subnets as string[];
365 if (
366 !deepEqual(
367 [...observedSubnets].sort(),
368 [...desiredSubnets].sort(),
369 )
370 ) {
371 yield* elbv2.setSubnets({
372 LoadBalancerArn: loadBalancerArn,
373 Subnets: desiredSubnets,
374 });
375 }
376 } else if (subnetMappings) {
377 yield* elbv2.setSubnets({
378 LoadBalancerArn: loadBalancerArn,
379 SubnetMappings: subnetMappings,
380 });
381 }
382
383 // Sync security groups — diff observed against desired.
384 if (news.securityGroups) {
385 const observedSgs = loadBalancer.SecurityGroups ?? [];
386 const desiredSgs = news.securityGroups as string[];
387 if (
388 !deepEqual([...observedSgs].sort(), [...desiredSgs].sort()) ||
389 news.enforceSecurityGroupInboundRulesOnPrivateLinkTraffic
390 ) {
391 yield* elbv2.setSecurityGroups({
392 LoadBalancerArn: loadBalancerArn,
393 SecurityGroups: desiredSgs,
394 EnforceSecurityGroupInboundRulesOnPrivateLinkTraffic:
395 news.enforceSecurityGroupInboundRulesOnPrivateLinkTraffic,
396 });
397 }
398 }
399
400 // Sync IP address type — diff observed against desired.
401 if (
402 news.ipAddressType &&
403 news.ipAddressType !== loadBalancer.IpAddressType
404 ) {
405 yield* elbv2.setIpAddressType({
406 LoadBalancerArn: loadBalancerArn,
407 IpAddressType: news.ipAddressType,
408 });
409 }
410
411 // Sync attributes — observed ↔ desired. We always apply when
412 // desired attrs are non-empty; AWS rejects an empty list anyway,
413 // and reading observed attributes is an extra round-trip we
414 // don't need for convergence.
415 if (news.attributes && Object.keys(news.attributes).length > 0) {
416 yield* elbv2.modifyLoadBalancerAttributes({
417 LoadBalancerArn: loadBalancerArn,
418 Attributes: Object.entries(news.attributes).map(
419 ([Key, Value]) => ({
420 Key,
421 Value,
422 }),
423 ),
424 });
425 }
426
427 // Sync tags — diff observed cloud tags against desired.
428 const tagDescriptions = yield* elbv2.describeTags({
429 ResourceArns: [loadBalancerArn],
430 });
431 const observedTags = Object.fromEntries(
432 (tagDescriptions.TagDescriptions?.[0]?.Tags ?? [])
433 .filter(
434 (t): t is { Key: string; Value: string } =>
435 typeof t.Key === "string" && typeof t.Value === "string",
436 )
437 .map((t) => [t.Key, t.Value]),
438 );
439 const { removed, upsert } = diffTags(observedTags, desiredTags);
440 if (upsert.length > 0) {
441 yield* elbv2.addTags({
442 ResourceArns: [loadBalancerArn],
443 Tags: upsert,
444 });
445 }
446 if (removed.length > 0) {
447 yield* elbv2.removeTags({
448 ResourceArns: [loadBalancerArn],
449 TagKeys: removed,
450 });
451 }
452
453 yield* session.note(loadBalancerArn);
454 return {
455 loadBalancerArn,
456 loadBalancerName: loadBalancer.LoadBalancerName!,
457 dnsName: loadBalancer.DNSName!,
458 canonicalHostedZoneId: loadBalancer.CanonicalHostedZoneId!,
459 vpcId: loadBalancer.VpcId!,
460 scheme: loadBalancer.Scheme!,
461 type: loadBalancer.Type!,
462 securityGroups: loadBalancer.SecurityGroups ?? [],
463 subnets:
464 loadBalancer.AvailabilityZones?.flatMap((zone) =>
465 zone.SubnetId ? [zone.SubnetId] : [],
466 ) ?? [],
467 tags: desiredTags,
468 };
469 }),
470 delete: Effect.fn(function* ({ output }) {
471 yield* elbv2
472 .deleteLoadBalancer({
473 LoadBalancerArn: output.loadBalancerArn,
474 })
475 .pipe(
476 Effect.catchTag(
477 "LoadBalancerNotFoundException",
478 () => Effect.void,
479 ),
480 );
481
482 // `deleteLoadBalancer` returns immediately, but the ALB's ENIs
483 // linger for a minute or two afterwards and block deletion of any
484 // subnet or security group they occupy. Wait until the balancer is
485 // fully gone so downstream network resources tear down on the first
486 // attempt instead of spinning on dependency-violation retries.
487 yield* elbv2
488 .describeLoadBalancers({
489 LoadBalancerArns: [output.loadBalancerArn],
490 })
491 .pipe(
492 Effect.flatMap(() =>
493 Effect.fail(new LoadBalancerStillDeleting()),
494 ),
495 Effect.catchTag(
496 "LoadBalancerNotFoundException",
497 () => Effect.void,
498 ),
499 Effect.retry({
500 while: (e) => e._tag === "LoadBalancerStillDeleting",
501 schedule: Schedule.max([
502 Schedule.fixed("5 seconds"),
503 Schedule.recurs(48),
504 ]),
505 }),
506 // Best-effort: if it is somehow still visible after ~4 min, let
507 // the downstream deletes retry rather than fail the teardown.
508 Effect.catchTag("LoadBalancerStillDeleting", () => Effect.void),
509 );
510 }),
511 };
512 }),
513 );