1import * as AWS from "@/AWS";
2import { Cluster } from "@/AWS/ECS/Cluster.ts";
3import { Service } from "@/AWS/ECS/Service.ts";
4import { isResourceState, State, type ResourceState } from "@/State";
5import * as Test from "@/Test/Alchemy";
6import * as ec2 from "@distilled.cloud/aws/ec2";
7import * as ecr from "@distilled.cloud/aws/ecr";
8import * as ecs from "@distilled.cloud/aws/ecs";
9import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
10import * as iam from "@distilled.cloud/aws/iam";
11import { expect } from "alchemy-test";
12import * as Effect from "effect/Effect";
13import * as Schedule from "effect/Schedule";
14import { getDefaultVpcNetwork } from "../DefaultVpc.ts";
15
16const { test } = Test.make({ providers: AWS.providers() });
17
18// The default VPC's per-AZ default subnets (exactly one per AZ). Concurrent
19// suite runs create EXTRA subnets in the default VPC, and an ALB rejects two
20// subnets in the same AZ — so never use the full subnet list for ALBs.
21const defaultAzSubnets = (vpcId: string) =>
22 ec2
23 .describeSubnets({
24 Filters: [
25 { Name: "vpc-id", Values: [vpcId] },
26 { Name: "default-for-az", Values: ["true"] },
27 ],
28 })
29 .pipe(
30 Effect.map((r) =>
31 (r.Subnets ?? []).flatMap((s) => (s.SubnetId ? [s.SubnetId] : [])),
32 ),
33 );
34
35/**
36 * Delete any leftover out-of-band "legacy" fixtures carrying {@link name}.
37 *
38 * The reap test builds its fixtures under a fixed name and relies on
39 * finalizers to reclaim them, which a hard kill skips. Matching by listing
40 * (rather than describe-by-name) keeps this free of not-found handling: an
41 * absent fixture simply doesn't appear.
42 */
43const reclaimLegacyFixtures = Effect.fn("reclaimLegacyFixtures")(function* (
44 name: string,
45 vpcId: string,
46) {
47 const lbs = yield* elbv2.describeLoadBalancers({});
48 const lbArn = (lbs.LoadBalancers ?? []).find(
49 (lb) => lb.LoadBalancerName === name,
50 )?.LoadBalancerArn;
51 if (lbArn) {
52 const listeners = yield* elbv2.describeListeners({
53 LoadBalancerArn: lbArn,
54 });
55 yield* Effect.forEach(
56 (listeners.Listeners ?? []).flatMap((l) =>
57 l.ListenerArn ? [l.ListenerArn] : [],
58 ),
59 (ListenerArn) => elbv2.deleteListener({ ListenerArn }),
60 );
61 yield* elbv2.deleteLoadBalancer({ LoadBalancerArn: lbArn });
62 }
63
64 const tgs = yield* elbv2.describeTargetGroups({});
65 const tgArn = (tgs.TargetGroups ?? []).find(
66 (tg) => tg.TargetGroupName === name,
67 )?.TargetGroupArn;
68 if (tgArn) {
69 yield* elbv2.deleteTargetGroup({ TargetGroupArn: tgArn });
70 }
71
72 const sgs = yield* ec2.describeSecurityGroups({
73 Filters: [
74 { Name: "vpc-id", Values: [vpcId] },
75 { Name: "group-name", Values: [name] },
76 ],
77 });
78 const sgId = (sgs.SecurityGroups ?? [])[0]?.GroupId;
79 if (sgId) {
80 yield* ec2.deleteSecurityGroup({ GroupId: sgId });
81 }
82});
83
84// The image-owning `Service` platform form: `image:` (mirrored into ECR) with
85// `loadBalancer: true` — the Service synthesizes its own task definition
86// (roles, log group, ECR repository) and wires an ALB + target group +
87// listener + owned ingress security group, defaulting networking to the
88// account's default VPC. `desiredCount: 0` keeps the test inside the speed
89// budget (no Fargate placement, no image pull on the service side — the
90// mirror is a local docker pull/tag/push of a tiny busybox).
91//
92// Requires a local Docker daemon (same as the ECS Bindings fixture).
93test.provider(
94 "image-owning service synthesizes its task definition and ALB",
95 (stack) =>
96 Effect.gen(function* () {
97 yield* stack.destroy();
98
99 const deployed = yield* stack.deploy(
100 Effect.gen(function* () {
101 const cluster = yield* Cluster("PlatformSvcCluster", {
102 clusterName: "alchemy-test-ecs-service-platform",
103 });
104 // Docker Hub busybox: the public.ecr.aws mirror aggressively
105 // rate-limits anonymous pulls during local builds.
106 return yield* Service("PlatformEdge", {
107 cluster,
108 image: "busybox:stable",
109 command: ["sh", "-c", "while true; do sleep 30; done"],
110 port: 80,
111 desiredCount: 0,
112 loadBalancer: true,
113 environmentFiles: [
114 {
115 value: "arn:aws:s3:::alchemy-test-ecs-env-files/edge.env",
116 type: "s3",
117 },
118 ],
119 });
120 }),
121 );
122
123 // Synthesized task definition attributes.
124 expect(deployed.serviceArn).toBeTruthy();
125 expect(deployed.taskDefinitionArn).toBeTruthy();
126 expect(deployed.taskFamily).toBeTruthy();
127 expect(deployed.repositoryName).toBeTruthy();
128 expect(deployed.imageUri).toContain(deployed.repositoryName!);
129 expect(deployed.code?.hash).toBeTruthy();
130 expect(deployed.taskRoleArn).toBeTruthy();
131 expect(deployed.executionRoleArn).toBeTruthy();
132 expect(deployed.logGroupName).toBeTruthy();
133
134 // Managed ingress attributes.
135 expect(deployed.url).toMatch(/^http:\/\//);
136 expect(deployed.loadBalancerArn).toBeTruthy();
137 expect(deployed.targetGroupArn).toBeTruthy();
138 expect(deployed.listenerArn).toBeTruthy();
139 expect(deployed.securityGroupId).toBeTruthy();
140
141 // Out-of-band: the registered task definition runs the mirrored image.
142 const described = yield* ecs.describeTaskDefinition({
143 taskDefinition: deployed.taskDefinitionArn,
144 });
145 const container = described.taskDefinition?.containerDefinitions?.[0];
146 expect(container?.image).toBe(deployed.imageUri);
147 expect(container?.command).toEqual([
148 "sh",
149 "-c",
150 "while true; do sleep 30; done",
151 ]);
152 expect(container?.portMappings?.[0]?.containerPort).toBe(80);
153 // `environmentFiles` lands on the synthesized primary container, with
154 // the execution role granted read access to the referenced object.
155 expect(container?.environmentFiles).toEqual([
156 {
157 value: "arn:aws:s3:::alchemy-test-ecs-env-files/edge.env",
158 type: "s3",
159 },
160 ]);
161 const envFilesPolicy = yield* iam.getRolePolicy({
162 RoleName: deployed.executionRoleArn!.split(":role/")[1]!,
163 PolicyName: "alchemy-environment-files",
164 });
165 expect(decodeURIComponent(envFilesPolicy.PolicyDocument ?? "")).toContain(
166 "arn:aws:s3:::alchemy-test-ecs-env-files/edge.env",
167 );
168
169 // Out-of-band: the service is wired to the generated target group.
170 const services = yield* ecs.describeServices({
171 cluster: deployed.clusterArn,
172 services: [deployed.serviceName],
173 });
174 const svc = services.services?.[0];
175 expect(svc?.loadBalancers?.[0]?.targetGroupArn).toBe(
176 deployed.targetGroupArn,
177 );
178 expect(svc?.loadBalancers?.[0]?.containerPort).toBe(80);
179
180 yield* stack.destroy();
181
182 // Zero-orphan proofs: every owned resource is gone (or terminally
183 // deleting) after destroy.
184 const repoGone = yield* ecr
185 .describeRepositories({
186 repositoryNames: [deployed.repositoryName!],
187 })
188 .pipe(
189 Effect.map(() => false),
190 Effect.catchTag("RepositoryNotFoundException", () =>
191 Effect.succeed(true),
192 ),
193 );
194 expect(repoGone).toBe(true);
195
196 const lbGone = yield* elbv2
197 .describeLoadBalancers({
198 LoadBalancerArns: [deployed.loadBalancerArn!],
199 })
200 .pipe(
201 Effect.map((r) => (r.LoadBalancers ?? []).length === 0),
202 Effect.catchTag("LoadBalancerNotFoundException", () =>
203 Effect.succeed(true),
204 ),
205 );
206 expect(lbGone).toBe(true);
207
208 const sgGone = yield* ec2
209 .describeSecurityGroups({ GroupIds: [deployed.securityGroupId!] })
210 .pipe(
211 Effect.map((r) => (r.SecurityGroups ?? []).length === 0),
212 Effect.catchTag("InvalidGroup.NotFound", () => Effect.succeed(true)),
213 );
214 expect(sgGone).toBe(true);
215
216 // The revision is deregistered (INACTIVE / DELETE_IN_PROGRESS) or
217 // already hard-deleted.
218 const taskDefGone = yield* ecs
219 .describeTaskDefinition({
220 taskDefinition: deployed.taskDefinitionArn,
221 })
222 .pipe(
223 Effect.map((r) => r.taskDefinition?.status !== "ACTIVE"),
224 Effect.catchTag("ClientException", () => Effect.succeed(true)),
225 );
226 expect(taskDefGone).toBe(true);
227
228 const clusters = yield* ecs.describeClusters({
229 clusters: ["alchemy-test-ecs-service-platform"],
230 });
231 expect((clusters.clusters ?? []).some((c) => c.status === "ACTIVE")).toBe(
232 false,
233 );
234 }),
235 {
236 tags: [
237 "provider:aws",
238 "provider:aws:ec2",
239 "provider:aws:ecr",
240 "provider:aws:ecs",
241 "provider:aws:iam",
242 "live",
243 ],
244 timeout: 420_000,
245 },
246);
247
248// Migration reap: state rows written by the pre-composition provider carry
249// the inline-created ALB/TG/listener/SG ARNs in the service's own attributes
250// with no `ingressKind` marker. The first reconcile under the composed shape
251// must DELETE that legacy inline infrastructure (nothing stranded) and stamp
252// the composed attrs. We simulate the legacy row exactly the way the #736
253// test simulates a wedged one: deploy under the new shape, create a real
254// "legacy" ALB/TG/listener/SG out of band, then rewrite the service's state
255// row to reference them (marker stripped, `ingress` prop removed,
256// `loadBalancer: true`), and redeploy.
257test.provider(
258 "legacy inline ingress is reaped on first reconcile under the composed shape",
259 (stack) =>
260 Effect.gen(function* () {
261 yield* stack.destroy();
262 const net = yield* getDefaultVpcNetwork;
263
264 const program = Effect.gen(function* () {
265 const cluster = yield* Cluster("LegacyReapCluster", {
266 clusterName: "alchemy-test-ecs-service-legacy-reap",
267 });
268 return yield* Service("LegacyReapEdge", {
269 cluster,
270 image: "busybox:stable",
271 command: ["sh", "-c", "while true; do sleep 30; done"],
272 port: 80,
273 desiredCount: 0,
274 loadBalancer: true,
275 });
276 });
277
278 const deployed = yield* stack.deploy(program);
279 expect(deployed.loadBalancerArn).toBeTruthy();
280
281 // Out-of-band "legacy" inline ingress (never carried any traffic, so
282 // reap deletes are instant). Finalizers reclaim them if the test dies
283 // before the reap runs.
284 const legacyName = "alchemy-test-ecs-svc-legacy";
285 // The scaffolding below is created out-of-band under a fixed name and
286 // reclaimed by finalizers. A hard kill (SIGKILL, machine death) skips
287 // finalizers, so the previous run's copy survives and the next run dies
288 // on DuplicateLoadBalancerName before it can test anything. Reclaim any
289 // same-named leftovers first so the suite is rerunnable after a crash.
290 //
291 // This is scaffolding hygiene, NOT adoption of a leaked stack-managed
292 // resource: these are fixtures the test itself creates, and the reap
293 // under test is unaffected by whether they pre-existed.
294 yield* reclaimLegacyFixtures(legacyName, net.vpcId);
295 const legacySubnets = yield* defaultAzSubnets(net.vpcId);
296 const legacyLb = yield* elbv2.createLoadBalancer({
297 Name: legacyName,
298 Type: "application",
299 Scheme: "internal",
300 Subnets: legacySubnets,
301 });
302 const legacyLbArn = legacyLb.LoadBalancers?.[0]?.LoadBalancerArn!;
303 yield* Effect.addFinalizer(() =>
304 elbv2
305 .deleteLoadBalancer({ LoadBalancerArn: legacyLbArn })
306 .pipe(Effect.ignore),
307 );
308 const legacyTg = yield* elbv2.createTargetGroup({
309 Name: legacyName,
310 VpcId: net.vpcId,
311 TargetType: "ip",
312 Protocol: "HTTP",
313 Port: 80,
314 });
315 const legacyTgArn = legacyTg.TargetGroups?.[0]?.TargetGroupArn!;
316 yield* Effect.addFinalizer(() =>
317 elbv2
318 .deleteTargetGroup({ TargetGroupArn: legacyTgArn })
319 .pipe(Effect.ignore),
320 );
321 const legacyListener = yield* elbv2.createListener({
322 LoadBalancerArn: legacyLbArn,
323 Port: 80,
324 Protocol: "HTTP",
325 DefaultActions: [{ Type: "forward", TargetGroupArn: legacyTgArn }],
326 });
327 const legacyListenerArn = legacyListener.Listeners?.[0]?.ListenerArn!;
328 yield* Effect.addFinalizer(() =>
329 elbv2
330 .deleteListener({ ListenerArn: legacyListenerArn })
331 .pipe(Effect.ignore),
332 );
333 const legacySg = yield* ec2.createSecurityGroup({
334 GroupName: legacyName,
335 Description: "legacy inline ingress SG (reap test)",
336 VpcId: net.vpcId,
337 });
338 const legacySgId = legacySg.GroupId!;
339 yield* Effect.addFinalizer(() =>
340 ec2.deleteSecurityGroup({ GroupId: legacySgId }).pipe(Effect.ignore),
341 );
342
343 // Rewrite the service's state row into the legacy inline shape.
344 const state = yield* yield* State;
345 const stage = stack.stage;
346 const fqns = yield* state.list({ stack: stack.name, stage });
347 const rows = yield* Effect.forEach(fqns, (fqn) =>
348 state
349 .get({ stack: stack.name, stage, fqn })
350 .pipe(Effect.map((row) => ({ fqn, row }))),
351 );
352 const serviceRow = rows.find(
353 (r): r is { fqn: string; row: ResourceState } =>
354 isResourceState(r.row) && r.row.resourceType === "AWS.ECS.Service",
355 );
356 if (!serviceRow) {
357 return yield* Effect.die(
358 new Error("no AWS.ECS.Service state row found after deploy"),
359 );
360 }
361 yield* state.set({
362 stack: stack.name,
363 stage,
364 fqn: serviceRow.fqn,
365 value: {
366 ...serviceRow.row,
367 attr: {
368 ...(serviceRow.row.attr as object),
369 // Legacy rows have the inline ARNs and NO ingressKind marker.
370 ingressKind: undefined,
371 loadBalancerArn: legacyLbArn,
372 targetGroupArn: legacyTgArn,
373 listenerArn: legacyListenerArn,
374 securityGroupId: legacySgId,
375 url: "http://legacy.invalid",
376 },
377 props: {
378 ...serviceRow.row.props,
379 ingress: undefined,
380 loadBalancer: true,
381 },
382 },
383 });
384
385 // Redeploy: reconcile must reap the legacy inline infra and stamp the
386 // composed attrs.
387 const redeployed = yield* stack.deploy(program);
388 expect(redeployed.ingressKind).toBe("owned");
389 expect(redeployed.loadBalancerArn).toBeTruthy();
390 expect(redeployed.loadBalancerArn).not.toBe(legacyLbArn);
391 expect(redeployed.targetGroupArn).not.toBe(legacyTgArn);
392
393 // The redeploy registered a NEW task-definition revision — the
394 // superseded one must be reaped (deregistered + deleted), not left
395 // ACTIVE forever. This is the exact leak this test used to produce:
396 // one stranded ACTIVE revision per run.
397 expect(redeployed.taskDefinitionArn).not.toBe(deployed.taskDefinitionArn);
398 const supersededReaped = yield* ecs
399 .describeTaskDefinition({
400 taskDefinition: deployed.taskDefinitionArn,
401 })
402 .pipe(
403 Effect.map((r) => r.taskDefinition?.status !== "ACTIVE"),
404 Effect.catchTag("ClientException", () => Effect.succeed(true)),
405 );
406 expect(supersededReaped).toBe(true);
407
408 // The legacy inline resources are gone (nothing stranded). The ALB
409 // deletion is async — poll bounded.
410 const legacyLbGone = yield* elbv2
411 .describeLoadBalancers({ LoadBalancerArns: [legacyLbArn] })
412 .pipe(
413 Effect.map((r) => (r.LoadBalancers ?? []).length === 0),
414 Effect.catchTag("LoadBalancerNotFoundException", () =>
415 Effect.succeed(true),
416 ),
417 Effect.repeat({
418 schedule: Schedule.spaced("5 seconds"),
419 until: (gone) => gone,
420 times: 12,
421 }),
422 );
423 expect(legacyLbGone).toBe(true);
424 const legacyTgGone = yield* elbv2
425 .describeTargetGroups({ TargetGroupArns: [legacyTgArn] })
426 .pipe(
427 Effect.map((r) => (r.TargetGroups ?? []).length === 0),
428 Effect.catchTag("TargetGroupNotFoundException", () =>
429 Effect.succeed(true),
430 ),
431 );
432 expect(legacyTgGone).toBe(true);
433 const legacySgGone = yield* ec2
434 .describeSecurityGroups({ GroupIds: [legacySgId] })
435 .pipe(
436 Effect.map((r) => (r.SecurityGroups ?? []).length === 0),
437 Effect.catchTag("InvalidGroup.NotFound", () => Effect.succeed(true)),
438 );
439 expect(legacySgGone).toBe(true);
440
441 yield* stack.destroy();
442
443 // The composed ingress is gone after destroy too.
444 const composedLbGone = yield* elbv2
445 .describeLoadBalancers({
446 LoadBalancerArns: [redeployed.loadBalancerArn!],
447 })
448 .pipe(
449 Effect.map((r) => (r.LoadBalancers ?? []).length === 0),
450 Effect.catchTag("LoadBalancerNotFoundException", () =>
451 Effect.succeed(true),
452 ),
453 );
454 expect(composedLbGone).toBe(true);
455
456 // Zero-orphan proof for the synthesized task definition: NO revision
457 // of the family survives destroy as ACTIVE (deploy + redeploy
458 // registered two revisions; both must be gone).
459 const activeRevisions = yield* ecs
460 .listTaskDefinitions({
461 familyPrefix: redeployed.taskFamily!,
462 status: "ACTIVE",
463 })
464 .pipe(
465 Effect.map((r) =>
466 (r.taskDefinitionArns ?? []).filter((arn) =>
467 arn.includes(`/${redeployed.taskFamily!}:`),
468 ),
469 ),
470 Effect.catchTag("ClientException", () =>
471 Effect.succeed([] as string[]),
472 ),
473 );
474 expect(activeRevisions).toEqual([]);
475 }),
476 {
477 tags: ["provider:aws", "provider:aws:ec2", "provider:aws:ecs", "live"],
478 timeout: 420_000,
479 },
480);