1import * as AWS from "@/AWS";
2import { Cluster } from "@/AWS/ECS/Cluster.ts";
3import { Service } from "@/AWS/ECS/Service.ts";
4import * as Test from "@/Test/Alchemy";
5import * as ec2 from "@distilled.cloud/aws/ec2";
6import * as ecs from "@distilled.cloud/aws/ecs";
7import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
8import { expect } from "alchemy-test";
9import * as Data from "effect/Data";
10import * as Effect from "effect/Effect";
11import * as Schedule from "effect/Schedule";
12import * as HttpClient from "effect/http/HttpClient";
13import { getDefaultVpcNetwork } from "../DefaultVpc.ts";
14
15const { test } = Test.make({ providers: AWS.providers() });
16
17class RouteMismatch extends Data.TaggedError("RouteMismatch")<{
18 readonly status: number;
19 readonly body: string;
20}> {}
21
22// NLB + capacity: a `"80/tcp"` listen rule derives a Network Load Balancer
23// (type, TCP listener, TCP target group), proven by a live HTTP-over-TCP
24// round-trip through the NLB's DNS; a second service on the same cluster
25// runs `capacity: "spot"` (describe-level — the strategy is visible on
26// `describeServices`, no need to place a task).
27//
28// Requires a local Docker daemon (the `image:` sources mirror into ECR).
29// NLB provisioning + target registration is slower than ALB (~3-4 min).
30test.provider.skipIf(!!process.env.FAST)(
31 "tcp rules compose an NLB (live round-trip); capacity 'spot' maps to FARGATE_SPOT",
32 (stack) =>
33 Effect.gen(function* () {
34 yield* stack.destroy();
35 const net = yield* getDefaultVpcNetwork;
36 const client = yield* HttpClient.HttpClient;
37
38 // Only the per-AZ default subnets: concurrent suites create extra
39 // subnets, and a load balancer rejects two subnets in one AZ.
40 const azSubnets = yield* ec2
41 .describeSubnets({
42 Filters: [
43 { Name: "vpc-id", Values: [net.vpcId] },
44 { Name: "default-for-az", Values: ["true"] },
45 ],
46 })
47 .pipe(
48 Effect.map((r) =>
49 (r.Subnets ?? []).flatMap((s) => (s.SubnetId ? [s.SubnetId] : [])),
50 ),
51 );
52
53 const deployed = yield* stack.deploy(
54 Effect.gen(function* () {
55 const cluster = yield* Cluster("NlbCluster", {
56 clusterName: "alchemy-test-ecs-nlb",
57 capacityProviders: ["FARGATE", "FARGATE_SPOT"],
58 });
59 const base = {
60 cluster,
61 vpcId: net.vpcId as string,
62 subnets: azSubnets,
63 // Default-VPC public subnets: tasks need a public IP to pull
64 // the mirrored image from ECR without a NAT.
65 assignPublicIp: true,
66 };
67 const echo = yield* Service("NlbEcho", {
68 ...base,
69 image: "hashicorp/http-echo",
70 command: ["-text=nlb-echo"],
71 port: 5678,
72 desiredCount: 1,
73 loadBalancer: { rules: [{ listen: "80/tcp" }] },
74 });
75 const spot = yield* Service("SpotWorker", {
76 ...base,
77 image: "busybox:stable",
78 command: ["sh", "-c", "while true; do sleep 30; done"],
79 desiredCount: 0,
80 capacity: "spot",
81 });
82 return {
83 url: echo.url.as<string>(),
84 loadBalancerArn: echo.loadBalancerArn.as<string>(),
85 targetGroupArn: echo.targetGroupArn.as<string>(),
86 listenerArn: echo.listenerArn.as<string>(),
87 clusterArn: echo.clusterArn.as<string>(),
88 spotServiceName: spot.serviceName.as<string>(),
89 };
90 }),
91 );
92
93 // ── the composed LB is a Network Load Balancer ────────────────────
94 const loadBalancers = yield* elbv2.describeLoadBalancers({
95 LoadBalancerArns: [deployed.loadBalancerArn],
96 });
97 const nlb = loadBalancers.LoadBalancers?.[0];
98 expect(nlb?.Type).toBe("network");
99 // Non-HTTP protocols always carry the port (there is no well-known
100 // default for tcp://).
101 expect(deployed.url).toBe(`tcp://${nlb?.DNSName}:80`);
102
103 // TCP listener on 80, TCP target group on the container port.
104 const listeners = yield* elbv2.describeListeners({
105 LoadBalancerArn: deployed.loadBalancerArn,
106 });
107 const listener = (listeners.Listeners ?? []).find((l) => l.Port === 80);
108 expect(listener?.Protocol).toBe("TCP");
109 const targetGroups = yield* elbv2.describeTargetGroups({
110 TargetGroupArns: [deployed.targetGroupArn],
111 });
112 expect(targetGroups.TargetGroups?.[0]?.Protocol).toBe("TCP");
113 expect(targetGroups.TargetGroups?.[0]?.Port).toBe(5678);
114
115 // ── live round-trip: HTTP over the NLB's TCP listener ─────────────
116 yield* client.get(`http://${nlb!.DNSName}/`).pipe(
117 Effect.flatMap((res) =>
118 Effect.flatMap(res.text, (body) =>
119 res.status === 200 && body.includes("nlb-echo")
120 ? Effect.void
121 : Effect.fail(new RouteMismatch({ status: res.status, body })),
122 ),
123 ),
124 // NLB provisioning + target registration: ~3-4 min worst case.
125 Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 60 }),
126 );
127
128 // ── capacity: "spot" arrives as a FARGATE_SPOT strategy ───────────
129 const services = yield* ecs.describeServices({
130 cluster: deployed.clusterArn,
131 services: [deployed.spotServiceName],
132 });
133 const spotService = services.services?.[0];
134 expect(spotService?.capacityProviderStrategy).toEqual([
135 { capacityProvider: "FARGATE_SPOT", weight: 1, base: 0 },
136 ]);
137 expect(spotService?.launchType).toBeUndefined();
138
139 // ── destroy + zero-orphan proofs ──────────────────────────────────
140 yield* stack.destroy();
141
142 const nlbGone = yield* elbv2
143 .describeLoadBalancers({
144 LoadBalancerArns: [deployed.loadBalancerArn],
145 })
146 .pipe(
147 Effect.map((r) => (r.LoadBalancers ?? []).length === 0),
148 Effect.catchTag("LoadBalancerNotFoundException", () =>
149 Effect.succeed(true),
150 ),
151 );
152 expect(nlbGone).toBe(true);
153
154 const targetGroupGone = yield* elbv2
155 .describeTargetGroups({
156 TargetGroupArns: [deployed.targetGroupArn],
157 })
158 .pipe(
159 Effect.map((r) => (r.TargetGroups ?? []).length === 0),
160 Effect.catchTag("TargetGroupNotFoundException", () =>
161 Effect.succeed(true),
162 ),
163 );
164 expect(targetGroupGone).toBe(true);
165 }),
166 {
167 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
168 timeout: 900_000,
169 },
170);