From 1e200c849be13ac8b86538d5e6f01c7e5f27405c Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 14:25:17 -0400 Subject: [PATCH 01/10] Add repr for resource debugging --- destroy-cumulus/destroy_cumulus.py | 3 +++ 1 file changed, 3 insertions(+) diff --git a/destroy-cumulus/destroy_cumulus.py b/destroy-cumulus/destroy_cumulus.py index 9ba964d..4ce347a 100644 --- a/destroy-cumulus/destroy_cumulus.py +++ b/destroy-cumulus/destroy_cumulus.py @@ -230,6 +230,9 @@ def __hash__(self): def __eq__(self, other): return (self.__class__, self.id) == (other.__class__, other.id) + def __repr__(self) -> str: + return f"{self.__class__.__name__}(name={self.name!r}, id={self.id!r})" + class VersionedResource(Resource, register=False): """A resource where the arn ends with a ':'""" From e401bc98f0ba5a687a2f5295e94b82aaf9005fc9 Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 14:30:15 -0400 Subject: [PATCH 02/10] Refactor StateResource to base class --- destroy-cumulus/destroy_cumulus.py | 69 ++++++++++++++++----------- destroy-cumulus/tests/test_display.py | 2 +- 2 files changed, 41 insertions(+), 30 deletions(-) diff --git a/destroy-cumulus/destroy_cumulus.py b/destroy-cumulus/destroy_cumulus.py index 4ce347a..1318cec 100644 --- a/destroy-cumulus/destroy_cumulus.py +++ b/destroy-cumulus/destroy_cumulus.py @@ -234,6 +234,24 @@ def __repr__(self) -> str: return f"{self.__class__.__name__}(name={self.name!r}, id={self.id!r})" +class StateResource(Resource, register=False): + """A resource with a state attribute""" + + def __init__(self, name, id, *, state=None, arn=None, tags=()): + super().__init__(name, id, arn=arn, tags=tags) + self.state = state + + @classmethod + def from_arn(cls, arn, *, state=None, tags=()): + return cls(arn.name, arn.id, state=state, arn=arn, tags=tags) + + def display(self, *args, **kwargs): + lines = super().display(*args, **kwargs) + if self.state: + lines[0] = lines[0] + f" ({self.state})" + return lines + + class VersionedResource(Resource, register=False): """A resource where the arn ends with a ':'""" @@ -803,11 +821,11 @@ def delete(self, get_client): ) -class ECSCluster(Resource): +class ECSCluster(StateResource): TYPE_FILTER = "ecs:cluster" - def __init__(self, name, id, *, services=(), arn=None, tags=()): - super().__init__(name, id, arn=arn, tags=tags) + def __init__(self, name, id, *, services=(), state=None, arn=None, tags=()): + super().__init__(name, id, state=state, arn=arn, tags=tags) self.services = sorted( services, key=lambda res: (res.name, res.id), @@ -872,20 +890,16 @@ def delete(self, get_client): ) -class ECSTaskDefinition(VersionedResource): +class ECSTaskDefinition(StateResource, VersionedResource): TYPE_FILTER = "ecs:task-definition" - def __init__(self, name, id, *, status=None, arn=None, tags=()): - super().__init__(name, id, arn=arn, tags=tags) - self.status = status - @classmethod def gather(cls, get_client, name_matcher, _options): client = get_client("ecs") paginator = client.get_paginator("list_task_definitions") return [ - cls(arn.name, arn.id, status=status, arn=arn) + cls(arn.name, arn.id, state=status, arn=arn) for status in ("ACTIVE", "INACTIVE", "DELETE_IN_PROGRESS") for response in paginator.paginate(status=status) for arn_ in response["taskDefinitionArns"] @@ -894,16 +908,11 @@ def gather(cls, get_client, name_matcher, _options): def delete(self, get_client): client = get_client("ecs") - if self.status != "DELETE_IN_PROGRESS": + if self.state != "DELETE_IN_PROGRESS": client.deregister_task_definition(taskDefinition=str(self.arn)) # NOTE: Could actually do a bulk delete here client.delete_task_definitions(taskDefinitions=[str(self.arn)]) - def display(self, *args, **kwargs): - lines = super().display(*args, **kwargs) - if self.status: - lines[0] = lines[0] + f" ({self.status})" - return lines class ElasticsearchDomain(Resource): @@ -1218,13 +1227,9 @@ def delete(self, get_client): ) -class NetworkInterface(Resource): +class NetworkInterface(StateResource): TYPE_FILTER = "ec2:network-interface" - def __init__(self, name, id, status, *, arn=None, tags=()): - super().__init__(name, id, arn=arn, tags=tags) - self.status = status - def delete(self, get_client): client = get_client("ec2") client.delete_network_interface(NetworkInterfaceId=self.id) @@ -1232,11 +1237,6 @@ def delete(self, get_client): def get_display_name(self): return self.name or self.id - def display(self, *args, **kwargs): - lines = super().display(*args, **kwargs) - lines[0] = lines[0] + f" ({self.status})" - return lines - class RDSCluster(Resource): TYPE_FILTER = "rds:cluster" @@ -1304,7 +1304,7 @@ def delete(self, get_client): client.delete_db_subnet_group(DBSubnetGroupName=self.name) -class Secret(Resource): +class Secret(StateResource): TYPE_FILTER = "secretsmanager:secret" @classmethod @@ -1313,8 +1313,19 @@ def gather(cls, get_client, name_matcher, _options): paginator = client.get_paginator("list_secrets") return [ - cls.from_arn(Arn(entry["ARN"]), tags=entry.get("Tags", ())) - for response in paginator.paginate() + cls.from_arn( + Arn(entry["ARN"]), + state="DELETED" if "DeletedDate" in entry else None, + tags=entry.get("Tags", ()), + ) + for response in paginator.paginate( + Filters=[ + dict( + Key="name", + Values=[name_matcher.prefix], + ), + ] + ) for entry in response.get("SecretList", ()) if name_matcher.matches(entry["Name"]) ] @@ -1390,7 +1401,7 @@ def load_bulk(cls, get_client, resources): network_interface = NetworkInterface( entry["Description"], entry["NetworkInterfaceId"], - entry["Status"], + state=entry["Status"], tags=entry.get("TagSet", ()), ) for group_entry in entry["Groups"]: diff --git a/destroy-cumulus/tests/test_display.py b/destroy-cumulus/tests/test_display.py index cee05e0..0a56e53 100644 --- a/destroy-cumulus/tests/test_display.py +++ b/destroy-cumulus/tests/test_display.py @@ -2,7 +2,7 @@ def test_display_network_interface_no_description(): - resource = NetworkInterface("", "eni-0899de89cbd073900", "in-use") + resource = NetworkInterface("", "eni-0899de89cbd073900", state="in-use") assert resource.display() == [ "[NetworkInterface] eni-0899de89cbd073900 (in-use)", From 34a9bee06f8f44af8c8cc5a968395824332248b9 Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 14:42:48 -0400 Subject: [PATCH 03/10] Improve ResourceSet to track dependencies --- destroy-cumulus/destroy_cumulus.py | 116 ++++++++++++++++++++- destroy-cumulus/tests/test_resource_set.py | 84 ++++++++++++++- 2 files changed, 194 insertions(+), 6 deletions(-) diff --git a/destroy-cumulus/destroy_cumulus.py b/destroy-cumulus/destroy_cumulus.py index 1318cec..4fdd0e6 100644 --- a/destroy-cumulus/destroy_cumulus.py +++ b/destroy-cumulus/destroy_cumulus.py @@ -1241,21 +1241,109 @@ def get_display_name(self): class RDSCluster(Resource): TYPE_FILTER = "rds:cluster" + def __init__(self, name, id, db_instances, *, arn=None, tags=()): + super().__init__(name, id, arn=arn, tags=tags) + self.db_instances = sorted( + db_instances, + key=lambda res: (res.name, res.id), + ) + + @classmethod + def from_arn(cls, arn, *, tags=()): + return cls(arn.name, arn.id, [], arn=arn, tags=tags) + @classmethod def gather(cls, get_client, name_matcher, _options): client = get_client("rds") paginator = client.get_paginator("describe_db_clusters") return [ - cls.from_arn(Arn(entry["DBClusterArn"]), tags=entry.get("TagList", ())) - for response in paginator.paginate() + cls( + id, + id, + db_instances=[], + arn=Arn(entry["DBClusterArn"]), + tags=entry.get("TagList", ()), + ) + for response in paginator.paginate( + # NOTE(07/01/26): Filters are supported, but wildcards are not + ) for entry in response.get("DBClusters", ()) - if name_matcher.matches(entry["DBClusterIdentifier"]) + if name_matcher.matches(id := entry["DBClusterIdentifier"]) ] + def load(self, get_client): + self.load_bulk(get_client, [self]) + + @classmethod + def load_bulk(cls, get_client, resources): + client = get_client("rds") + paginator = client.get_paginator("describe_db_clusters") + instance_paginator = client.get_paginator("describe_db_instances") + + db_clusters_by_id = {resource.id: resource for resource in resources} + db_cluster_ids = list(db_clusters_by_id.keys()) + + for response in paginator.paginate( + Filters=[ + dict( + Name="db-cluster-id", + Values=db_cluster_ids, + ), + ], + ): + for entry in response.get("DBClusters", ()): + cluster = db_clusters_by_id[entry["DBClusterIdentifier"]] + + cluster.db_instances.clear() + cluster.tags = _tag_dict(entry.get("TagList", ())) + + for response in instance_paginator.paginate( + Filters=[ + dict( + Name="db-cluster-id", + Values=db_cluster_ids, + ), + ], + ): + for entry in response.get("DBInstances", ()): + db_instance = RDSClusterInstance( + entry["DBInstanceIdentifier"], + entry["DBInstanceIdentifier"], + tags=entry.get("TagList", ()), + ) + cluster = db_clusters_by_id.get(entry["DBClusterIdentifier"]) + + if not cluster: + continue + + cluster.db_instances.append(db_instance) + + for cluster in resources: + cluster.db_instances.sort( + key=lambda res: (res.name, res.id), + ) + def delete(self, get_client): client = get_client("rds") - client.delete_db_cluster(DBClusterIdentifier=self.id, SkipFinalSnapshot=True) + client.delete_db_cluster( + DBClusterIdentifier=self.id, + SkipFinalSnapshot=True, + ) + + def get_dependencies(self): + return self.db_instances + + +class RDSClusterInstance(Resource): + TYPE_FILTER = "rds:db" + + def delete(self, get_client): + client = get_client("rds") + client.delete_db_instance( + DBInstanceIdentifier=self.id, + SkipFinalSnapshot=True, + ) class RDSClusterParameterGroup(Resource): @@ -1596,6 +1684,7 @@ class ResourceSet: def __init__(self, iterable=()): self._resources = {} self._resources_by_class = defaultdict(set) + self._dependencies = {} for item in iterable: self.add(item) @@ -1610,9 +1699,24 @@ def add(self, resource): self._resources_by_class[resource.__class__].discard(resource) resource.tags.update(old.tags) - self._resources[resource] = resource + if resource not in self._dependencies: + self._resources[resource] = resource self._resources_by_class[resource.__class__].add(resource) + for dependency in resource.get_dependencies(): + self._resources.pop(dependency, None) + self._dependencies[dependency] = dependency + self.add(dependency) + + def resolve_dependencies(self): + # Needs to be called if dependencies are loaded after resources are + # added to the set. + for resource in list(self): + for dependency in resource.get_dependencies(): + self._resources.pop(dependency, None) + self._dependencies[dependency] = dependency + self.add(dependency) + def iter_by_class(self): for key, values in self._resources_by_class.items(): if values: @@ -1779,6 +1883,8 @@ def gather(self, collectors=None): for cls, resources in resource_set.iter_by_class(): cls.load_bulk(self.client, resources) + resource_set.resolve_dependencies() + end = time.perf_counter() log.debug("Total time gathering was %.1fs", end - start) diff --git a/destroy-cumulus/tests/test_resource_set.py b/destroy-cumulus/tests/test_resource_set.py index 367f963..feb490a 100644 --- a/destroy-cumulus/tests/test_resource_set.py +++ b/destroy-cumulus/tests/test_resource_set.py @@ -1,4 +1,12 @@ -from destroy_cumulus import Bucket, LambdaFunction, ResourceSet +from destroy_cumulus import ( + Bucket, + LambdaFunction, + NetworkInterface, + RDSCluster, + RDSClusterInstance, + ResourceSet, + SecurityGroup, +) def test_add_tag_merge(): @@ -31,6 +39,44 @@ def test_add_tag_merge(): } +def test_add_dependencies(): + resource_set = ResourceSet() + + instance = RDSClusterInstance("foo", "foo") + cluster = RDSCluster( + "bar", + "bar", + db_instances=[instance], + ) + + resource_set.add(instance) + resource_set.add(cluster) + resource_set.add(instance) + + assert list(resource_set) == [cluster] + + +def test_resolve_dependencies(): + resource_set = ResourceSet() + + instance = RDSClusterInstance("foo", "foo") + cluster = RDSCluster( + "bar", + "bar", + db_instances=[], + ) + + resource_set.add(instance) + resource_set.add(cluster) + + assert list(resource_set) == [instance, cluster] + + cluster.db_instances.append(instance) + resource_set.resolve_dependencies() + + assert list(resource_set) == [cluster] + + def test_iter_by_class(): resource_set = ResourceSet() @@ -43,3 +89,39 @@ def test_iter_by_class(): (Bucket, {Bucket("foo", "foo"), Bucket("bar", "bar")}), (LambdaFunction, {LambdaFunction("test-lambda", "test-lambda")}), ] + + +def test_iter_by_class_dependencies(): + resource_set = ResourceSet() + + security_group_1 = SecurityGroup( + "foo", + "foo", + network_interfaces=[ + NetworkInterface("bar", "bar", state="available"), + NetworkInterface("baz", "baz", state="available"), + ], + ) + security_group_2 = SecurityGroup( + "qux", + "qux", + network_interfaces=[ + NetworkInterface("bar", "bar", state="available"), + NetworkInterface("spam", "spam", state="available"), + ], + ) + + resource_set.add(security_group_1) + resource_set.add(security_group_2) + + assert list(resource_set.iter_by_class()) == [ + (SecurityGroup, {security_group_1, security_group_2}), + ( + NetworkInterface, + { + NetworkInterface("bar", "bar", state="available"), + NetworkInterface("baz", "baz", state="available"), + NetworkInterface("spam", "spam", state="available"), + }, + ), + ] From 0cc733b741eb2c61315e94b447e2ba4b26eb0e21 Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 14:54:45 -0400 Subject: [PATCH 04/10] Add Batch resources --- destroy-cumulus/destroy_cumulus.py | 109 +++++++++++++++++++++++++++++ 1 file changed, 109 insertions(+) diff --git a/destroy-cumulus/destroy_cumulus.py b/destroy-cumulus/destroy_cumulus.py index 4fdd0e6..76242f3 100644 --- a/destroy-cumulus/destroy_cumulus.py +++ b/destroy-cumulus/destroy_cumulus.py @@ -550,6 +550,112 @@ def delete(self, get_client): ) +class BatchComputeEnvironment(StateResource): + TYPE_FILTER = "batch:compute-environment" + + @classmethod + def gather(cls, get_client, name_matcher, _options): + client = get_client("batch") + paginator = client.get_paginator("describe_compute_environments") + + return [ + # ruff hint + cls( + name, + name, + state=entry["state"], + arn=Arn(entry["computeEnvironmentArn"]), + tags=[ + # ruff hint + dict(Key=k, Value=v) + for k, v in entry.get("tags", {}).items() + ], + ) + for response in paginator.paginate() + for entry in response.get("computeEnvironments", ()) + if name_matcher.matches(name := entry["computeEnvironmentName"]) + ] + + def delete(self, get_client): + client = get_client("batch") + if self.state == "ENABLED": + client.update_compute_environment( + computeEnvironment=str(self.arn), + state="DISABLED", + ) + # This can fail silently if the service role has already been deleted + client.delete_compute_environment(computeEnvironment=str(self.arn)) + + +class BatchJobDefinition(StateResource, VersionedResource): + TYPE_FILTER = "batch:job-definition" + + @classmethod + def gather(cls, get_client, name_matcher, _options): + client = get_client("batch") + paginator = client.get_paginator("describe_job_definitions") + + resources = [ + # ruff hint + cls( + name, + str(entry["revision"]), + state=entry["status"], + arn=Arn(entry["jobDefinitionArn"]), + tags=[ + # ruff hint + dict(Key=k, Value=v) + for k, v in entry.get("tags", {}).items() + ], + ) + for response in paginator.paginate() + for entry in response.get("jobDefinitions", ()) + if name_matcher.matches(name := entry["jobDefinitionName"]) + ] + return resources + + def delete(self, get_client): + client = get_client("batch") + client.deregister_job_definition(jobDefinition=str(self.arn)) + + +class BatchJobQueue(StateResource): + TYPE_FILTER = "batch:job-queue" + + @classmethod + def gather(cls, get_client, name_matcher, _options): + client = get_client("batch") + paginator = client.get_paginator("describe_job_queues") + + return [ + # ruff hint + cls( + name, + name, + state=entry["state"], + arn=Arn(entry["jobQueueArn"]), + tags=[ + # ruff hint + dict(Key=k, Value=v) + for k, v in entry.get("tags", {}).items() + ], + ) + for response in paginator.paginate() + for entry in response.get("jobQueues", ()) + if name_matcher.matches(name := entry["jobQueueName"]) + ] + + def delete(self, get_client): + client = get_client("batch") + if self.state == "ENABLED": + client.update_job_queue( + jobQueue=str(self.arn), + state="DISABLED", + computeEnvironmentOrder=[], + ) + client.delete_job_queue(jobQueue=str(self.arn)) + + class Bucket(Resource): TYPE_FILTER = "s3" @@ -1752,6 +1858,9 @@ class CumulusDestroyer: SNSTopic, SQSQueue, DynamoDBTable, + BatchJobQueue, + BatchComputeEnvironment, + BatchJobDefinition, ECSCluster, ECSTaskDefinition, ECRRepository, From 547d9387f516a250294b91c0d21e9314d1ff45c7 Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 14:55:45 -0400 Subject: [PATCH 05/10] Add EC2 resources --- destroy-cumulus/destroy_cumulus.py | 81 ++++++++++++++++++++++++++++++ 1 file changed, 81 insertions(+) diff --git a/destroy-cumulus/destroy_cumulus.py b/destroy-cumulus/destroy_cumulus.py index 76242f3..d82fc55 100644 --- a/destroy-cumulus/destroy_cumulus.py +++ b/destroy-cumulus/destroy_cumulus.py @@ -902,6 +902,53 @@ def delete(self, get_client): client.delete_table(TableName=self.name) +class EC2Instance(StateResource): + TYPE_FILTER = "ec2:instance" + + @classmethod + def gather(cls, get_client, name_matcher, _options): + client = get_client("ec2") + paginator = client.get_paginator("describe_instances") + + return [ + cls( + name, + entry["InstanceId"], + state=entry.get("State", {}).get("Name"), + tags=entry.get("Tags", ()), + ) + for response in paginator.paginate( + Filters=[ + dict( + Name="tag:Name", + Values=[name_matcher.prefix + "*"], + ), + dict( + Name="instance-state-name", + Values=[ + "pending", + "running", + "shutting-down", + "stopping", + "stopped", + ], + ), + ], + ) + for reservation in response.get("Reservations", ()) + for entry in reservation.get("Instances", ()) + if name_matcher.matches(name := _tag_dict(entry.get("Tags", ())).get("Name")) + ] + + def delete(self, get_client): + client = get_client("ec2") + # NOTE: Could actually do a bulk delete here + client.terminate_instances(InstanceIds=[self.id]) + + def get_display_name(self): + return f"{self.id} {self.name}" + + class ECRRepository(Resource): """Possible workflow resource. Not part of core.""" @@ -1333,6 +1380,38 @@ def delete(self, get_client): ) +class LaunchTemplate(Resource): + TYPE_FILTER = "ec2:launch-template" + + @classmethod + def gather(cls, get_client, name_matcher, _options): + client = get_client("ec2") + paginator = client.get_paginator("describe_launch_templates") + + return [ + # ruff hint + cls( + name, + entry["LaunchTemplateId"], + tags=entry.get("Tags", ()), + ) + for response in paginator.paginate( + Filters=[ + dict( + Name="launch-template-name", + Values=[name_matcher.prefix + "*"], + ), + ], + ) + for entry in response.get("LaunchTemplates", ()) + if name_matcher.matches(name := entry["LaunchTemplateName"]) + ] + + def delete(self, get_client): + client = get_client("ec2") + client.delete_launch_template(LaunchTemplateId=self.id) + + class NetworkInterface(StateResource): TYPE_FILTER = "ec2:network-interface" @@ -1861,6 +1940,8 @@ class CumulusDestroyer: BatchJobQueue, BatchComputeEnvironment, BatchJobDefinition, + LaunchTemplate, + EC2Instance, ECSCluster, ECSTaskDefinition, ECRRepository, From 2e2c66d326bc78413a3d76045e350f5a3bca04e7 Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 14:57:55 -0400 Subject: [PATCH 06/10] Add ELB resources --- destroy-cumulus/destroy_cumulus.py | 55 ++++++++++++++++++++++++++++++ destroy-cumulus/tests/test_arn.py | 16 +++++++++ 2 files changed, 71 insertions(+) diff --git a/destroy-cumulus/destroy_cumulus.py b/destroy-cumulus/destroy_cumulus.py index d82fc55..fbc4845 100644 --- a/destroy-cumulus/destroy_cumulus.py +++ b/destroy-cumulus/destroy_cumulus.py @@ -110,6 +110,12 @@ def __init__(self, arn): # arn:aws:iam::123456789012:role/ngap/system/s3-all-region-access-role self.id = rest self.name = rest.split("/")[-1] + elif self.service == "elasticloadbalancing" and self.type == "loadbalancer": + # Special case for load balancers where arns look like this: + # arn:aws:elasticloadbalancing:us-west-2:123456789101:loadbalancer/app/rew-cumulus-uat2-iceberg/6680b609e7f9d62a + rest_parts = rest.split("/") + self.name = rest_parts[1] + self.id = rest_parts[2] else: self.name, *rest = rest.split("/", 1) self.id = "".join(rest) @@ -320,6 +326,7 @@ def gather(self, get_client, name_matcher, _options): "apigateway:restapis-stages", "application-autoscaling:scalable-target", "ecs:service", + "elasticloadbalancing:listener", ): log.debug( "Skipping arn '%s' for type '%s' as it is a known child " @@ -1088,6 +1095,52 @@ def delete(self, get_client): client.delete_domain(DomainName=self.name) +class ELBLoadBalancer(Resource): + TYPE_FILTER = "elasticloadbalancing:loadbalancer" + + @classmethod + def gather(cls, get_client, name_matcher, _options): + client = get_client("elbv2") + paginator = client.get_paginator("describe_load_balancers") + + return [ + # ruff hint + cls.from_arn(Arn(entry["LoadBalancerArn"])) + for response in paginator.paginate() + for entry in response.get("LoadBalancers", ()) + if name_matcher.matches(entry["LoadBalancerName"]) + ] + + def delete(self, get_client): + client = get_client("elbv2") + client.delete_load_balancer(LoadBalancerArn=str(self.arn)) + + +class ELBTargetGroup(Resource): + TYPE_FILTER = "elasticloadbalancing:targetgroup" + + @classmethod + def gather(cls, get_client, name_matcher, _options): + client = get_client("elbv2") + paginator = client.get_paginator("describe_target_groups") + + # TargetGroupNames are unlikely to match because they are mostly + # overwritten by a time stamp e.g. rew-cu20260626220724919600000002 + # However, they are discoverable through the resource tagging API. + + return [ + # ruff hint + cls.from_arn(Arn(entry["TargetGroupArn"])) + for response in paginator.paginate() + for entry in response.get("TargetGroups", ()) + if name_matcher.matches(entry["TargetGroupName"]) + ] + + def delete(self, get_client): + client = get_client("elbv2") + client.delete_target_group(TargetGroupArn=str(self.arn)) + + class EventSourceMapping(Resource): TYPE_FILTER = "lambda:event-source-mapping" @@ -1945,6 +1998,8 @@ class CumulusDestroyer: ECSCluster, ECSTaskDefinition, ECRRepository, + ELBLoadBalancer, + ELBTargetGroup, RDSCluster, RDSClusterParameterGroup, RDSSubnetGroup, diff --git a/destroy-cumulus/tests/test_arn.py b/destroy-cumulus/tests/test_arn.py index 7616db4..4b46a6d 100644 --- a/destroy-cumulus/tests/test_arn.py +++ b/destroy-cumulus/tests/test_arn.py @@ -139,6 +139,22 @@ def test_arn_api_gateway_stage(): assert arn.type_id == "apigateway:restapis-stages" +def test_arn_load_balancer(): + arn = Arn( + "arn:aws:elasticloadbalancing:us-west-2:123456789101:loadbalancer/app/" + "rew-cumulus-uat2-iceberg/6680b609e7f9d62a", + ) + + assert arn.partition == "aws" + assert arn.service == "elasticloadbalancing" + assert arn.region == "us-west-2" + assert arn.account == "123456789101" + assert arn.type == "loadbalancer" + assert arn.name == "rew-cumulus-uat2-iceberg" + assert arn.id == "6680b609e7f9d62a" + assert arn.type_id == "elasticloadbalancing:loadbalancer" + + # # Colons and slashes in identifier # From 197a8d3a73ccd103d3b32474919f3efbda3b112b Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 15:00:00 -0400 Subject: [PATCH 07/10] Add EFS resources --- destroy-cumulus/destroy_cumulus.py | 71 ++++++++++++++++++++++++++++++ 1 file changed, 71 insertions(+) diff --git a/destroy-cumulus/destroy_cumulus.py b/destroy-cumulus/destroy_cumulus.py index fbc4845..7523dea 100644 --- a/destroy-cumulus/destroy_cumulus.py +++ b/destroy-cumulus/destroy_cumulus.py @@ -1074,6 +1074,76 @@ def delete(self, get_client): client.delete_task_definitions(taskDefinitions=[str(self.arn)]) +class EFSFileSystem(Resource): + TYPE_FILTER = "elasticfilesystem:file-system" + + def __init__(self, name, id, mount_targets, *, arn=None, tags=()): + super().__init__(name, id, arn=arn, tags=tags) + self.mount_targets = sorted( + mount_targets, + key=lambda res: (res.name, res.id), + ) + + @classmethod + def gather(cls, get_client, name_matcher, _options): + client = get_client("efs") + paginator = client.get_paginator("describe_file_systems") + + return [ + # ruff hint + cls( + name, + entry["FileSystemId"], + mount_targets=[], + arn=Arn(entry["FileSystemArn"]), + tags=entry.get("Tags", ()), + ) + for response in paginator.paginate() + for entry in response.get("FileSystems", ()) + if name_matcher.matches(name := entry["Name"]) + ] + + def load(self, get_client): + client = get_client("efs") + paginator = client.get_paginator("describe_file_systems") + target_paginator = client.get_paginator("describe_mount_targets") + + for response in paginator.paginate(FileSystemId=self.id): + for entry in response.get("FileSystems", ()): + if entry["FileSystemId"] != self.id: + continue + + self.name = entry["Name"] + self.tags = _tag_dict(entry.get("Tags", ())) + + self.mount_targets = sorted( + [ + EFSMountTarget( + entry["MountTargetId"], + entry["MountTargetId"], + state=entry["LifeCycleState"], + ) + for response in target_paginator.paginate(FileSystemId=self.id) + for entry in response.get("MountTargets", ()) + ], + key=lambda res: (res.name, res.id), + ) + + def delete(self, get_client): + client = get_client("efs") + client.delete_file_system(FileSystemId=self.id) + + def get_dependencies(self): + return self.mount_targets + + +class EFSMountTarget(StateResource): + TYPE_FILTER = "elasticfilesystem:mount-target" + + def delete(self, get_client): + client = get_client("efs") + client.delete_mount_target(MountTargetId=self.id) + class ElasticsearchDomain(Resource): """These are expensive: $$""" @@ -1998,6 +2068,7 @@ class CumulusDestroyer: ECSCluster, ECSTaskDefinition, ECRRepository, + EFSFileSystem, ELBLoadBalancer, ELBTargetGroup, RDSCluster, From 73e1fd93b58b53e37fc25cebd26aff633a2f3a85 Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 15:47:05 -0400 Subject: [PATCH 08/10] Add ACM certificate --- destroy-cumulus/destroy_cumulus.py | 23 +++++++++++++++++++++++ 1 file changed, 23 insertions(+) diff --git a/destroy-cumulus/destroy_cumulus.py b/destroy-cumulus/destroy_cumulus.py index 7523dea..06ac2b3 100644 --- a/destroy-cumulus/destroy_cumulus.py +++ b/destroy-cumulus/destroy_cumulus.py @@ -708,6 +708,28 @@ def list_objects(): client.delete_bucket(Bucket=self.name) +class Certificate(Resource): + TYPE_FILTER = "acm:certificate" + + @classmethod + def gather(cls, get_client, name_matcher, _options): + client = get_client("acm") + paginator = client.get_paginator("list_certificates") + + return [ + cls.from_arn( + Arn(entry["CertificateArn"]), + ) + for response in paginator.paginate() + for entry in response.get("CertificateSummaryList", ()) + if name_matcher.matches(entry["DomainName"]) + ] + + def delete(self, get_client): + client = get_client("acm") + client.delete_certificate(CertificateArn=str(self.arn)) + + class CloudFormationStack(Resource): TYPE_FILTER = "cloudformation:stack" @@ -2047,6 +2069,7 @@ class CumulusDestroyer: RESOURCE_DESTRUCTION_ORDER = [ CloudFormationStack, ApiGateway, + Certificate, LambdaFunction, LambdaLayerVersion, StepFunction, From 880be15195719309573e0651d119703b8cf4281e Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 15:47:25 -0400 Subject: [PATCH 09/10] Implement load for ECS clusters --- destroy-cumulus/destroy_cumulus.py | 25 +++++++++++++++++++++++++ 1 file changed, 25 insertions(+) diff --git a/destroy-cumulus/destroy_cumulus.py b/destroy-cumulus/destroy_cumulus.py index 06ac2b3..216f933 100644 --- a/destroy-cumulus/destroy_cumulus.py +++ b/destroy-cumulus/destroy_cumulus.py @@ -1037,6 +1037,31 @@ def gather(cls, get_client, name_matcher, _options): if name_matcher.matches((arn := Arn(arn_)).name) ] + def load(self, get_client): + self.load_bulk(get_client, [self]) + + @classmethod + def load_bulk(cls, get_client, resources): + client = get_client("ecs") + + clusters_by_arn = {str(resource.arn): resource for resource in resources} + + # Can't be paginated. Will accept up to 100 cluster ARNs + response = client.describe_clusters( + clusters=[str(resource.arn) for resource in resources], + include=["TAGS"], + ) + + for entry in response.get("clusters", ()): + cluster = clusters_by_arn[entry["clusterArn"]] + + cluster.state = entry["status"] + cluster.tags = { + # ruff hint + tag["key"]: tag["value"] + for tag in entry.get("tags") + } + def delete(self, get_client): client = get_client("ecs") client.delete_cluster(cluster=self.name) From 1d08fd02b76971c6e46fc1b937544e397e49e8af Mon Sep 17 00:00:00 2001 From: Rohan Weeden Date: Mon, 6 Jul 2026 15:47:43 -0400 Subject: [PATCH 10/10] Bump version number --- destroy-cumulus/pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/destroy-cumulus/pyproject.toml b/destroy-cumulus/pyproject.toml index 06a9568..97ef0ee 100644 --- a/destroy-cumulus/pyproject.toml +++ b/destroy-cumulus/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "poetry.core.masonry.api" [tool.poetry] name = "destroy-cumulus" -version = "0.3.1" +version = "0.4.0" description = "" authors = ["Rohan Weeden "] readme = "README.md"