| 1 | import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; |
| 2 | import * as Data from "effect/Data"; |
| 3 | import * as Effect from "effect/Effect"; |
| 4 | import * as Schedule from "effect/Schedule"; |
| 5 | import * as Stream from "effect/Stream"; |
| 6 | |
| 7 | /** Internal: the ALB is still visible after `deleteLoadBalancer`. */ |
| 8 | class LoadBalancerStillDeleting extends Data.TaggedError( |
| 9 | "LoadBalancerStillDeleting", |
| 10 | )<{}> {} |
| 11 | import { deepEqual, isResolved } from "../../Diff.ts"; |
| 12 | import type { Input } from "../../Input.ts"; |
| 13 | import { createPhysicalName } from "../../PhysicalName.ts"; |
| 14 | import * as Provider from "../../Provider.ts"; |
| 15 | import { Resource } from "../../Resource.ts"; |
| 16 | import type { Providers } from "../Providers.ts"; |
| 17 | import { createInternalTags, diffTags } from "../../Tags.ts"; |
| 18 | import type { AccountID } from "../Environment.ts"; |
| 19 | import type { SecurityGroupId } from "../EC2/SecurityGroup.ts"; |
| 20 | import type { SubnetId } from "../EC2/Subnet.ts"; |
| 21 | import type { RegionID } from "../Region.ts"; |
| 22 | |
| 23 | export type LoadBalancerName = string; |
| 24 | export type LoadBalancerArn = |
| 25 | `arn:aws:elasticloadbalancing:${RegionID}:${AccountID}:loadbalancer/${string}`; |
| 26 | |
| 27 | export 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 | |
| 80 | export 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 | */ |
| 149 | export const LoadBalancer = Resource<LoadBalancer>("AWS.ELBv2.LoadBalancer"); |
| 150 | |
| 151 | export 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 | ); |