| 1 | import * as AWS from "@/AWS"; |
| 2 | import { SecurityGroupRule, type SecurityGroupId } from "@/AWS/EC2"; |
| 3 | import type { SubnetId } from "@/AWS/EC2/Subnet.ts"; |
| 4 | import { |
| 5 | Listener, |
| 6 | ListenerRule, |
| 7 | LoadBalancer, |
| 8 | TargetGroup, |
| 9 | TargetGroupAttachment, |
| 10 | } from "@/AWS/ELBv2"; |
| 11 | import * as Test from "@/Test/Alchemy"; |
| 12 | import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; |
| 13 | import * as EC2 from "@distilled.cloud/aws/ec2"; |
| 14 | import { expect } from "alchemy-test"; |
| 15 | import { resolve4 } from "node:dns/promises"; |
| 16 | import * as Effect from "effect/Effect"; |
| 17 | import { MinimumLogLevel } from "effect/References"; |
| 18 | import * as Schedule from "effect/Schedule"; |
| 19 | import * as HttpClient from "effect/http/HttpClient"; |
| 20 | import { getDefaultVpc } from "../DefaultVpc.ts"; |
| 21 | import { |
| 22 | ApiTargetFunction, |
| 23 | ApiTargetFunctionLive, |
| 24 | } from "./fixtures/api-handler.ts"; |
| 25 | import { |
| 26 | WebTargetFunction, |
| 27 | WebTargetFunctionLive, |
| 28 | } from "./fixtures/web-handler.ts"; |
| 29 | |
| 30 | const { test } = Test.make({ providers: AWS.providers() }); |
| 31 | |
| 32 | const logLevel = Effect.provideService( |
| 33 | MinimumLogLevel, |
| 34 | process.env.DEBUG ? "Debug" : "Info", |
| 35 | ); |
| 36 | |
| 37 | // The flagship ELBv2 e2e: one internet-facing ALB routes two path patterns to |
| 38 | // two different Lambda targets (no VPC targets, no NAT — Lambda targets prove |
| 39 | // the rule routing end-to-end). AWS kept new ALBs in `provisioning` with |
| 40 | // unresolvable DNS beyond the factory's bounded readiness budget under c128, |
| 41 | // so this slow live lifecycle is opt-in. Set AWS_TEST_SLOW=1 to run it. |
| 42 | test.provider.skipIf(!process.env.AWS_TEST_SLOW)( |
| 43 | "ALB routes two paths to two Lambda targets", |
| 44 | (stack) => |
| 45 | Effect.gen(function* () { |
| 46 | yield* stack.destroy(); |
| 47 | |
| 48 | const defaultVpc = yield* getDefaultVpc; |
| 49 | |
| 50 | // Use the default VPC's EXISTING default subnets + default SG rather |
| 51 | // than creating our own. An ALB leaves ENIs that take minutes to |
| 52 | // release after deletion; a stack-managed subnet/SG cannot delete until |
| 53 | // those ENIs clear, which blew the whole test past its wall on |
| 54 | // teardown. Pre-existing, non-stack-managed network resources are never |
| 55 | // torn down, so teardown is just ALB + target groups + listener. |
| 56 | const subnetResult = yield* EC2.describeSubnets({ |
| 57 | Filters: [ |
| 58 | { Name: "vpc-id", Values: [defaultVpc.vpcId] }, |
| 59 | { Name: "default-for-az", Values: ["true"] }, |
| 60 | ], |
| 61 | }); |
| 62 | const subnetIds = (subnetResult.Subnets ?? []) |
| 63 | .flatMap((s) => (s.SubnetId ? [s.SubnetId as SubnetId] : [])) |
| 64 | .slice(0, 2); |
| 65 | expect(subnetIds.length).toBe(2); |
| 66 | |
| 67 | const groupResult = yield* EC2.describeSecurityGroups({ |
| 68 | Filters: [ |
| 69 | { Name: "vpc-id", Values: [defaultVpc.vpcId] }, |
| 70 | { Name: "group-name", Values: ["default"] }, |
| 71 | ], |
| 72 | }); |
| 73 | const defaultGroupId = groupResult.SecurityGroups?.[0] |
| 74 | ?.GroupId as SecurityGroupId; |
| 75 | expect(defaultGroupId).toBeTruthy(); |
| 76 | |
| 77 | const out = yield* stack.deploy( |
| 78 | Effect.gen(function* () { |
| 79 | const apiFn = yield* ApiTargetFunction.pipe( |
| 80 | Effect.provide(ApiTargetFunctionLive), |
| 81 | ); |
| 82 | const webFn = yield* WebTargetFunction.pipe( |
| 83 | Effect.provide(WebTargetFunctionLive), |
| 84 | ); |
| 85 | |
| 86 | // Add one stack-owned ingress rule to the standing default SG. The |
| 87 | // ALB uses only pre-existing network containers, so teardown does |
| 88 | // not spend minutes waiting for ALB ENIs before deleting an SG. |
| 89 | yield* SecurityGroupRule("LtIngress", { |
| 90 | groupId: defaultGroupId, |
| 91 | type: "ingress", |
| 92 | ipProtocol: "tcp", |
| 93 | fromPort: 80, |
| 94 | toPort: 80, |
| 95 | cidrIpv4: "0.0.0.0/0", |
| 96 | description: "Alchemy ELBv2 Lambda-target e2e", |
| 97 | }); |
| 98 | |
| 99 | const lb = yield* LoadBalancer("LtLb", { |
| 100 | type: "application", |
| 101 | scheme: "internet-facing", |
| 102 | subnets: subnetIds, |
| 103 | securityGroups: [defaultGroupId], |
| 104 | }); |
| 105 | |
| 106 | const apiTg = yield* TargetGroup("LtApiTg", { |
| 107 | targetType: "lambda", |
| 108 | }); |
| 109 | const webTg = yield* TargetGroup("LtWebTg", { |
| 110 | targetType: "lambda", |
| 111 | }); |
| 112 | |
| 113 | // ALB validates the invoke permission at registerTargets time, so |
| 114 | // each target group needs its Permission before the attachment |
| 115 | // (the attachment retries briefly while the permission propagates). |
| 116 | yield* AWS.Lambda.Permission("LtApiInvoke", { |
| 117 | action: "lambda:InvokeFunction", |
| 118 | functionName: apiFn.functionName, |
| 119 | principal: "elasticloadbalancing.amazonaws.com", |
| 120 | sourceArn: apiTg.targetGroupArn, |
| 121 | }); |
| 122 | yield* AWS.Lambda.Permission("LtWebInvoke", { |
| 123 | action: "lambda:InvokeFunction", |
| 124 | functionName: webFn.functionName, |
| 125 | principal: "elasticloadbalancing.amazonaws.com", |
| 126 | sourceArn: webTg.targetGroupArn, |
| 127 | }); |
| 128 | |
| 129 | const apiAttachment = yield* TargetGroupAttachment("LtApiTarget", { |
| 130 | targetGroupArn: apiTg.targetGroupArn, |
| 131 | targetId: apiFn.functionArn, |
| 132 | }); |
| 133 | const webAttachment = yield* TargetGroupAttachment("LtWebTarget", { |
| 134 | targetGroupArn: webTg.targetGroupArn, |
| 135 | targetId: webFn.functionArn, |
| 136 | }); |
| 137 | |
| 138 | const listener = yield* Listener("LtListener", { |
| 139 | loadBalancerArn: lb.loadBalancerArn, |
| 140 | port: 80, |
| 141 | protocol: "HTTP", |
| 142 | defaultActions: [ |
| 143 | { |
| 144 | type: "fixedResponse", |
| 145 | statusCode: "404", |
| 146 | contentType: "text/plain", |
| 147 | messageBody: "no rule matched", |
| 148 | }, |
| 149 | ], |
| 150 | }); |
| 151 | yield* ListenerRule("LtApiRule", { |
| 152 | listenerArn: listener.listenerArn, |
| 153 | priority: 10, |
| 154 | conditions: [{ pathPattern: { values: ["/api/*"] } }], |
| 155 | actions: [ |
| 156 | { |
| 157 | type: "forward", |
| 158 | targetGroups: [{ targetGroupArn: apiTg.targetGroupArn }], |
| 159 | }, |
| 160 | ], |
| 161 | }); |
| 162 | yield* ListenerRule("LtWebRule", { |
| 163 | listenerArn: listener.listenerArn, |
| 164 | priority: 20, |
| 165 | conditions: [{ pathPattern: { values: ["/web/*"] } }], |
| 166 | actions: [ |
| 167 | { |
| 168 | type: "forward", |
| 169 | targetGroups: [{ targetGroupArn: webTg.targetGroupArn }], |
| 170 | }, |
| 171 | ], |
| 172 | }); |
| 173 | |
| 174 | return { |
| 175 | dnsName: lb.dnsName, |
| 176 | apiTargetGroupArn: apiAttachment.targetGroupArn, |
| 177 | webTargetGroupArn: webAttachment.targetGroupArn, |
| 178 | apiFunctionArn: apiAttachment.targetId, |
| 179 | webFunctionArn: webAttachment.targetId, |
| 180 | }; |
| 181 | }), |
| 182 | ); |
| 183 | |
| 184 | // Out-of-band: both Lambda targets are registered. |
| 185 | const apiHealth = yield* elbv2.describeTargetHealth({ |
| 186 | TargetGroupArn: out.apiTargetGroupArn, |
| 187 | }); |
| 188 | expect( |
| 189 | apiHealth.TargetHealthDescriptions?.some( |
| 190 | (d) => d.Target?.Id === out.apiFunctionArn, |
| 191 | ), |
| 192 | ).toBe(true); |
| 193 | const webHealth = yield* elbv2.describeTargetHealth({ |
| 194 | TargetGroupArn: out.webTargetGroupArn, |
| 195 | }); |
| 196 | expect( |
| 197 | webHealth.TargetHealthDescriptions?.some( |
| 198 | (d) => d.Target?.Id === out.webFunctionArn, |
| 199 | ), |
| 200 | ).toBe(true); |
| 201 | |
| 202 | // Resolve the newly-created ALB explicitly before the first HTTP |
| 203 | // request. Starting fetch while its hostname is still propagating can |
| 204 | // retain the negative DNS lookup for every retry in this process. |
| 205 | const [albAddress] = yield* Effect.tryPromise(() => |
| 206 | resolve4(out.dnsName), |
| 207 | ).pipe( |
| 208 | Effect.flatMap((addresses) => |
| 209 | addresses[0] |
| 210 | ? Effect.succeed(addresses) |
| 211 | : Effect.fail(new Error(`ALB ${out.dnsName} has no IPv4 address`)), |
| 212 | ), |
| 213 | Effect.retry({ schedule: Schedule.spaced("3 seconds"), times: 8 }), |
| 214 | ); |
| 215 | |
| 216 | // Drive both paths through the public ALB address. Keep readiness under |
| 217 | // 50 seconds and probe both targets in parallel so one Lambda cold start |
| 218 | // does not serialize the other. |
| 219 | const getJson = (url: string) => |
| 220 | HttpClient.get(url).pipe( |
| 221 | Effect.flatMap((response) => |
| 222 | response.status === 200 |
| 223 | ? response.json |
| 224 | : Effect.fail(new Error(`ALB returned ${response.status}`)), |
| 225 | ), |
| 226 | Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 10 }), |
| 227 | ); |
| 228 | |
| 229 | const baseUrl = `http://${albAddress}`; |
| 230 | const [apiBody, webBody] = (yield* Effect.all( |
| 231 | [getJson(`${baseUrl}/api/hello`), getJson(`${baseUrl}/web/hello`)], |
| 232 | { concurrency: "unbounded" }, |
| 233 | )) as [ |
| 234 | { target: string; path: string }, |
| 235 | { target: string; path: string }, |
| 236 | ]; |
| 237 | expect(apiBody.target).toBe("api"); |
| 238 | expect(apiBody.path).toBe("/api/hello"); |
| 239 | expect(webBody.target).toBe("web"); |
| 240 | expect(webBody.path).toBe("/web/hello"); |
| 241 | |
| 242 | // Unmatched paths hit the listener's fixed-response default action. |
| 243 | const fallback = yield* HttpClient.get(`${baseUrl}/nope`); |
| 244 | expect(fallback.status).toBe(404); |
| 245 | |
| 246 | yield* stack.destroy(); |
| 247 | |
| 248 | // Out-of-band: the target groups are gone after destroy. |
| 249 | const after = yield* elbv2 |
| 250 | .describeTargetGroups({ TargetGroupArns: [out.apiTargetGroupArn] }) |
| 251 | .pipe( |
| 252 | Effect.map((r) => r.TargetGroups?.length ?? 0), |
| 253 | Effect.catchTag("TargetGroupNotFoundException", () => |
| 254 | Effect.succeed(0), |
| 255 | ), |
| 256 | ); |
| 257 | expect(after).toBe(0); |
| 258 | }).pipe(logLevel), |
| 259 | { |
| 260 | tags: [ |
| 261 | "provider:aws", |
| 262 | "provider:aws:ec2", |
| 263 | "provider:aws:elbv2", |
| 264 | "provider:aws:lambda", |
| 265 | "live", |
| 266 | ], |
| 267 | timeout: 240_000, |
| 268 | }, |
| 269 | ); |