| 1 | import * as AWS from "@/AWS"; |
| 2 | import { Cluster } from "@/AWS/ECS/Cluster.ts"; |
| 3 | import { Service } from "@/AWS/ECS/Service.ts"; |
| 4 | import * as Test from "@/Test/Alchemy"; |
| 5 | import * as ec2 from "@distilled.cloud/aws/ec2"; |
| 6 | import * as ecs from "@distilled.cloud/aws/ecs"; |
| 7 | import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2"; |
| 8 | import { expect } from "alchemy-test"; |
| 9 | import * as Data from "effect/Data"; |
| 10 | import * as Effect from "effect/Effect"; |
| 11 | import * as Schedule from "effect/Schedule"; |
| 12 | import * as HttpClient from "effect/http/HttpClient"; |
| 13 | import { getDefaultVpcNetwork } from "../DefaultVpc.ts"; |
| 14 | |
| 15 | const { test } = Test.make({ providers: AWS.providers() }); |
| 16 | |
| 17 | class 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). |
| 30 | test.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 | ); |