1#!/usr/bin/env bun
2/**
3 * AWS leak sweep — finds (and optionally deletes) alchemy-tagged TEST resources
4 * left behind by crashed/interrupted test runs.
5 *
6 * Discovery is tag-driven: every alchemy resource is branded with
7 * `alchemy::stack` / `alchemy::stage` / `alchemy::id` (see src/Tags.ts
8 * createInternalTags). Test suites deploy with stage `test_$USER` (the default
9 * in packages/alchemy/src/Test/Core.ts), so we sweep everything tagged
10 * `alchemy::stage = test_$USER`.
11 *
12 * Primary discovery path: resourcegroupstaggingapi GetResources (regional).
13 * Fallbacks for resources the tagging API can't see from a regional query:
14 * - IAM roles/policies (global service): listRoles/listPolicies + tag reads
15 * - EventBridge Scheduler schedules (not taggable): name-pattern heuristic
16 * (physical names embed `-{stage}-`, see src/PhysicalName.ts)
17 *
18 * Usage:
19 * bun scripts/aws-leak-sweep.ts # DRY RUN (default)
20 * bun scripts/aws-leak-sweep.ts --delete # actually delete
21 * bun scripts/aws-leak-sweep.ts --older-than 12 # only >= 12h old (default 6)
22 * bun scripts/aws-leak-sweep.ts --stage test_sam # tag stage filter (default test_$USER)
23 * bun scripts/aws-leak-sweep.ts --region us-west-2 # region (default env or us-west-2)
24 *
25 * KMS keys are ALWAYS report-only — never deleted by this tool.
26 * Resources whose age can't be determined are treated as eligible (a leak with
27 * an unknown birthday is still a leak) and shown with age `?`.
28 */
29import * as Effect from "effect/Effect";
30import * as Layer from "effect/Layer";
31import * as Result from "effect/Result";
32import * as Schedule from "effect/Schedule";
33import { FetchHttpClient } from "effect/http";
34import type * as HttpClient from "effect/http/HttpClient";
35
36import { fromChain } from "@distilled.cloud/aws/Credentials";
37import { Region } from "@distilled.cloud/aws/Region";
38
39import * as Logs from "@distilled.cloud/aws/cloudwatch-logs";
40import * as DynamoDB from "@distilled.cloud/aws/dynamodb";
41import * as EC2 from "@distilled.cloud/aws/ec2";
42import * as ECS from "@distilled.cloud/aws/ecs";
43import * as EFS from "@distilled.cloud/aws/efs";
44import * as ELBv2 from "@distilled.cloud/aws/elastic-load-balancing-v2";
45import * as EventBridge from "@distilled.cloud/aws/eventbridge";
46import * as IAM from "@distilled.cloud/aws/iam";
47import * as Kinesis from "@distilled.cloud/aws/kinesis";
48import * as KMS from "@distilled.cloud/aws/kms";
49import * as Lambda from "@distilled.cloud/aws/lambda";
50import * as RDS from "@distilled.cloud/aws/rds";
51import {
52 getResources,
53 type GetResourcesOutput,
54} from "@distilled.cloud/aws/resource-groups-tagging-api";
55import * as S3 from "@distilled.cloud/aws/s3";
56import * as Scheduler from "@distilled.cloud/aws/scheduler";
57import * as SecretsManager from "@distilled.cloud/aws/secrets-manager";
58import * as SNS from "@distilled.cloud/aws/sns";
59import * as SQS from "@distilled.cloud/aws/sqs";
60import * as SSM from "@distilled.cloud/aws/ssm";
61
62// ---------------------------------------------------------------------------
63// CLI args
64// ---------------------------------------------------------------------------
65
66interface Args {
67 delete: boolean;
68 olderThanHours: number;
69 stage: string;
70 region: string;
71}
72
73const parseArgs = Effect.sync((): Args => {
74 const argv = process.argv.slice(2);
75 const flag = (name: string) => argv.includes(`--${name}`);
76 const opt = (name: string): string | undefined => {
77 const i = argv.indexOf(`--${name}`);
78 return i >= 0 ? argv[i + 1] : undefined;
79 };
80 return {
81 delete: flag("delete"),
82 olderThanHours: Number(opt("older-than") ?? 6),
83 stage:
84 opt("stage") ??
85 `test_${process.env.USER || process.env.USERNAME || "unknown"}`,
86 region:
87 opt("region") ??
88 process.env.AWS_REGION ??
89 process.env.AWS_DEFAULT_REGION ??
90 "us-west-2",
91 };
92});
93
94// ---------------------------------------------------------------------------
95// Model
96// ---------------------------------------------------------------------------
97
98type Env =
99 | import("@distilled.cloud/aws/Credentials").Credentials
100 | Region
101 | HttpClient.HttpClient;
102
103interface Leak {
104 arn: string;
105 /** display kind, e.g. "sqs:queue" */
106 kind: string;
107 /** physical name / identifier used by delete calls */
108 name: string;
109 tags: Record<string, string>;
110 createdAt: Date | undefined;
111 hourly: boolean;
112 reportOnly: boolean;
113 reportOnlyReason?: string;
114 /** deletion ordering: lower phases first */
115 phase: number;
116}
117
118const toDate = (v: Date | string | number | undefined): Date | undefined => {
119 if (v === undefined || v === null) return undefined;
120 if (v instanceof Date) return v;
121 if (typeof v === "number") {
122 // seconds vs millis
123 return new Date(v > 10_000_000_000 ? v : v * 1000);
124 }
125 const n = Number(v);
126 if (!Number.isNaN(n) && v.trim() !== "") {
127 return new Date(n > 10_000_000_000 ? n : n * 1000);
128 }
129 const d = new Date(v);
130 return Number.isNaN(d.getTime()) ? undefined : d;
131};
132
133const ageHours = (d: Date | undefined): number | undefined =>
134 d === undefined ? undefined : (Date.now() - d.getTime()) / 3_600_000;
135
136const tagRecord = (
137 tags: readonly { Key?: string; Value?: string }[] | undefined,
138): Record<string, string> =>
139 Object.fromEntries(
140 (tags ?? [])
141 .filter((t) => t.Key !== undefined)
142 .map((t) => [t.Key as string, t.Value ?? ""]),
143 );
144
145/** best-effort: any failure (missing perms, races, region mismatch) -> undefined */
146const tryDate = <E, R>(
147 eff: Effect.Effect<Date | string | number | undefined, E, R>,
148): Effect.Effect<Date | undefined, never, R> =>
149 eff.pipe(
150 Effect.map(toDate),
151 Effect.catch(() => Effect.succeed(undefined)),
152 );
153
154// ---------------------------------------------------------------------------
155// ARN classification
156// ---------------------------------------------------------------------------
157
158interface Parsed {
159 service: string;
160 region: string;
161 account: string;
162 rest: string;
163}
164
165const parseArn = (arn: string): Parsed | undefined => {
166 const parts = arn.split(":");
167 if (parts.length < 6 || parts[0] !== "arn") return undefined;
168 return {
169 service: parts[2],
170 region: parts[3],
171 account: parts[4],
172 rest: parts.slice(5).join(":"),
173 };
174};
175
176interface Classified {
177 kind: string;
178 name: string;
179 hourly: boolean;
180 reportOnly: boolean;
181 reportOnlyReason?: string;
182 phase: number;
183}
184
185const classify = (arn: string): Classified => {
186 const p = parseArn(arn);
187 const unsupported = (reason: string): Classified => ({
188 kind: `${p?.service ?? "?"}:?`,
189 name: p?.rest ?? arn,
190 hourly: false,
191 reportOnly: true,
192 reportOnlyReason: reason,
193 phase: 99,
194 });
195 if (!p) return unsupported("unparseable arn");
196 const r = p.rest;
197 const mk = (
198 kind: string,
199 name: string,
200 phase: number,
201 opts?: { hourly?: boolean; reportOnly?: boolean; reason?: string },
202 ): Classified => ({
203 kind,
204 name,
205 phase,
206 hourly: opts?.hourly ?? false,
207 reportOnly: opts?.reportOnly ?? false,
208 reportOnlyReason: opts?.reason,
209 });
210
211 switch (p.service) {
212 case "sqs":
213 return mk("sqs:queue", r, 2);
214 case "sns":
215 return mk("sns:topic", r, 2);
216 case "dynamodb":
217 if (r.startsWith("table/") && !r.includes("/stream/")) {
218 return mk("dynamodb:table", r.slice("table/".length), 2);
219 }
220 return unsupported("only tables are swept");
221 case "lambda":
222 if (r.startsWith("function:")) {
223 return mk("lambda:function", r.split(":")[1], 2);
224 }
225 return unsupported("only functions are swept");
226 case "logs":
227 if (r.startsWith("log-group:")) {
228 return mk(
229 "logs:log-group",
230 r.slice("log-group:".length).replace(/:\*$/, ""),
231 2,
232 );
233 }
234 return unsupported("only log groups are swept");
235 case "s3":
236 return mk("s3:bucket", r, 2);
237 case "kinesis":
238 if (r.startsWith("stream/")) {
239 return mk("kinesis:stream", r.slice("stream/".length), 2);
240 }
241 return unsupported("only streams are swept");
242 case "events": {
243 if (r.startsWith("rule/")) {
244 return mk("events:rule", r.slice("rule/".length), 1);
245 }
246 if (r.startsWith("event-bus/")) {
247 return mk("events:event-bus", r.slice("event-bus/".length), 3);
248 }
249 return unsupported("only rules and buses are swept");
250 }
251 case "ecs": {
252 if (r.startsWith("service/")) return mk("ecs:service", r, 1);
253 if (r.startsWith("cluster/")) {
254 return mk("ecs:cluster", r.slice("cluster/".length), 3);
255 }
256 if (r.startsWith("task-definition/")) {
257 return mk(
258 "ecs:task-definition",
259 r.slice("task-definition/".length),
260 2,
261 );
262 }
263 if (r.startsWith("task/")) {
264 return mk("ecs:task", r, 99, {
265 reportOnly: true,
266 reason: "tasks die with their service/cluster",
267 });
268 }
269 return unsupported("unhandled ecs type");
270 }
271 case "elasticloadbalancing": {
272 if (r.startsWith("loadbalancer/")) {
273 return mk("elbv2:load-balancer", arn, 3, { hourly: true });
274 }
275 if (r.startsWith("targetgroup/")) {
276 return mk("elbv2:target-group", arn, 4);
277 }
278 if (r.startsWith("listener/")) {
279 return mk("elbv2:listener", arn, 99, {
280 reportOnly: true,
281 reason: "listeners die with their load balancer",
282 });
283 }
284 return unsupported("unhandled elb type");
285 }
286 case "ec2": {
287 if (r.startsWith("natgateway/")) {
288 return mk("ec2:nat-gateway", r.slice("natgateway/".length), 3, {
289 hourly: true,
290 });
291 }
292 if (r.startsWith("elastic-ip/")) {
293 return mk("ec2:elastic-ip", r.slice("elastic-ip/".length), 4, {
294 hourly: true,
295 });
296 }
297 if (r.startsWith("vpc/")) {
298 return mk("ec2:vpc", r.slice("vpc/".length), 5);
299 }
300 // subnets, route tables, IGWs, SGs etc. are swept as part of their
301 // VPC's teardown; report them so nothing is silently ignored.
302 return mk(`ec2:${r.split("/")[0]}`, r, 99, {
303 reportOnly: true,
304 reason: "deleted as part of the owning VPC's teardown",
305 });
306 }
307 case "rds": {
308 if (r.startsWith("db:")) {
309 return mk("rds:instance", r.slice("db:".length), 2, { hourly: true });
310 }
311 if (r.startsWith("cluster:")) {
312 return mk("rds:cluster", r.slice("cluster:".length), 3, {
313 hourly: true,
314 });
315 }
316 return mk(`rds:${r.split(":")[0]}`, r, 99, {
317 reportOnly: true,
318 reason: "only instances/clusters are swept",
319 });
320 }
321 case "elasticfilesystem": {
322 if (r.startsWith("file-system/")) {
323 return mk("efs:file-system", r.slice("file-system/".length), 2);
324 }
325 return unsupported("only file systems are swept");
326 }
327 case "kms":
328 if (r.startsWith("key/")) {
329 return mk("kms:key", r.slice("key/".length), 99, {
330 reportOnly: true,
331 reason: "KMS keys are NEVER deleted by this tool",
332 });
333 }
334 return unsupported("only keys are reported");
335 case "secretsmanager":
336 if (r.startsWith("secret:")) {
337 return mk("secretsmanager:secret", arn, 2);
338 }
339 return unsupported("only secrets are swept");
340 case "ssm":
341 if (r.startsWith("parameter/")) {
342 const raw = r.slice("parameter/".length);
343 return mk("ssm:parameter", raw.includes("/") ? `/${raw}` : raw, 2);
344 }
345 return unsupported("only parameters are swept");
346 case "scheduler": {
347 if (r.startsWith("schedule/")) {
348 return mk("scheduler:schedule", r.slice("schedule/".length), 1);
349 }
350 if (r.startsWith("schedule-group/")) {
351 return mk(
352 "scheduler:schedule-group",
353 r.slice("schedule-group/".length),
354 3,
355 );
356 }
357 return unsupported("unhandled scheduler type");
358 }
359 case "iam": {
360 if (r.startsWith("role/")) {
361 return mk("iam:role", r.slice("role/".length).split("/").pop()!, 4);
362 }
363 if (r.startsWith("policy/")) {
364 return mk("iam:policy", arn, 4);
365 }
366 return unsupported("only roles/policies are swept");
367 }
368 default:
369 return unsupported("no sweep handler for this service");
370 }
371};
372
373// ---------------------------------------------------------------------------
374// Age enrichment (best-effort, one describe per resource)
375// ---------------------------------------------------------------------------
376
377const sqsQueueUrl = (leak: Leak, region: string): string => {
378 const p = parseArn(leak.arn)!;
379 return `https://sqs.${region}.amazonaws.com/${p.account}/${leak.name}`;
380};
381
382const lookupCreatedAt = (
383 leak: Leak,
384 region: string,
385 bucketDates: Map<string, Date>,
386): Effect.Effect<Date | undefined, never, Env> => {
387 switch (leak.kind) {
388 case "sqs:queue":
389 return tryDate(
390 SQS.getQueueAttributes({
391 QueueUrl: sqsQueueUrl(leak, region),
392 AttributeNames: ["CreatedTimestamp"],
393 }).pipe(Effect.map((r) => r.Attributes?.CreatedTimestamp)),
394 );
395 case "dynamodb:table":
396 return tryDate(
397 DynamoDB.describeTable({ TableName: leak.name }).pipe(
398 Effect.map((r) => r.Table?.CreationDateTime),
399 ),
400 );
401 case "lambda:function":
402 return tryDate(
403 Lambda.getFunctionConfiguration({ FunctionName: leak.name }).pipe(
404 Effect.map((r) => r.LastModified),
405 ),
406 );
407 case "logs:log-group":
408 return tryDate(
409 Logs.describeLogGroups({ logGroupNamePrefix: leak.name }).pipe(
410 Effect.map(
411 (r) =>
412 r.logGroups?.find((g) => g.logGroupName === leak.name)
413 ?.creationTime,
414 ),
415 ),
416 );
417 case "s3:bucket":
418 return Effect.succeed(bucketDates.get(leak.name));
419 case "kinesis:stream":
420 return tryDate(
421 Kinesis.describeStreamSummary({ StreamName: leak.name }).pipe(
422 Effect.map(
423 (r) => r.StreamDescriptionSummary?.StreamCreationTimestamp,
424 ),
425 ),
426 );
427 case "ecs:service": {
428 // name = "service/{cluster}/{service}"
429 const [, cluster, service] = leak.name.split("/");
430 return tryDate(
431 ECS.describeServices({ cluster, services: [service] }).pipe(
432 Effect.map((r) => r.services?.[0]?.createdAt),
433 ),
434 );
435 }
436 case "elbv2:load-balancer":
437 return tryDate(
438 ELBv2.describeLoadBalancers({ LoadBalancerArns: [leak.arn] }).pipe(
439 Effect.map((r) => r.LoadBalancers?.[0]?.CreatedTime),
440 ),
441 );
442 case "ec2:nat-gateway":
443 return tryDate(
444 EC2.describeNatGateways({ NatGatewayIds: [leak.name] }).pipe(
445 Effect.map((r) => r.NatGateways?.[0]?.CreateTime),
446 ),
447 );
448 case "rds:instance":
449 return tryDate(
450 RDS.describeDBInstances({ DBInstanceIdentifier: leak.name }).pipe(
451 Effect.map((r) => r.DBInstances?.[0]?.InstanceCreateTime),
452 ),
453 );
454 case "rds:cluster":
455 return tryDate(
456 RDS.describeDBClusters({ DBClusterIdentifier: leak.name }).pipe(
457 Effect.map((r) => r.DBClusters?.[0]?.ClusterCreateTime),
458 ),
459 );
460 case "efs:file-system":
461 return tryDate(
462 EFS.describeFileSystems({ FileSystemId: leak.name }).pipe(
463 Effect.map((r) => r.FileSystems?.[0]?.CreationTime),
464 ),
465 );
466 case "kms:key":
467 return tryDate(
468 KMS.describeKey({ KeyId: leak.name }).pipe(
469 Effect.map((r) => r.KeyMetadata?.CreationDate),
470 ),
471 );
472 case "secretsmanager:secret":
473 return tryDate(
474 SecretsManager.describeSecret({ SecretId: leak.arn }).pipe(
475 Effect.map((r) => r.CreatedDate),
476 ),
477 );
478 case "ssm:parameter":
479 return tryDate(
480 SSM.describeParameters({
481 ParameterFilters: [
482 { Key: "Name", Option: "Equals", Values: [leak.name] },
483 ],
484 }).pipe(Effect.map((r) => r.Parameters?.[0]?.LastModifiedDate)),
485 );
486 case "scheduler:schedule": {
487 const [group, name] = leak.name.includes("/")
488 ? leak.name.split("/")
489 : ["default", leak.name];
490 return tryDate(
491 Scheduler.getSchedule({ Name: name, GroupName: group }).pipe(
492 Effect.map((r) => r.CreationDate),
493 ),
494 );
495 }
496 default:
497 return Effect.succeed(undefined);
498 }
499};
500
501// ---------------------------------------------------------------------------
502// Deletion (typed per service; not-found tags forgiven; bounded dep retries)
503// ---------------------------------------------------------------------------
504
505/** bounded retry on dependency-violation / in-use style typed tags */
506const retryDependencies = <A, E extends { _tag: string }, R>(
507 eff: Effect.Effect<A, E, R>,
508): Effect.Effect<A, E, R> =>
509 eff.pipe(
510 Effect.retry({
511 while: (e) =>
512 /InUse|DependencyViolation|Conflict|ContainsServices|ContainsTasks|BucketNotEmpty|InvalidDBClusterState|InvalidDBInstanceState|Throttl|TooManyRequests|LimitExceeded/i.test(
513 e._tag,
514 ),
515 schedule: Schedule.spaced("8 seconds"),
516 times: 6,
517 }),
518 );
519
520const GONE = /NotFound|NoSuchEntity|DoesNotExist|NoSuch|Missing|Gone|Deleted/i;
521
522const emptyBucket = (bucket: string) =>
523 Effect.gen(function* () {
524 // re-list from the start after each batch delete; bounded passes
525 for (let pass = 0; pass < 100; pass++) {
526 const listing = yield* S3.listObjectVersions({ Bucket: bucket });
527 const objects = [
528 ...(listing.Versions ?? []),
529 ...(listing.DeleteMarkers ?? []),
530 ]
531 .filter((o) => o.Key !== undefined)
532 .map((o) => ({ Key: o.Key as string, VersionId: o.VersionId }));
533 if (objects.length === 0) return;
534 yield* S3.deleteObjects({
535 Bucket: bucket,
536 Delete: { Objects: objects, Quiet: true },
537 });
538 if (!listing.IsTruncated) return;
539 }
540 });
541
542const deleteIamRole = (roleName: string) =>
543 Effect.gen(function* () {
544 const profiles = yield* IAM.listInstanceProfilesForRole({
545 RoleName: roleName,
546 });
547 yield* Effect.forEach(
548 profiles.InstanceProfiles ?? [],
549 (p) =>
550 IAM.removeRoleFromInstanceProfile({
551 InstanceProfileName: p.InstanceProfileName,
552 RoleName: roleName,
553 }),
554 { discard: true },
555 );
556 const attached = yield* IAM.listAttachedRolePolicies({
557 RoleName: roleName,
558 });
559 yield* Effect.forEach(
560 attached.AttachedPolicies ?? [],
561 (p) =>
562 IAM.detachRolePolicy({
563 RoleName: roleName,
564 PolicyArn: p.PolicyArn!,
565 }),
566 { discard: true },
567 );
568 const inline = yield* IAM.listRolePolicies({ RoleName: roleName });
569 yield* Effect.forEach(
570 inline.PolicyNames ?? [],
571 (name) =>
572 IAM.deleteRolePolicy({ RoleName: roleName, PolicyName: name }),
573 { discard: true },
574 );
575 yield* retryDependencies(IAM.deleteRole({ RoleName: roleName }));
576 });
577
578const deleteIamPolicy = (policyArn: string) =>
579 Effect.gen(function* () {
580 const versions = yield* IAM.listPolicyVersions({ PolicyArn: policyArn });
581 yield* Effect.forEach(
582 (versions.Versions ?? []).filter((v) => !v.IsDefaultVersion),
583 (v) =>
584 IAM.deletePolicyVersion({
585 PolicyArn: policyArn,
586 VersionId: v.VersionId!,
587 }),
588 { discard: true },
589 );
590 yield* retryDependencies(IAM.deletePolicy({ PolicyArn: policyArn }));
591 });
592
593const deleteEcsService = (leak: Leak) =>
594 Effect.gen(function* () {
595 // name = "service/{cluster}/{service}"
596 const [, cluster, service] = leak.name.split("/");
597 yield* ECS.updateService({ cluster, service, desiredCount: 0 }).pipe(
598 Effect.catch(() => Effect.succeed(undefined)),
599 );
600 yield* retryDependencies(
601 ECS.deleteService({ cluster, service, force: true }),
602 );
603 });
604
605const deleteEventsRule = (leak: Leak) =>
606 Effect.gen(function* () {
607 // name is "name" (default bus) or "{bus}/{name}"
608 const parts = leak.name.split("/");
609 const [bus, name] =
610 parts.length === 2 ? [parts[0], parts[1]] : ["default", parts[0]];
611 const targets = yield* EventBridge.listTargetsByRule({
612 Rule: name,
613 EventBusName: bus,
614 }).pipe(Effect.catch(() => Effect.succeed({ Targets: [] })));
615 const ids = (targets.Targets ?? [])
616 .map((t) => t.Id)
617 .filter((id): id is string => id !== undefined);
618 if (ids.length > 0) {
619 yield* EventBridge.removeTargets({
620 Rule: name,
621 EventBusName: bus,
622 Ids: ids,
623 Force: true,
624 }).pipe(Effect.catch(() => Effect.succeed(undefined)));
625 }
626 yield* EventBridge.deleteRule({ Name: name, EventBusName: bus, Force: true });
627 });
628
629/**
630 * A leaked VPC can't be deleted while its subnets / route tables / IGW /
631 * security groups exist, so tear those down first. NEVER touches the default
632 * VPC.
633 */
634const deleteVpcDeep = (vpcId: string) =>
635 Effect.gen(function* () {
636 const vpcs = yield* EC2.describeVpcs({ VpcIds: [vpcId] });
637 const vpc = vpcs.Vpcs?.[0];
638 if (!vpc) return; // already gone
639 if (vpc.IsDefault) {
640 return yield* Effect.fail({
641 _tag: "RefusingToDeleteDefaultVpc" as const,
642 });
643 }
644 const vpcFilter = [{ Name: "vpc-id", Values: [vpcId] }];
645
646 const subnets = yield* EC2.describeSubnets({ Filters: vpcFilter });
647 yield* Effect.forEach(
648 subnets.Subnets ?? [],
649 (s) =>
650 retryDependencies(EC2.deleteSubnet({ SubnetId: s.SubnetId! })).pipe(
651 Effect.catch(() => Effect.succeed(undefined)),
652 ),
653 { discard: true },
654 );
655
656 const igws = yield* EC2.describeInternetGateways({
657 Filters: [{ Name: "attachment.vpc-id", Values: [vpcId] }],
658 });
659 yield* Effect.forEach(
660 igws.InternetGateways ?? [],
661 (igw) =>
662 EC2.detachInternetGateway({
663 InternetGatewayId: igw.InternetGatewayId!,
664 VpcId: vpcId,
665 }).pipe(
666 Effect.andThen(
667 EC2.deleteInternetGateway({
668 InternetGatewayId: igw.InternetGatewayId!,
669 }),
670 ),
671 Effect.catch(() => Effect.succeed(undefined)),
672 ),
673 { discard: true },
674 );
675
676 const rts = yield* EC2.describeRouteTables({ Filters: vpcFilter });
677 yield* Effect.forEach(
678 (rts.RouteTables ?? []).filter(
679 (rt) => !(rt.Associations ?? []).some((a) => a.Main),
680 ),
681 (rt) =>
682 EC2.deleteRouteTable({ RouteTableId: rt.RouteTableId! }).pipe(
683 Effect.catch(() => Effect.succeed(undefined)),
684 ),
685 { discard: true },
686 );
687
688 const sgs = yield* EC2.describeSecurityGroups({ Filters: vpcFilter });
689 yield* Effect.forEach(
690 (sgs.SecurityGroups ?? []).filter((sg) => sg.GroupName !== "default"),
691 (sg) =>
692 retryDependencies(
693 EC2.deleteSecurityGroup({ GroupId: sg.GroupId! }),
694 ).pipe(Effect.catch(() => Effect.succeed(undefined))),
695 { discard: true },
696 );
697
698 yield* retryDependencies(EC2.deleteVpc({ VpcId: vpcId }));
699 });
700
701const deleteEfs = (fileSystemId: string) =>
702 Effect.gen(function* () {
703 const mts = yield* EFS.describeMountTargets({
704 FileSystemId: fileSystemId,
705 }).pipe(Effect.catch(() => Effect.succeed({ MountTargets: [] })));
706 yield* Effect.forEach(
707 mts.MountTargets ?? [],
708 (mt) => EFS.deleteMountTarget({ MountTargetId: mt.MountTargetId }),
709 { discard: true },
710 );
711 yield* retryDependencies(
712 EFS.deleteFileSystem({ FileSystemId: fileSystemId }),
713 );
714 });
715
716const destroyLeak = (
717 leak: Leak,
718 region: string,
719): Effect.Effect<unknown, { _tag: string }, Env> => {
720 switch (leak.kind) {
721 case "sqs:queue":
722 return SQS.deleteQueue({ QueueUrl: sqsQueueUrl(leak, region) });
723 case "sns:topic":
724 return SNS.deleteTopic({ TopicArn: leak.arn });
725 case "dynamodb:table":
726 return retryDependencies(
727 DynamoDB.deleteTable({ TableName: leak.name }),
728 );
729 case "lambda:function":
730 return Lambda.deleteFunction({ FunctionName: leak.name });
731 case "logs:log-group":
732 return Logs.deleteLogGroup({ logGroupName: leak.name });
733 case "s3:bucket":
734 return emptyBucket(leak.name).pipe(
735 Effect.andThen(
736 retryDependencies(S3.deleteBucket({ Bucket: leak.name })),
737 ),
738 );
739 case "kinesis:stream":
740 return retryDependencies(
741 Kinesis.deleteStream({
742 StreamName: leak.name,
743 EnforceConsumerDeletion: true,
744 }),
745 );
746 case "events:rule":
747 return deleteEventsRule(leak);
748 case "events:event-bus":
749 return EventBridge.deleteEventBus({ Name: leak.name });
750 case "ecs:service":
751 return deleteEcsService(leak);
752 case "ecs:cluster":
753 return retryDependencies(ECS.deleteCluster({ cluster: leak.name }));
754 case "ecs:task-definition":
755 return ECS.deregisterTaskDefinition({ taskDefinition: leak.name });
756 case "elbv2:load-balancer":
757 return ELBv2.deleteLoadBalancer({ LoadBalancerArn: leak.arn });
758 case "elbv2:target-group":
759 return retryDependencies(
760 ELBv2.deleteTargetGroup({ TargetGroupArn: leak.arn }),
761 );
762 case "ec2:nat-gateway":
763 return EC2.deleteNatGateway({ NatGatewayId: leak.name });
764 case "ec2:elastic-ip":
765 return retryDependencies(
766 EC2.releaseAddress({ AllocationId: leak.name }),
767 );
768 case "ec2:vpc":
769 return deleteVpcDeep(leak.name);
770 case "rds:instance":
771 return retryDependencies(
772 RDS.deleteDBInstance({
773 DBInstanceIdentifier: leak.name,
774 SkipFinalSnapshot: true,
775 DeleteAutomatedBackups: true,
776 }),
777 );
778 case "rds:cluster":
779 return retryDependencies(
780 RDS.deleteDBCluster({
781 DBClusterIdentifier: leak.name,
782 SkipFinalSnapshot: true,
783 }),
784 );
785 case "efs:file-system":
786 return deleteEfs(leak.name);
787 case "secretsmanager:secret":
788 return SecretsManager.deleteSecret({
789 SecretId: leak.arn,
790 ForceDeleteWithoutRecovery: true,
791 });
792 case "ssm:parameter":
793 return SSM.deleteParameter({ Name: leak.name });
794 case "scheduler:schedule": {
795 const [group, name] = leak.name.includes("/")
796 ? leak.name.split("/")
797 : ["default", leak.name];
798 return Scheduler.deleteSchedule({ Name: name, GroupName: group });
799 }
800 case "scheduler:schedule-group":
801 return retryDependencies(
802 Scheduler.deleteScheduleGroup({ Name: leak.name }),
803 );
804 case "iam:role":
805 return deleteIamRole(leak.name);
806 case "iam:policy":
807 return deleteIamPolicy(leak.arn);
808 default:
809 return Effect.void;
810 }
811};
812
813// ---------------------------------------------------------------------------
814// Discovery
815// ---------------------------------------------------------------------------
816
817/**
818 * NOTE: distilled's generated `.pages`/`.items` streams hang on APIs that
819 * signal the last page with an EMPTY-STRING token (the tagging API returns
820 * `PaginationToken: ""` when done, but src/client/api.ts only treats
821 * undefined/null as done). Paginate by hand, bounded, until that core bug is
822 * fixed.
823 */
824const MAX_PAGES = 50;
825
826const discoverTagged = (stage: string) =>
827 Effect.gen(function* () {
828 const all: import("@distilled.cloud/aws/resource-groups-tagging-api").ResourceTagMapping[] =
829 [];
830 let token: string | undefined = undefined;
831 for (let page = 0; page < MAX_PAGES; page++) {
832 const res: GetResourcesOutput = yield* getResources({
833 TagFilters: [{ Key: "alchemy::stage", Values: [stage] }],
834 ResourcesPerPage: 100,
835 ...(token ? { PaginationToken: token } : {}),
836 });
837 all.push(...(res.ResourceTagMappingList ?? []));
838 token = res.PaginationToken;
839 if (!token) break;
840 }
841 return all;
842 });
843
844/**
845 * IAM is a global service — a regional GetResources call does not return IAM
846 * resources, so list roles/local policies directly and confirm ownership via
847 * their alchemy tags. To bound tag-read calls, only name-plausible candidates
848 * (physical names embed `-{stage}-`, see src/PhysicalName.ts) are tag-checked.
849 */
850const discoverIam = (stage: string, account: string) =>
851 Effect.gen(function* () {
852 const marker = `-${stage}-`;
853 const leaks: {
854 arn: string;
855 tags: Record<string, string>;
856 createdAt: Date | undefined;
857 }[] = [];
858
859 const roles: import("@distilled.cloud/aws/iam").Role[] =
860 [];
861 {
862 let marker: string | undefined = undefined;
863 for (let page = 0; page < MAX_PAGES; page++) {
864 const res: IAM.ListRolesResponse = yield* IAM.listRoles({
865 MaxItems: 1000,
866 ...(marker ? { Marker: marker } : {}),
867 });
868 roles.push(...(res.Roles ?? []));
869 if (!res.IsTruncated || !res.Marker) break;
870 marker = res.Marker;
871 }
872 }
873 const roleCandidates = roles.filter((r) =>
874 (r.RoleName ?? "").includes(marker),
875 );
876 const roleTags = yield* Effect.forEach(
877 roleCandidates,
878 (r) =>
879 IAM.listRoleTags({ RoleName: r.RoleName }).pipe(
880 Effect.map((t) => ({ role: r, tags: tagRecord(t.Tags) })),
881 Effect.catch(() =>
882 Effect.succeed({ role: r, tags: {} as Record<string, string> }),
883 ),
884 ),
885 { concurrency: 5 },
886 );
887 for (const { role, tags } of roleTags) {
888 if (tags["alchemy::stage"] === stage) {
889 leaks.push({
890 arn: role.Arn ?? `arn:aws:iam::${account}:role/${role.RoleName}`,
891 tags,
892 createdAt: toDate(role.CreateDate),
893 });
894 }
895 }
896
897 const policies: import("@distilled.cloud/aws/iam").Policy[] =
898 [];
899 {
900 let marker: string | undefined = undefined;
901 for (let page = 0; page < MAX_PAGES; page++) {
902 const res: IAM.ListPoliciesResponse = yield* IAM.listPolicies({
903 Scope: "Local",
904 MaxItems: 1000,
905 ...(marker ? { Marker: marker } : {}),
906 });
907 policies.push(...(res.Policies ?? []));
908 if (!res.IsTruncated || !res.Marker) break;
909 marker = res.Marker;
910 }
911 }
912 const policyCandidates = policies.filter((p) =>
913 (p.PolicyName ?? "").includes(marker),
914 );
915 const policyTags = yield* Effect.forEach(
916 policyCandidates,
917 (p) =>
918 IAM.listPolicyTags({ PolicyArn: p.Arn! }).pipe(
919 Effect.map((t) => ({ policy: p, tags: tagRecord(t.Tags) })),
920 Effect.catch(() =>
921 Effect.succeed({ policy: p, tags: {} as Record<string, string> }),
922 ),
923 ),
924 { concurrency: 5 },
925 );
926 for (const { policy, tags } of policyTags) {
927 if (tags["alchemy::stage"] === stage) {
928 leaks.push({ arn: policy.Arn!, tags, createdAt: toDate(policy.CreateDate) });
929 }
930 }
931 return leaks;
932 });
933
934/**
935 * EventBridge Scheduler schedules are not taggable, so the tagging API can't
936 * find them. Fall back to listing all schedules and matching the alchemy
937 * physical-name shape (`{stack}-{id}-{stage}-{suffix}`).
938 */
939const discoverSchedules = (stage: string, region: string, account: string) =>
940 Effect.gen(function* () {
941 const marker = `-${stage}-`;
942 const schedules: import("@distilled.cloud/aws/scheduler").ScheduleSummary[] =
943 [];
944 {
945 let token: string | undefined = undefined;
946 for (let page = 0; page < MAX_PAGES; page++) {
947 const res: Scheduler.ListSchedulesOutput = yield* Scheduler.listSchedules({
948 MaxResults: 100,
949 ...(token ? { NextToken: token } : {}),
950 });
951 schedules.push(...(res.Schedules ?? []));
952 token = res.NextToken;
953 if (!token) break;
954 }
955 }
956 return schedules
957 .filter((s) => (s.Name ?? "").includes(marker))
958 .map((s) => ({
959 arn:
960 s.Arn ??
961 `arn:aws:scheduler:${region}:${account}:schedule/${s.GroupName ?? "default"}/${s.Name}`,
962 group: s.GroupName ?? "default",
963 name: s.Name ?? "",
964 createdAt: toDate(s.CreationDate),
965 }));
966 }).pipe(Effect.catch(() => Effect.succeed([])));
967
968// ---------------------------------------------------------------------------
969// Report
970// ---------------------------------------------------------------------------
971
972const fmtAge = (h: number | undefined): string =>
973 h === undefined ? "?" : h >= 48 ? `${(h / 24).toFixed(1)}d` : `${h.toFixed(1)}h`;
974
975const printTable = (leaks: Leak[]) => {
976 const rows = leaks.map((l) => ({
977 age: fmtAge(ageHours(l.createdAt)),
978 cost: l.hourly ? "HOURLY" : "",
979 kind: l.kind + (l.reportOnly ? " (report-only)" : ""),
980 stack: l.tags["alchemy::stack"] ?? "",
981 id: l.tags["alchemy::id"] ?? "",
982 arn: l.arn,
983 }));
984 const cols = ["age", "cost", "kind", "stack", "id", "arn"] as const;
985 const headers = { age: "AGE", cost: "COST", kind: "KIND", stack: "STACK", id: "ID", arn: "ARN" };
986 const width = (c: (typeof cols)[number]) =>
987 Math.max(headers[c].length, ...rows.map((r) => r[c].length));
988 const line = (r: Record<(typeof cols)[number], string>) =>
989 cols.map((c) => r[c].padEnd(width(c))).join(" ");
990 console.log(line(headers));
991 console.log(cols.map((c) => "-".repeat(width(c))).join(" "));
992 for (const r of rows) console.log(line(r));
993};
994
995// ---------------------------------------------------------------------------
996// Main
997// ---------------------------------------------------------------------------
998
999const main = Effect.gen(function* () {
1000 const args = yield* parseArgs;
1001 console.log(
1002 `aws-leak-sweep: region=${args.region} stage=${args.stage} older-than=${args.olderThanHours}h mode=${args.delete ? "DELETE" : "DRY-RUN"}`,
1003 );
1004
1005 // -- discover ---------------------------------------------------------
1006 const tagged = yield* discoverTagged(args.stage);
1007 const account =
1008 tagged
1009 .map((t) => parseArn(t.ResourceARN ?? "")?.account)
1010 .find((a) => a && a.length > 0) ?? "";
1011
1012 const leaks: Leak[] = [];
1013 const seen = new Set<string>();
1014 const push = (leak: Leak) => {
1015 if (!seen.has(leak.arn)) {
1016 seen.add(leak.arn);
1017 leaks.push(leak);
1018 }
1019 };
1020
1021 for (const mapping of tagged) {
1022 const arn = mapping.ResourceARN;
1023 if (!arn) continue;
1024 const c = classify(arn);
1025 push({
1026 arn,
1027 kind: c.kind,
1028 name: c.name,
1029 tags: tagRecord(mapping.Tags),
1030 createdAt: undefined,
1031 hourly: c.hourly,
1032 reportOnly: c.reportOnly,
1033 reportOnlyReason: c.reportOnlyReason,
1034 phase: c.phase,
1035 });
1036 }
1037
1038 const iamLeaks = yield* discoverIam(args.stage, account);
1039 for (const l of iamLeaks) {
1040 const c = classify(l.arn);
1041 push({
1042 arn: l.arn,
1043 kind: c.kind,
1044 name: c.name,
1045 tags: l.tags,
1046 createdAt: l.createdAt,
1047 hourly: c.hourly,
1048 reportOnly: c.reportOnly,
1049 reportOnlyReason: c.reportOnlyReason,
1050 phase: c.phase,
1051 });
1052 }
1053
1054 const schedules = yield* discoverSchedules(args.stage, args.region, account);
1055 for (const s of schedules) {
1056 push({
1057 arn: s.arn,
1058 kind: "scheduler:schedule",
1059 name: `${s.group}/${s.name}`,
1060 tags: {},
1061 createdAt: s.createdAt,
1062 hourly: false,
1063 reportOnly: false,
1064 phase: 1,
1065 });
1066 }
1067
1068 if (leaks.length === 0) {
1069 console.log("No alchemy-tagged test resources found. Clean.");
1070 return;
1071 }
1072
1073 // -- enrich ages ------------------------------------------------------
1074 const bucketDates = new Map<string, Date>();
1075 if (leaks.some((l) => l.kind === "s3:bucket")) {
1076 const buckets = yield* S3.listBuckets({}).pipe(
1077 Effect.catch(() => Effect.succeed({ Buckets: [] })),
1078 );
1079 for (const b of buckets.Buckets ?? []) {
1080 const d = toDate(b.CreationDate);
1081 if (b.Name && d) bucketDates.set(b.Name, d);
1082 }
1083 }
1084 yield* Effect.forEach(
1085 leaks,
1086 (leak) =>
1087 leak.createdAt !== undefined
1088 ? Effect.void
1089 : lookupCreatedAt(leak, args.region, bucketDates).pipe(
1090 Effect.map((d) => {
1091 leak.createdAt = d;
1092 }),
1093 ),
1094 { concurrency: 8, discard: true },
1095 );
1096
1097 // -- filter by age ----------------------------------------------------
1098 const eligible: Leak[] = [];
1099 let skippedYoung = 0;
1100 for (const leak of leaks) {
1101 const h = ageHours(leak.createdAt);
1102 // unknown age = still a leak; can't prove it's young
1103 if (h === undefined || h >= args.olderThanHours) eligible.push(leak);
1104 else skippedYoung++;
1105 }
1106
1107 eligible.sort(
1108 (a, b) =>
1109 (a.createdAt?.getTime() ?? 0) - (b.createdAt?.getTime() ?? 0),
1110 );
1111
1112 console.log(
1113 `\nFound ${leaks.length} alchemy '${args.stage}'-stage resources; ${eligible.length} older than ${args.olderThanHours}h (or unknown age), ${skippedYoung} younger (skipped).\n`,
1114 );
1115 printTable(eligible);
1116
1117 const hourlyCount = eligible.filter((l) => l.hourly).length;
1118 if (hourlyCount > 0) {
1119 console.log(
1120 `\n!! ${hourlyCount} resource(s) bill HOURLY (NAT/EIP/RDS/ELB) — sweep these first.`,
1121 );
1122 }
1123 const reportOnly = eligible.filter((l) => l.reportOnly);
1124 if (reportOnly.length > 0) {
1125 console.log(`\nReport-only (not deleted by this tool):`);
1126 for (const l of reportOnly) {
1127 console.log(` - ${l.arn} (${l.reportOnlyReason ?? ""})`);
1128 }
1129 }
1130
1131 if (!args.delete) {
1132 console.log(
1133 `\nDRY RUN — nothing deleted. Re-run with --delete to remove the ${
1134 eligible.filter((l) => !l.reportOnly).length
1135 } deletable resource(s).`,
1136 );
1137 return;
1138 }
1139
1140 // -- delete: phase order, oldest-first within phase --------------------
1141 const deletable = eligible
1142 .filter((l) => !l.reportOnly)
1143 .sort(
1144 (a, b) =>
1145 a.phase - b.phase ||
1146 (a.createdAt?.getTime() ?? Number.MAX_SAFE_INTEGER) -
1147 (b.createdAt?.getTime() ?? Number.MAX_SAFE_INTEGER),
1148 );
1149
1150 let ok = 0;
1151 let gone = 0;
1152 let failed = 0;
1153 for (const leak of deletable) {
1154 const result = yield* destroyLeak(leak, args.region).pipe(
1155 Effect.timeout("120 seconds"),
1156 Effect.result,
1157 );
1158 if (Result.isFailure(result)) {
1159 const tag =
1160 typeof result.failure === "object" &&
1161 result.failure !== null &&
1162 "_tag" in result.failure
1163 ? String(result.failure._tag)
1164 : String(result.failure);
1165 if (GONE.test(tag)) {
1166 gone++;
1167 console.log(` ~ already gone ${leak.kind} ${leak.arn}`);
1168 } else {
1169 failed++;
1170 console.log(` x FAILED ${leak.kind} ${leak.arn}: ${tag}`);
1171 }
1172 } else {
1173 ok++;
1174 console.log(` ✓ deleted ${leak.kind} ${leak.arn}`);
1175 }
1176 }
1177 console.log(
1178 `\nDelete complete: ${ok} deleted, ${gone} already gone, ${failed} failed.`,
1179 );
1180 if (failed > 0) {
1181 console.log(
1182 "Failures are usually dependency ordering (NAT->EIP->VPC take minutes); re-run the sweep.",
1183 );
1184 process.exitCode = 1;
1185 }
1186});
1187
1188const args = await Effect.runPromise(parseArgs);
1189const layers = Layer.mergeAll(
1190 fromChain(),
1191 Layer.succeed(Region, Effect.succeed(args.region as any)),
1192 FetchHttpClient.layer,
1193);
1194
1195await Effect.runPromise(main.pipe(Effect.provide(layers)));