1import * as ec2 from "@distilled.cloud/aws/ec2";
2import * as ecr from "@distilled.cloud/aws/ecr";
3import * as ecs from "@distilled.cloud/aws/ecs";
4import * as elbv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
5import * as iam from "@distilled.cloud/aws/iam";
6import * as logs from "@distilled.cloud/aws/cloudwatch-logs";
7import * as Effect from "effect/Effect";
8import * as Schedule from "effect/Schedule";
9import * as Stream from "effect/Stream";
10
11/**
12 * Orphan reclaim for the Fargate E2E smoke test (`Task.smoke.test.ts`).
13 *
14 * The smoke test's scratch stack keeps its state IN MEMORY, so a hard-killed
15 * run (vitest hard timeout, OOM, SIGKILL) orphans everything it deployed:
16 * the `AWS.ECS.Task` resource's out-of-band IAM roles / ECR repository / log
17 * group / task-definition family, the `AWS.ECS.Service`'s ALB + target group,
18 * and the EC2 networking chain (VPC/IGW/subnets/route tables/security group).
19 * Physical names carry a random per-instance suffix, so a later run cannot
20 * reclaim them through the engine — this sweep finds them by their
21 * DETERMINISTIC name prefixes (mirroring `createPhysicalName`) and by the
22 * `alchemy::stack` ownership tag, and deletes them idempotently.
23 *
24 * Run it BEFORE the test (pre-clean recovers a previously killed run) and as
25 * a finalizer AFTER (a passing run leaves ZERO leftovers — including the
26 * INACTIVE task-definition revisions that `deregisterTaskDefinition` alone
27 * leaves behind).
28 */
29
30/**
31 * The exact test title — it becomes the scratch stack name (see
32 * `sanitizeStackName` in `Test/Core.ts`), which prefixes every physical name
33 * this test creates. The smoke test imports this constant so the title and
34 * the sweep can never drift apart.
35 */
36export const E2E_TEST_TITLE =
37 "deploys a real Fargate task that serves HTTP and runs a background loop";
38
39/** Deterministic cluster name pinned by the smoke test's `clusterName` prop. */
40export const E2E_CLUSTER_NAME = "alchemy-test-ecs-task-e2e";
41
42// Mirror of Test/Core.ts `sanitizeStackName`.
43const STACK_NAME = E2E_TEST_TITLE.replaceAll(/[^a-zA-Z0-9_]/g, "-").replace(
44 /-+/g,
45 "-",
46);
47
48const STAGE = "test";
49
50/**
51 * Mirror of `createPhysicalName`'s deterministic portion: the full name is
52 * `${stack}-${id}-${stage}-${16-char-suffix}` truncated to `maxLength` by
53 * slicing the prefix to `maxLength - 16`. Everything before the random
54 * suffix is deterministic — that's what we prefix-match on.
55 */
56const physicalNamePrefix = (
57 id: string,
58 { maxLength, lowercase = false }: { maxLength: number; lowercase?: boolean },
59) => {
60 const prefix = `${STACK_NAME}-${id}-${STAGE}-`;
61 const sliced =
62 prefix.length + 16 > maxLength ? prefix.slice(0, maxLength - 16) : prefix;
63 return (lowercase ? sliced.toLowerCase() : sliced).replaceAll(
64 lowercase ? /[^a-z0-9-]/g : /[^a-zA-Z0-9-]/g,
65 "-",
66 );
67};
68
69// AWS.ECS.Task out-of-band names (logical id "EcsE2ETask").
70const ROLE_PREFIX = physicalNamePrefix("EcsE2ETask-task-role", {
71 maxLength: 64,
72});
73const REPO_PREFIX = physicalNamePrefix("EcsE2ETask-repo", {
74 maxLength: 256,
75 lowercase: true,
76});
77const LOG_GROUP_PREFIX = physicalNamePrefix("EcsE2ETask-logs", {
78 maxLength: 512,
79 lowercase: true,
80});
81const FAMILY_PREFIX = physicalNamePrefix("EcsE2ETask", {
82 maxLength: 255,
83 lowercase: true,
84});
85// AWS.ECS.Service managed ingress (logical id "EcsE2EService"). ALB/TG names
86// are capped at 32 chars, so only the first 16 chars are deterministic —
87// broad, so matches are additionally verified against the `alchemy::stack`
88// tag before deletion.
89const ALB_PREFIX = physicalNamePrefix("EcsE2EService-alb", {
90 maxLength: 32,
91 lowercase: true,
92});
93const TG_PREFIX = physicalNamePrefix("EcsE2EService-tg", {
94 maxLength: 32,
95 lowercase: true,
96});
97
98/**
99 * Bounded wait for the dependency-drain errors every step of the teardown can
100 * hit while ECS task / ALB ENIs release (~55s budget per step, only paid when
101 * an orphan actually exists).
102 */
103const drainSchedule = Schedule.spaced("5 seconds");
104const DRAIN_ATTEMPTS = 11;
105
106const reclaimEcsCluster = Effect.gen(function* () {
107 // Delete any services in the deterministic cluster (force detaches them
108 // from the ALB and stops their tasks).
109 const serviceArns = yield* ecs
110 .listServices({ cluster: E2E_CLUSTER_NAME })
111 .pipe(
112 Effect.map((r) => r.serviceArns ?? []),
113 Effect.catchTag("ClusterNotFoundException", () =>
114 Effect.succeed([] as string[]),
115 ),
116 );
117 yield* Effect.forEach(serviceArns, (serviceArn) =>
118 ecs
119 .deleteService({
120 cluster: E2E_CLUSTER_NAME,
121 service: serviceArn,
122 force: true,
123 })
124 .pipe(
125 Effect.catchTag(
126 ["ServiceNotFoundException", "ClusterNotFoundException"],
127 () => Effect.void,
128 ),
129 ),
130 );
131
132 // Stop any straggler tasks so the cluster drains quickly.
133 const taskArns = yield* ecs.listTasks({ cluster: E2E_CLUSTER_NAME }).pipe(
134 Effect.map((r) => r.taskArns ?? []),
135 Effect.catchTag("ClusterNotFoundException", () =>
136 Effect.succeed([] as string[]),
137 ),
138 );
139 yield* Effect.forEach(taskArns, (taskArn) =>
140 ecs
141 .stopTask({
142 cluster: E2E_CLUSTER_NAME,
143 task: taskArn,
144 reason: "Task.smoke.test.ts orphan reclaim",
145 })
146 .pipe(
147 Effect.catchTag(
148 ["ClusterNotFoundException", "InvalidParameterException"],
149 () => Effect.void,
150 ),
151 ),
152 );
153
154 yield* ecs.deleteCluster({ cluster: E2E_CLUSTER_NAME }).pipe(
155 Effect.retry({
156 while: (e) =>
157 e._tag === "ClusterContainsServicesException" ||
158 e._tag === "ClusterContainsTasksException" ||
159 e._tag === "UpdateInProgressException",
160 schedule: drainSchedule,
161 times: DRAIN_ATTEMPTS,
162 }),
163 Effect.catchTag("ClusterNotFoundException", () => Effect.void),
164 Effect.asVoid,
165 );
166});
167
168// True when the resource's ELBv2 tags brand it as owned by this test's stack.
169const isOwnedElbResource = (arn: string) =>
170 elbv2
171 .describeTags({ ResourceArns: [arn] })
172 .pipe(
173 Effect.map((r) =>
174 (r.TagDescriptions?.[0]?.Tags ?? []).some(
175 (tag) => tag.Key === "alchemy::stack" && tag.Value === STACK_NAME,
176 ),
177 ),
178 );
179
180const reclaimIngress = Effect.gen(function* () {
181 const loadBalancers = yield* elbv2.describeLoadBalancers.items({}).pipe(
182 Stream.runCollect,
183 Effect.map((chunk) => Array.from(chunk)),
184 );
185 for (const lb of loadBalancers) {
186 if (
187 !lb.LoadBalancerArn ||
188 !lb.LoadBalancerName?.startsWith(ALB_PREFIX) ||
189 !(yield* isOwnedElbResource(lb.LoadBalancerArn))
190 ) {
191 continue;
192 }
193 yield* elbv2
194 .deleteLoadBalancer({ LoadBalancerArn: lb.LoadBalancerArn })
195 .pipe(
196 Effect.catchTag("LoadBalancerNotFoundException", () => Effect.void),
197 Effect.asVoid,
198 );
199 }
200
201 const targetGroups = yield* elbv2.describeTargetGroups.items({}).pipe(
202 Stream.runCollect,
203 Effect.map((chunk) => Array.from(chunk)),
204 );
205 for (const tg of targetGroups) {
206 if (
207 !tg.TargetGroupArn ||
208 !tg.TargetGroupName?.startsWith(TG_PREFIX) ||
209 !(yield* isOwnedElbResource(tg.TargetGroupArn))
210 ) {
211 continue;
212 }
213 // A TG stays in-use until its ALB's deletion propagates.
214 yield* elbv2.deleteTargetGroup({ TargetGroupArn: tg.TargetGroupArn }).pipe(
215 Effect.retry({
216 while: (e) => e._tag === "ResourceInUseException",
217 schedule: drainSchedule,
218 times: DRAIN_ATTEMPTS,
219 }),
220 Effect.asVoid,
221 );
222 }
223});
224
225const reclaimNetworking = Effect.gen(function* () {
226 const vpcs = yield* ec2
227 .describeVpcs({
228 Filters: [{ Name: "tag:alchemy::stack", Values: [STACK_NAME] }],
229 })
230 .pipe(Effect.map((r) => r.Vpcs ?? []));
231
232 for (const vpc of vpcs) {
233 const vpcId = vpc.VpcId!;
234
235 // Route tables: disassociate the non-main associations, delete non-main
236 // tables.
237 const routeTables = yield* ec2
238 .describeRouteTables({
239 Filters: [{ Name: "vpc-id", Values: [vpcId] }],
240 })
241 .pipe(Effect.map((r) => r.RouteTables ?? []));
242 for (const routeTable of routeTables) {
243 const associations = routeTable.Associations ?? [];
244 for (const association of associations) {
245 if (!association.Main && association.RouteTableAssociationId) {
246 yield* ec2
247 .disassociateRouteTable({
248 AssociationId: association.RouteTableAssociationId,
249 })
250 .pipe(
251 Effect.catchTag(
252 "InvalidAssociationID.NotFound",
253 () => Effect.void,
254 ),
255 Effect.asVoid,
256 );
257 }
258 }
259 if (
260 routeTable.RouteTableId &&
261 !associations.some((association) => association.Main)
262 ) {
263 yield* ec2
264 .deleteRouteTable({ RouteTableId: routeTable.RouteTableId })
265 .pipe(
266 Effect.retry({
267 while: (e) => e._tag === "DependencyViolation",
268 schedule: drainSchedule,
269 times: DRAIN_ATTEMPTS,
270 }),
271 Effect.catchTag("InvalidRouteTableID.NotFound", () => Effect.void),
272 Effect.asVoid,
273 );
274 }
275 }
276
277 // Internet gateways: detach, then delete.
278 const igws = yield* ec2
279 .describeInternetGateways({
280 Filters: [{ Name: "attachment.vpc-id", Values: [vpcId] }],
281 })
282 .pipe(Effect.map((r) => r.InternetGateways ?? []));
283 for (const igw of igws) {
284 if (!igw.InternetGatewayId) continue;
285 yield* ec2
286 .detachInternetGateway({
287 InternetGatewayId: igw.InternetGatewayId,
288 VpcId: vpcId,
289 })
290 .pipe(
291 Effect.retry({
292 while: (e) => e._tag === "DependencyViolation",
293 schedule: drainSchedule,
294 times: DRAIN_ATTEMPTS,
295 }),
296 Effect.catchTag(
297 ["Gateway.NotAttached", "InvalidInternetGatewayID.NotFound"],
298 () => Effect.void,
299 ),
300 Effect.asVoid,
301 );
302 yield* ec2
303 .deleteInternetGateway({ InternetGatewayId: igw.InternetGatewayId })
304 .pipe(
305 Effect.retry({
306 while: (e) => e._tag === "DependencyViolation",
307 schedule: drainSchedule,
308 times: DRAIN_ATTEMPTS,
309 }),
310 Effect.catchTag(
311 "InvalidInternetGatewayID.NotFound",
312 () => Effect.void,
313 ),
314 Effect.asVoid,
315 );
316 }
317
318 // Security groups (waits out lingering task/ALB ENIs).
319 const securityGroups = yield* ec2
320 .describeSecurityGroups({
321 Filters: [{ Name: "vpc-id", Values: [vpcId] }],
322 })
323 .pipe(Effect.map((r) => r.SecurityGroups ?? []));
324 for (const securityGroup of securityGroups) {
325 if (!securityGroup.GroupId || securityGroup.GroupName === "default") {
326 continue;
327 }
328 yield* ec2.deleteSecurityGroup({ GroupId: securityGroup.GroupId }).pipe(
329 Effect.retry({
330 while: (e) => e._tag === "DependencyViolation",
331 schedule: drainSchedule,
332 times: DRAIN_ATTEMPTS,
333 }),
334 Effect.catchTag("InvalidGroup.NotFound", () => Effect.void),
335 Effect.asVoid,
336 );
337 }
338
339 // Subnets.
340 const subnets = yield* ec2
341 .describeSubnets({ Filters: [{ Name: "vpc-id", Values: [vpcId] }] })
342 .pipe(Effect.map((r) => r.Subnets ?? []));
343 for (const subnet of subnets) {
344 if (!subnet.SubnetId) continue;
345 yield* ec2.deleteSubnet({ SubnetId: subnet.SubnetId }).pipe(
346 Effect.retry({
347 while: (e) => e._tag === "DependencyViolation",
348 schedule: drainSchedule,
349 times: DRAIN_ATTEMPTS,
350 }),
351 Effect.catchTag("InvalidSubnetID.NotFound", () => Effect.void),
352 Effect.asVoid,
353 );
354 }
355
356 yield* ec2.deleteVpc({ VpcId: vpcId }).pipe(
357 Effect.retry({
358 while: (e) => e._tag === "DependencyViolation",
359 schedule: drainSchedule,
360 times: DRAIN_ATTEMPTS,
361 }),
362 Effect.catchTag("InvalidVpcID.NotFound", () => Effect.void),
363 Effect.asVoid,
364 );
365 }
366
367 // The smoke fixture normally reuses the standing default VPC, so its
368 // test-owned security group is not nested under a test-owned VPC above.
369 // Select it by the exact stack tag and leave every shared/default group
370 // untouched.
371 const ownedSecurityGroups = yield* ec2
372 .describeSecurityGroups({
373 Filters: [{ Name: "tag:alchemy::stack", Values: [STACK_NAME] }],
374 })
375 .pipe(Effect.map((r) => r.SecurityGroups ?? []));
376 for (const securityGroup of ownedSecurityGroups) {
377 if (!securityGroup.GroupId || securityGroup.GroupName === "default") {
378 continue;
379 }
380 yield* ec2.deleteSecurityGroup({ GroupId: securityGroup.GroupId }).pipe(
381 Effect.retry({
382 while: (e) => e._tag === "DependencyViolation",
383 schedule: drainSchedule,
384 times: DRAIN_ATTEMPTS,
385 }),
386 Effect.catchTag("InvalidGroup.NotFound", () => Effect.void),
387 Effect.asVoid,
388 );
389 }
390});
391
392const reclaimEcrRepositories = Effect.gen(function* () {
393 const repositories = yield* ecr.describeRepositories.items({}).pipe(
394 Stream.runCollect,
395 Effect.map((chunk) => Array.from(chunk)),
396 );
397 for (const repository of repositories) {
398 if (!repository.repositoryName?.startsWith(REPO_PREFIX)) continue;
399 yield* ecr
400 .deleteRepository({
401 repositoryName: repository.repositoryName,
402 force: true,
403 })
404 .pipe(
405 Effect.catchTag("RepositoryNotFoundException", () => Effect.void),
406 Effect.asVoid,
407 );
408 }
409});
410
411const reclaimLogGroups = Effect.gen(function* () {
412 const logGroups = yield* logs
413 .describeLogGroups({ logGroupNamePrefix: LOG_GROUP_PREFIX })
414 .pipe(Effect.map((r) => r.logGroups ?? []));
415 for (const logGroup of logGroups) {
416 if (!logGroup.logGroupName) continue;
417 yield* logs.deleteLogGroup({ logGroupName: logGroup.logGroupName }).pipe(
418 Effect.catchTag("ResourceNotFoundException", () => Effect.void),
419 Effect.asVoid,
420 );
421 }
422});
423
424const reclaimIamRoles = Effect.gen(function* () {
425 const roles = yield* iam.listRoles.items({}).pipe(
426 Stream.runCollect,
427 Effect.map((chunk) => Array.from(chunk)),
428 );
429 for (const role of roles) {
430 const roleName = role.RoleName;
431 if (!roleName?.startsWith(ROLE_PREFIX)) continue;
432
433 const inlinePolicies = yield* iam
434 .listRolePolicies({ RoleName: roleName })
435 .pipe(
436 Effect.map((r) => r.PolicyNames ?? []),
437 Effect.catchTag("NoSuchEntityException", () =>
438 Effect.succeed([] as string[]),
439 ),
440 );
441 yield* Effect.forEach(inlinePolicies, (policyName) =>
442 iam
443 .deleteRolePolicy({ RoleName: roleName, PolicyName: policyName })
444 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)),
445 );
446
447 const attached = yield* iam
448 .listAttachedRolePolicies({ RoleName: roleName })
449 .pipe(
450 Effect.map((r) => r.AttachedPolicies ?? []),
451 Effect.catchTag("NoSuchEntityException", () => Effect.succeed([])),
452 );
453 yield* Effect.forEach(attached, (policy) =>
454 iam
455 .detachRolePolicy({ RoleName: roleName, PolicyArn: policy.PolicyArn! })
456 .pipe(Effect.catchTag("NoSuchEntityException", () => Effect.void)),
457 );
458
459 yield* iam.deleteRole({ RoleName: roleName }).pipe(
460 Effect.catchTag("NoSuchEntityException", () => Effect.void),
461 Effect.asVoid,
462 );
463 }
464});
465
466/**
467 * Every task-definition family this test ever created (each run mints a
468 * fresh random-suffixed family). NOTE: `listTaskDefinitionFamilies` is a
469 * true prefix match; `listTaskDefinitions`' `familyPrefix` only matches the
470 * EXACT family name (verified live), so families must be enumerated first.
471 */
472const listE2EFamilies = ecs.listTaskDefinitionFamilies
473 .items({ familyPrefix: FAMILY_PREFIX, status: "ALL" })
474 .pipe(
475 Stream.runCollect,
476 Effect.map((chunk) => Array.from(chunk)),
477 );
478
479const listFamilyArns = (family: string, status: "ACTIVE" | "INACTIVE") =>
480 ecs
481 .listTaskDefinitions({ familyPrefix: family, status })
482 .pipe(Effect.map((r) => r.taskDefinitionArns ?? []));
483
484const reclaimTaskDefinitions = Effect.gen(function* () {
485 const families = yield* listE2EFamilies;
486 for (const family of families) {
487 const active = yield* listFamilyArns(family, "ACTIVE");
488 yield* Effect.forEach(active, (arn) =>
489 ecs
490 .deregisterTaskDefinition({ taskDefinition: arn })
491 .pipe(Effect.catchTag("ClientException", () => Effect.void)),
492 );
493
494 const inactive = yield* listFamilyArns(family, "INACTIVE");
495 for (let i = 0; i < inactive.length; i += 10) {
496 yield* ecs
497 .deleteTaskDefinitions({ taskDefinitions: inactive.slice(i, i + 10) })
498 .pipe(Effect.catchTag("ClientException", () => Effect.void));
499 }
500 }
501});
502
503/**
504 * Idempotent full sweep, ordered so dependencies drain before their
505 * dependents are deleted: ECS service/cluster → ALB/TG → EC2 networking →
506 * ECR → log groups → IAM roles → task-definition revisions.
507 */
508export const reclaimTaskE2EOrphans = Effect.gen(function* () {
509 yield* reclaimEcsCluster;
510 yield* reclaimIngress;
511 yield* reclaimNetworking;
512 yield* reclaimEcrRepositories;
513 yield* reclaimLogGroups;
514 yield* reclaimIamRoles;
515 yield* reclaimTaskDefinitions;
516});
517
518/**
519 * Read-only clean-slate audit: every cloud resource the smoke test can
520 * possibly leave behind, as human-readable identifiers. The smoke test
521 * asserts this is EMPTY after teardown, proving a passing run leaves zero
522 * leftovers.
523 */
524export const scanTaskE2EOrphans = Effect.gen(function* () {
525 const leftovers: string[] = [];
526
527 const clusters = yield* ecs
528 .describeClusters({ clusters: [E2E_CLUSTER_NAME] })
529 .pipe(Effect.map((r) => r.clusters ?? []));
530 for (const cluster of clusters) {
531 if (cluster.status === "ACTIVE") {
532 leftovers.push(`ecs cluster ${cluster.clusterName}`);
533 }
534 }
535
536 const loadBalancers = yield* elbv2.describeLoadBalancers.items({}).pipe(
537 Stream.runCollect,
538 Effect.map((chunk) => Array.from(chunk)),
539 );
540 for (const lb of loadBalancers) {
541 if (
542 lb.LoadBalancerArn &&
543 lb.LoadBalancerName?.startsWith(ALB_PREFIX) &&
544 (yield* isOwnedElbResource(lb.LoadBalancerArn))
545 ) {
546 leftovers.push(`load balancer ${lb.LoadBalancerName}`);
547 }
548 }
549 const targetGroups = yield* elbv2.describeTargetGroups.items({}).pipe(
550 Stream.runCollect,
551 Effect.map((chunk) => Array.from(chunk)),
552 );
553 for (const tg of targetGroups) {
554 if (
555 tg.TargetGroupArn &&
556 tg.TargetGroupName?.startsWith(TG_PREFIX) &&
557 (yield* isOwnedElbResource(tg.TargetGroupArn))
558 ) {
559 leftovers.push(`target group ${tg.TargetGroupName}`);
560 }
561 }
562
563 const vpcs = yield* ec2
564 .describeVpcs({
565 Filters: [{ Name: "tag:alchemy::stack", Values: [STACK_NAME] }],
566 })
567 .pipe(Effect.map((r) => r.Vpcs ?? []));
568 for (const vpc of vpcs) {
569 leftovers.push(`vpc ${vpc.VpcId}`);
570 }
571 const securityGroups = yield* ec2
572 .describeSecurityGroups({
573 Filters: [{ Name: "tag:alchemy::stack", Values: [STACK_NAME] }],
574 })
575 .pipe(Effect.map((r) => r.SecurityGroups ?? []));
576 for (const securityGroup of securityGroups) {
577 leftovers.push(`security group ${securityGroup.GroupId}`);
578 }
579
580 const repositories = yield* ecr.describeRepositories.items({}).pipe(
581 Stream.runCollect,
582 Effect.map((chunk) => Array.from(chunk)),
583 );
584 for (const repository of repositories) {
585 if (repository.repositoryName?.startsWith(REPO_PREFIX)) {
586 leftovers.push(`ecr repository ${repository.repositoryName}`);
587 }
588 }
589
590 const logGroups = yield* logs
591 .describeLogGroups({ logGroupNamePrefix: LOG_GROUP_PREFIX })
592 .pipe(Effect.map((r) => r.logGroups ?? []));
593 for (const logGroup of logGroups) {
594 leftovers.push(`log group ${logGroup.logGroupName}`);
595 }
596
597 const roles = yield* iam.listRoles.items({}).pipe(
598 Stream.runCollect,
599 Effect.map((chunk) => Array.from(chunk)),
600 );
601 for (const role of roles) {
602 if (role.RoleName?.startsWith(ROLE_PREFIX)) {
603 leftovers.push(`iam role ${role.RoleName}`);
604 }
605 }
606
607 const families = yield* listE2EFamilies;
608 for (const family of families) {
609 for (const status of ["ACTIVE", "INACTIVE"] as const) {
610 const arns = yield* listFamilyArns(family, status);
611 for (const arn of arns) {
612 leftovers.push(`task definition ${arn}`);
613 }
614 }
615 }
616
617 return leftovers;
618});