1import * as AWS from "@/AWS";
2import { SecurityGroup } from "@/AWS/EC2/SecurityGroup.ts";
3import { Cluster } from "@/AWS/ECS/Cluster.ts";
4import { deriveRulePriority, Service } from "@/AWS/ECS/Service.ts";
5import { Listener } from "@/AWS/ELBv2/Listener.ts";
6import { LoadBalancer } from "@/AWS/ELBv2/LoadBalancer.ts";
7import * as Test from "@/Test/Alchemy";
8import * as ec2 from "@distilled.cloud/aws/ec2";
9import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
10import { expect } from "alchemy-test";
11import * as Data from "effect/Data";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14import * as HttpClient from "effect/http/HttpClient";
15import { getDefaultVpcNetwork } from "../DefaultVpc.ts";
16
17const { test } = Test.make({ providers: AWS.providers() });
18
19// ── auto-priority determinism (pure, no cloud) ──────────────────────────────
20//
21// Rules that omit `priority` get one derived from the rule's namespaced
22// logical id via a stable FNV-1a hash into 1–50000. The exact values are
23// pinned so the derivation can never silently change between runs/releases —
24// a changed hash would re-prioritize (replace) every auto-prioritized rule.
25test(
26 "auto-derived rule priorities are deterministic and distinct",
27 Effect.sync(() => {
28 // Pinned values (computed once; stable forever).
29 expect(deriveRulePriority("SharedAlbStack/EchoA/Rule-0")).toBe(21915);
30 expect(deriveRulePriority("SharedAlbStack/EchoB/Rule-0")).toBe(6262);
31 expect(deriveRulePriority("Api/Rule-0")).toBe(13044);
32 expect(deriveRulePriority("Api/Rule-1")).toBe(35425);
33
34 // Same input ⇒ same output; distinct inputs ⇒ distinct outputs.
35 const ids = [
36 "SharedAlbStack/EchoA/Rule-0",
37 "SharedAlbStack/EchoA/Rule-1",
38 "SharedAlbStack/EchoB/Rule-0",
39 "SharedAlbStack/EchoB/Rule-1",
40 "Api/Rule-0",
41 "Api/Rule-1",
42 ];
43 const first = ids.map(deriveRulePriority);
44 expect(ids.map(deriveRulePriority)).toEqual(first);
45 expect(new Set(first).size).toBe(ids.length);
46 for (const priority of first) {
47 expect(Number.isInteger(priority)).toBe(true);
48 expect(priority).toBeGreaterThanOrEqual(1);
49 expect(priority).toBeLessThanOrEqual(50000);
50 }
51 }),
52 { tags: ["provider:aws", "provider:aws:ecs", "live"] },
53);
54
55class RouteMismatch extends Data.TaggedError("RouteMismatch")<{
56 readonly path: string;
57 readonly status: number;
58 readonly body: string;
59}> {}
60
61// ── shared-ALB end-to-end ───────────────────────────────────────────────────
62//
63// One stack owns an ALB + HTTP listener at stack level; TWO cheap external
64// `image:` services share that listener with distinct `path` rules. Each
65// service composes only its own TargetGroup + ListenerRule (+ managed SG).
66// We assert both paths route through the ALB DNS, then destroy ONE service
67// and assert its rule + target group are gone while the other service still
68// routes and the ALB/listener are untouched. Finally a full destroy with
69// out-of-band zero-orphan proofs.
70//
71// Requires a local Docker daemon (the `image:` source mirrors into ECR).
72// FAST runs skip it: ALB provisioning + Fargate startup + ENI release put it
73// well past the fast budget.
74test.provider.skipIf(!!process.env.FAST)(
75 "two services share one listener; destroying one leaves the other routing",
76 (stack) =>
77 Effect.gen(function* () {
78 yield* stack.destroy();
79 const net = yield* getDefaultVpcNetwork;
80 const client = yield* HttpClient.HttpClient;
81
82 // Only the per-AZ default subnets (one per AZ): concurrent suite runs
83 // create extra subnets in the default VPC, and an ALB rejects two
84 // subnets in the same Availability Zone.
85 const azSubnets = yield* ec2
86 .describeSubnets({
87 Filters: [
88 { Name: "vpc-id", Values: [net.vpcId] },
89 { Name: "default-for-az", Values: ["true"] },
90 ],
91 })
92 .pipe(
93 Effect.map((r) =>
94 (r.Subnets ?? []).flatMap((s) => (s.SubnetId ? [s.SubnetId] : [])),
95 ),
96 );
97
98 const program = (includeEchoB: boolean) =>
99 Effect.gen(function* () {
100 const cluster = yield* Cluster("SharedAlbCluster", {
101 clusterName: "alchemy-test-ecs-shared-alb",
102 });
103 // The shared ALB + listener are stack-level resources owned by
104 // NEITHER service. Unmatched requests hit the 404 default action.
105 const albSg = yield* SecurityGroup("SharedAlbSg", {
106 vpcId: net.vpcId,
107 description: "shared ALB ingress for alchemy ECS tests",
108 ingress: [
109 {
110 ipProtocol: "tcp",
111 fromPort: 80,
112 toPort: 80,
113 cidrIpv4: "0.0.0.0/0",
114 },
115 ],
116 });
117 const alb = yield* LoadBalancer("SharedAlb", {
118 type: "application",
119 scheme: "internet-facing",
120 subnets: azSubnets as `subnet-${string}`[],
121 securityGroups: [albSg.groupId],
122 });
123 const listener = yield* Listener("SharedHttp", {
124 loadBalancerArn: alb.loadBalancerArn,
125 port: 80,
126 protocol: "HTTP",
127 defaultActions: [
128 {
129 type: "fixedResponse",
130 statusCode: "404",
131 contentType: "text/plain",
132 messageBody: "no rule",
133 },
134 ],
135 });
136
137 const base = {
138 cluster,
139 image: "hashicorp/http-echo",
140 port: 5678,
141 desiredCount: 1,
142 vpcId: net.vpcId as string,
143 subnets: azSubnets,
144 // Default-VPC public subnets: tasks need a public IP to pull
145 // the mirrored image from ECR without a NAT.
146 assignPublicIp: true,
147 };
148 const echoA = yield* Service("EchoA", {
149 ...base,
150 command: ["-text=svc-a"],
151 loadBalancer: { listener, rules: [{ path: "/a*" }] },
152 });
153 const echoB = includeEchoB
154 ? yield* Service("EchoB", {
155 ...base,
156 command: ["-text=svc-b"],
157 loadBalancer: { listener, rules: [{ path: "/b*" }] },
158 })
159 : undefined;
160 return {
161 albArn: alb.loadBalancerArn.as<string>(),
162 albDns: alb.dnsName.as<string>(),
163 listenerArn: listener.listenerArn.as<string>(),
164 aTargetGroupArn: echoA.targetGroupArn.as<string>(),
165 aUrl: echoA.url.as<string>(),
166 bTargetGroupArn: echoB?.targetGroupArn.as<string>(),
167 };
168 });
169
170 const fetchRoute = (dns: string, path: string) =>
171 client
172 .get(`http://${dns}${path}`)
173 .pipe(
174 Effect.flatMap((res) =>
175 Effect.map(res.text, (body) => ({ status: res.status, body })),
176 ),
177 );
178 // Bounded route poll: ALB provisioning (~2 min) + Fargate task start +
179 // fast-converge health checks (~20s). Under a saturated full-suite run
180 // ALB DNS propagation alone can eat several minutes, so the budget is
181 // generous (~7 min) — the happy path exits on the first 200.
182 const awaitRoute = (dns: string, path: string, expected: string) =>
183 fetchRoute(dns, path).pipe(
184 Effect.flatMap(({ status, body }) =>
185 status === 200 && body.includes(expected)
186 ? Effect.void
187 : Effect.fail(new RouteMismatch({ path, status, body })),
188 ),
189 Effect.retry({ schedule: Schedule.spaced("5 seconds"), times: 84 }),
190 );
191
192 // ── deploy both services on the shared listener ────────────────────
193 const deployed = yield* stack.deploy(program(true));
194 expect(deployed.aTargetGroupArn).toBeTruthy();
195 expect(deployed.bTargetGroupArn).toBeTruthy();
196 expect(deployed.aTargetGroupArn).not.toBe(deployed.bTargetGroupArn);
197 // Shared ingress derives the service URL from the shared ALB's DNS.
198 expect(deployed.aUrl).toBe(`http://${deployed.albDns}`);
199
200 // Both paths route to their service; unmatched paths hit the
201 // listener's own 404 default action.
202 yield* awaitRoute(deployed.albDns, "/a", "svc-a");
203 yield* awaitRoute(deployed.albDns, "/b", "svc-b");
204 const unmatched = yield* fetchRoute(deployed.albDns, "/c");
205 expect(unmatched.status).toBe(404);
206
207 // Out-of-band: exactly two non-default rules on the shared listener.
208 const rulesBefore = yield* elbv2.describeRules({
209 ListenerArn: deployed.listenerArn,
210 });
211 expect((rulesBefore.Rules ?? []).filter((r) => !r.IsDefault).length).toBe(
212 2,
213 );
214
215 // ── destroy ONE service (EchoB) ────────────────────────────────────
216 yield* stack.deploy(program(false));
217
218 // Its rule + target group are gone...
219 const bTargetGroupGone = yield* elbv2
220 .describeTargetGroups({
221 TargetGroupArns: [deployed.bTargetGroupArn!],
222 })
223 .pipe(
224 Effect.map((r) => (r.TargetGroups ?? []).length === 0),
225 Effect.catchTag("TargetGroupNotFoundException", () =>
226 Effect.succeed(true),
227 ),
228 );
229 expect(bTargetGroupGone).toBe(true);
230 const rulesAfter = yield* elbv2.describeRules({
231 ListenerArn: deployed.listenerArn,
232 });
233 expect((rulesAfter.Rules ?? []).filter((r) => !r.IsDefault).length).toBe(
234 1,
235 );
236
237 // ...the shared ALB + listener are untouched...
238 const albAfter = yield* elbv2.describeLoadBalancers({
239 LoadBalancerArns: [deployed.albArn],
240 });
241 expect((albAfter.LoadBalancers ?? []).length).toBe(1);
242
243 // ...EchoA still routes, and /b now falls through to the 404 default.
244 yield* awaitRoute(deployed.albDns, "/a", "svc-a");
245 yield* fetchRoute(deployed.albDns, "/b").pipe(
246 Effect.flatMap(({ status, body }) =>
247 status === 404
248 ? Effect.void
249 : Effect.fail(new RouteMismatch({ path: "/b", status, body })),
250 ),
251 Effect.retry({ schedule: Schedule.spaced("3 seconds"), times: 20 }),
252 );
253
254 // ── full destroy + zero-orphan proofs ──────────────────────────────
255 yield* stack.destroy();
256
257 const albGone = yield* elbv2
258 .describeLoadBalancers({ LoadBalancerArns: [deployed.albArn] })
259 .pipe(
260 Effect.map((r) => (r.LoadBalancers ?? []).length === 0),
261 Effect.catchTag("LoadBalancerNotFoundException", () =>
262 Effect.succeed(true),
263 ),
264 );
265 expect(albGone).toBe(true);
266
267 const aTargetGroupGone = yield* elbv2
268 .describeTargetGroups({
269 TargetGroupArns: [deployed.aTargetGroupArn],
270 })
271 .pipe(
272 Effect.map((r) => (r.TargetGroups ?? []).length === 0),
273 Effect.catchTag("TargetGroupNotFoundException", () =>
274 Effect.succeed(true),
275 ),
276 );
277 expect(aTargetGroupGone).toBe(true);
278
279 const listenerGone = yield* elbv2
280 .describeRules({ ListenerArn: deployed.listenerArn })
281 .pipe(
282 Effect.map(() => false),
283 Effect.catchTag("ListenerNotFoundException", () =>
284 Effect.succeed(true),
285 ),
286 );
287 expect(listenerGone).toBe(true);
288 }),
289 {
290 tags: [
291 "provider:aws",
292 "provider:aws:ec2",
293 "provider:aws:ecs",
294 "provider:aws:elbv2",
295 "live",
296 ],
297 timeout: 900_000,
298 },
299);