1import * as AWS from "@/AWS";
2import { SecurityGroupRule, type SecurityGroupId } from "@/AWS/EC2";
3import type { SubnetId } from "@/AWS/EC2/Subnet.ts";
4import {
5 Listener,
6 ListenerRule,
7 LoadBalancer,
8 TargetGroup,
9 TargetGroupAttachment,
10} from "@/AWS/ELBv2";
11import * as Test from "@/Test/Alchemy";
12import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
13import * as EC2 from "@distilled.cloud/aws/ec2";
14import { expect } from "alchemy-test";
15import { resolve4 } from "node:dns/promises";
16import * as Effect from "effect/Effect";
17import { MinimumLogLevel } from "effect/References";
18import * as Schedule from "effect/Schedule";
19import * as HttpClient from "effect/http/HttpClient";
20import { getDefaultVpc } from "../DefaultVpc.ts";
21import {
22 ApiTargetFunction,
23 ApiTargetFunctionLive,
24} from "./fixtures/api-handler.ts";
25import {
26 WebTargetFunction,
27 WebTargetFunctionLive,
28} from "./fixtures/web-handler.ts";
29
30const { test } = Test.make({ providers: AWS.providers() });
31
32const 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.
42test.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);