aws-inventory-manager 0.17.12__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- aws_inventory_manager-0.17.12.dist-info/LICENSE +21 -0
- aws_inventory_manager-0.17.12.dist-info/METADATA +1292 -0
- aws_inventory_manager-0.17.12.dist-info/RECORD +152 -0
- aws_inventory_manager-0.17.12.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.17.12.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.17.12.dist-info/top_level.txt +1 -0
- src/__init__.py +3 -0
- src/aws/__init__.py +11 -0
- src/aws/client.py +128 -0
- src/aws/credentials.py +191 -0
- src/aws/rate_limiter.py +177 -0
- src/cli/__init__.py +12 -0
- src/cli/config.py +130 -0
- src/cli/main.py +4046 -0
- src/cloudtrail/__init__.py +5 -0
- src/cloudtrail/query.py +642 -0
- src/config_service/__init__.py +21 -0
- src/config_service/collector.py +346 -0
- src/config_service/detector.py +256 -0
- src/config_service/resource_type_mapping.py +328 -0
- src/cost/__init__.py +5 -0
- src/cost/analyzer.py +226 -0
- src/cost/explorer.py +209 -0
- src/cost/reporter.py +237 -0
- src/delta/__init__.py +5 -0
- src/delta/calculator.py +206 -0
- src/delta/differ.py +185 -0
- src/delta/formatters.py +272 -0
- src/delta/models.py +154 -0
- src/delta/reporter.py +234 -0
- src/matching/__init__.py +6 -0
- src/matching/config.py +52 -0
- src/matching/normalizer.py +450 -0
- src/matching/prompts.py +33 -0
- src/models/__init__.py +21 -0
- src/models/config_diff.py +135 -0
- src/models/cost_report.py +87 -0
- src/models/deletion_operation.py +104 -0
- src/models/deletion_record.py +97 -0
- src/models/delta_report.py +122 -0
- src/models/efs_resource.py +80 -0
- src/models/elasticache_resource.py +90 -0
- src/models/group.py +318 -0
- src/models/inventory.py +133 -0
- src/models/protection_rule.py +123 -0
- src/models/report.py +288 -0
- src/models/resource.py +111 -0
- src/models/security_finding.py +102 -0
- src/models/snapshot.py +122 -0
- src/restore/__init__.py +20 -0
- src/restore/audit.py +175 -0
- src/restore/cleaner.py +461 -0
- src/restore/config.py +209 -0
- src/restore/deleter.py +976 -0
- src/restore/dependency.py +254 -0
- src/restore/safety.py +115 -0
- src/security/__init__.py +0 -0
- src/security/checks/__init__.py +0 -0
- src/security/checks/base.py +56 -0
- src/security/checks/ec2_checks.py +88 -0
- src/security/checks/elasticache_checks.py +149 -0
- src/security/checks/iam_checks.py +102 -0
- src/security/checks/rds_checks.py +140 -0
- src/security/checks/s3_checks.py +95 -0
- src/security/checks/secrets_checks.py +96 -0
- src/security/checks/sg_checks.py +142 -0
- src/security/cis_mapper.py +97 -0
- src/security/models.py +53 -0
- src/security/reporter.py +174 -0
- src/security/scanner.py +87 -0
- src/snapshot/__init__.py +6 -0
- src/snapshot/capturer.py +453 -0
- src/snapshot/filter.py +259 -0
- src/snapshot/inventory_storage.py +236 -0
- src/snapshot/report_formatter.py +250 -0
- src/snapshot/reporter.py +189 -0
- src/snapshot/resource_collectors/__init__.py +5 -0
- src/snapshot/resource_collectors/apigateway.py +140 -0
- src/snapshot/resource_collectors/backup.py +136 -0
- src/snapshot/resource_collectors/base.py +81 -0
- src/snapshot/resource_collectors/cloudformation.py +55 -0
- src/snapshot/resource_collectors/cloudwatch.py +109 -0
- src/snapshot/resource_collectors/codebuild.py +69 -0
- src/snapshot/resource_collectors/codepipeline.py +82 -0
- src/snapshot/resource_collectors/dynamodb.py +65 -0
- src/snapshot/resource_collectors/ec2.py +240 -0
- src/snapshot/resource_collectors/ecs.py +215 -0
- src/snapshot/resource_collectors/efs_collector.py +102 -0
- src/snapshot/resource_collectors/eks.py +200 -0
- src/snapshot/resource_collectors/elasticache_collector.py +79 -0
- src/snapshot/resource_collectors/elb.py +126 -0
- src/snapshot/resource_collectors/eventbridge.py +156 -0
- src/snapshot/resource_collectors/glue.py +199 -0
- src/snapshot/resource_collectors/iam.py +188 -0
- src/snapshot/resource_collectors/kms.py +111 -0
- src/snapshot/resource_collectors/lambda_func.py +139 -0
- src/snapshot/resource_collectors/rds.py +109 -0
- src/snapshot/resource_collectors/route53.py +86 -0
- src/snapshot/resource_collectors/s3.py +105 -0
- src/snapshot/resource_collectors/secretsmanager.py +70 -0
- src/snapshot/resource_collectors/sns.py +68 -0
- src/snapshot/resource_collectors/sqs.py +82 -0
- src/snapshot/resource_collectors/ssm.py +160 -0
- src/snapshot/resource_collectors/stepfunctions.py +74 -0
- src/snapshot/resource_collectors/vpcendpoints.py +79 -0
- src/snapshot/resource_collectors/waf.py +159 -0
- src/snapshot/storage.py +351 -0
- src/storage/__init__.py +21 -0
- src/storage/audit_store.py +419 -0
- src/storage/database.py +294 -0
- src/storage/group_store.py +763 -0
- src/storage/inventory_store.py +320 -0
- src/storage/resource_store.py +416 -0
- src/storage/schema.py +339 -0
- src/storage/snapshot_store.py +363 -0
- src/utils/__init__.py +12 -0
- src/utils/export.py +305 -0
- src/utils/hash.py +60 -0
- src/utils/logging.py +63 -0
- src/utils/pagination.py +41 -0
- src/utils/paths.py +51 -0
- src/utils/progress.py +41 -0
- src/utils/unsupported_resources.py +306 -0
- src/web/__init__.py +5 -0
- src/web/app.py +97 -0
- src/web/dependencies.py +69 -0
- src/web/routes/__init__.py +1 -0
- src/web/routes/api/__init__.py +18 -0
- src/web/routes/api/charts.py +156 -0
- src/web/routes/api/cleanup.py +186 -0
- src/web/routes/api/filters.py +253 -0
- src/web/routes/api/groups.py +305 -0
- src/web/routes/api/inventories.py +80 -0
- src/web/routes/api/queries.py +202 -0
- src/web/routes/api/resources.py +393 -0
- src/web/routes/api/snapshots.py +314 -0
- src/web/routes/api/views.py +260 -0
- src/web/routes/pages.py +198 -0
- src/web/services/__init__.py +1 -0
- src/web/templates/base.html +955 -0
- src/web/templates/components/navbar.html +31 -0
- src/web/templates/components/sidebar.html +104 -0
- src/web/templates/pages/audit_logs.html +86 -0
- src/web/templates/pages/cleanup.html +279 -0
- src/web/templates/pages/dashboard.html +227 -0
- src/web/templates/pages/diff.html +175 -0
- src/web/templates/pages/error.html +30 -0
- src/web/templates/pages/groups.html +721 -0
- src/web/templates/pages/queries.html +246 -0
- src/web/templates/pages/resources.html +2429 -0
- src/web/templates/pages/snapshot_detail.html +271 -0
- src/web/templates/pages/snapshots.html +429 -0
src/restore/deleter.py
ADDED
|
@@ -0,0 +1,976 @@
|
|
|
1
|
+
"""AWS resource deletion strategies.
|
|
2
|
+
|
|
3
|
+
Maps AWS resource types to their deletion methods with proper error handling
|
|
4
|
+
and retry logic. Handles prerequisite cleanup for resources that require it
|
|
5
|
+
(e.g., emptying S3 buckets, detaching IAM policies).
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import time
|
|
12
|
+
from typing import Any, Optional
|
|
13
|
+
|
|
14
|
+
from botocore.exceptions import ClientError
|
|
15
|
+
|
|
16
|
+
from src.aws.client import create_boto_client
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
# Resource types that require prerequisite cleanup before deletion
|
|
21
|
+
RESOURCES_WITH_PREREQUISITES = {
|
|
22
|
+
"AWS::S3::Bucket",
|
|
23
|
+
"AWS::IAM::Role",
|
|
24
|
+
"AWS::IAM::User",
|
|
25
|
+
"AWS::Events::Rule",
|
|
26
|
+
"AWS::Route53::HostedZone",
|
|
27
|
+
"AWS::Backup::BackupVault",
|
|
28
|
+
"AWS::WAFv2::WebACL",
|
|
29
|
+
"AWS::WAFv2::RuleGroup",
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class ResourceDeleter:
|
|
34
|
+
"""AWS resource deletion orchestrator.
|
|
35
|
+
|
|
36
|
+
Handles deletion of various AWS resource types using appropriate boto3 API calls.
|
|
37
|
+
Implements retry logic and error handling for safe resource cleanup.
|
|
38
|
+
"""
|
|
39
|
+
|
|
40
|
+
# Deletion method mapping: resource_type -> (service, method, id_field)
|
|
41
|
+
DELETION_METHODS = {
|
|
42
|
+
# EC2 Resources
|
|
43
|
+
"AWS::EC2::Instance": ("ec2", "terminate_instances", "InstanceIds"),
|
|
44
|
+
"AWS::EC2::SecurityGroup": ("ec2", "delete_security_group", "GroupId"),
|
|
45
|
+
"AWS::EC2::Volume": ("ec2", "delete_volume", "VolumeId"),
|
|
46
|
+
"AWS::EC2::VPC": ("ec2", "delete_vpc", "VpcId"),
|
|
47
|
+
"AWS::EC2::Subnet": ("ec2", "delete_subnet", "SubnetId"),
|
|
48
|
+
"AWS::EC2::InternetGateway": ("ec2", "delete_internet_gateway", "InternetGatewayId"),
|
|
49
|
+
"AWS::EC2::RouteTable": ("ec2", "delete_route_table", "RouteTableId"),
|
|
50
|
+
"AWS::EC2::NetworkInterface": ("ec2", "delete_network_interface", "NetworkInterfaceId"),
|
|
51
|
+
"AWS::EC2::KeyPair": ("ec2", "delete_key_pair", "KeyName"),
|
|
52
|
+
# S3
|
|
53
|
+
"AWS::S3::Bucket": ("s3", "delete_bucket", "Bucket"),
|
|
54
|
+
# Lambda
|
|
55
|
+
"AWS::Lambda::Function": ("lambda", "delete_function", "FunctionName"),
|
|
56
|
+
# DynamoDB
|
|
57
|
+
"AWS::DynamoDB::Table": ("dynamodb", "delete_table", "TableName"),
|
|
58
|
+
# RDS
|
|
59
|
+
"AWS::RDS::DBInstance": ("rds", "delete_db_instance", "DBInstanceIdentifier"),
|
|
60
|
+
"AWS::RDS::DBCluster": ("rds", "delete_db_cluster", "DBClusterIdentifier"),
|
|
61
|
+
# IAM
|
|
62
|
+
"AWS::IAM::Role": ("iam", "delete_role", "RoleName"),
|
|
63
|
+
"AWS::IAM::User": ("iam", "delete_user", "UserName"),
|
|
64
|
+
"AWS::IAM::Policy": ("iam", "delete_policy", "PolicyArn"),
|
|
65
|
+
# ECS
|
|
66
|
+
"AWS::ECS::Service": ("ecs", "delete_service", "service"),
|
|
67
|
+
"AWS::ECS::Cluster": ("ecs", "delete_cluster", "cluster"),
|
|
68
|
+
"AWS::ECS::TaskDefinition": ("ecs", "deregister_task_definition", "taskDefinition"),
|
|
69
|
+
# EKS
|
|
70
|
+
"AWS::EKS::Cluster": ("eks", "delete_cluster", "name"),
|
|
71
|
+
# SNS
|
|
72
|
+
"AWS::SNS::Topic": ("sns", "delete_topic", "TopicArn"),
|
|
73
|
+
# SQS
|
|
74
|
+
"AWS::SQS::Queue": ("sqs", "delete_queue", "QueueUrl"),
|
|
75
|
+
# CloudWatch
|
|
76
|
+
"AWS::CloudWatch::Alarm": ("cloudwatch", "delete_alarms", "AlarmNames"),
|
|
77
|
+
# API Gateway
|
|
78
|
+
"AWS::ApiGateway::RestApi": ("apigateway", "delete_rest_api", "restApiId"),
|
|
79
|
+
# KMS
|
|
80
|
+
"AWS::KMS::Key": ("kms", "schedule_key_deletion", "KeyId"),
|
|
81
|
+
# Secrets Manager
|
|
82
|
+
"AWS::SecretsManager::Secret": ("secretsmanager", "delete_secret", "SecretId"),
|
|
83
|
+
# ELB
|
|
84
|
+
"AWS::ElasticLoadBalancing::LoadBalancer": ("elb", "delete_load_balancer", "LoadBalancerName"),
|
|
85
|
+
"AWS::ElasticLoadBalancingV2::LoadBalancer": ("elbv2", "delete_load_balancer", "LoadBalancerArn"),
|
|
86
|
+
# EFS
|
|
87
|
+
"AWS::EFS::FileSystem": ("efs", "delete_file_system", "FileSystemId"),
|
|
88
|
+
# ElastiCache
|
|
89
|
+
"AWS::ElastiCache::CacheCluster": ("elasticache", "delete_cache_cluster", "CacheClusterId"),
|
|
90
|
+
# SSM
|
|
91
|
+
"AWS::SSM::Parameter": ("ssm", "delete_parameter", "Name"),
|
|
92
|
+
# Step Functions
|
|
93
|
+
"AWS::StepFunctions::StateMachine": ("sfn", "delete_state_machine", "stateMachineArn"),
|
|
94
|
+
# EventBridge
|
|
95
|
+
"AWS::Events::Rule": ("events", "delete_rule", "Name"),
|
|
96
|
+
# CodeBuild
|
|
97
|
+
"AWS::CodeBuild::Project": ("codebuild", "delete_project", "name"),
|
|
98
|
+
# VPC Endpoints
|
|
99
|
+
"AWS::EC2::VPCEndpoint": ("ec2", "delete_vpc_endpoints", "VpcEndpointIds"),
|
|
100
|
+
# CodePipeline
|
|
101
|
+
"AWS::CodePipeline::Pipeline": ("codepipeline", "delete_pipeline", "name"),
|
|
102
|
+
# CloudFormation
|
|
103
|
+
"AWS::CloudFormation::Stack": ("cloudformation", "delete_stack", "StackName"),
|
|
104
|
+
# Route53
|
|
105
|
+
"AWS::Route53::HostedZone": ("route53", "delete_hosted_zone", "Id"),
|
|
106
|
+
# Backup
|
|
107
|
+
"AWS::Backup::BackupPlan": ("backup", "delete_backup_plan", "BackupPlanId"),
|
|
108
|
+
"AWS::Backup::BackupVault": ("backup", "delete_backup_vault", "BackupVaultName"),
|
|
109
|
+
# WAF
|
|
110
|
+
"AWS::WAFv2::WebACL": ("wafv2", "delete_web_acl", "Id"),
|
|
111
|
+
"AWS::WAFv2::RuleGroup": ("wafv2", "delete_rule_group", "Id"),
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
def __init__(self, aws_profile: Optional[str] = None, max_retries: int = 3):
|
|
115
|
+
"""Initialize resource deleter.
|
|
116
|
+
|
|
117
|
+
Args:
|
|
118
|
+
aws_profile: AWS profile name (optional)
|
|
119
|
+
max_retries: Maximum number of retry attempts (default: 3)
|
|
120
|
+
"""
|
|
121
|
+
self.aws_profile = aws_profile
|
|
122
|
+
self.max_retries = max_retries
|
|
123
|
+
|
|
124
|
+
def delete_resource(
|
|
125
|
+
self,
|
|
126
|
+
resource_type: str,
|
|
127
|
+
resource_id: str,
|
|
128
|
+
region: str,
|
|
129
|
+
arn: str,
|
|
130
|
+
) -> tuple[bool, Optional[str]]:
|
|
131
|
+
"""Delete an AWS resource.
|
|
132
|
+
|
|
133
|
+
Args:
|
|
134
|
+
resource_type: AWS resource type (e.g., "AWS::EC2::Instance")
|
|
135
|
+
resource_id: Resource identifier
|
|
136
|
+
region: AWS region
|
|
137
|
+
arn: Resource ARN
|
|
138
|
+
|
|
139
|
+
Returns:
|
|
140
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
141
|
+
"""
|
|
142
|
+
# Check if we support this resource type
|
|
143
|
+
if resource_type not in self.DELETION_METHODS:
|
|
144
|
+
error_msg = f"Unsupported resource type: {resource_type}"
|
|
145
|
+
logger.warning(error_msg)
|
|
146
|
+
return (False, error_msg)
|
|
147
|
+
|
|
148
|
+
service, method, id_field = self.DELETION_METHODS[resource_type]
|
|
149
|
+
|
|
150
|
+
# Try deletion with retries
|
|
151
|
+
for attempt in range(self.max_retries):
|
|
152
|
+
try:
|
|
153
|
+
success, error = self._attempt_deletion(
|
|
154
|
+
service=service,
|
|
155
|
+
method=method,
|
|
156
|
+
id_field=id_field,
|
|
157
|
+
resource_id=resource_id,
|
|
158
|
+
resource_type=resource_type,
|
|
159
|
+
region=region,
|
|
160
|
+
arn=arn,
|
|
161
|
+
)
|
|
162
|
+
|
|
163
|
+
if success:
|
|
164
|
+
logger.info(f"Successfully deleted {resource_type}: {resource_id}")
|
|
165
|
+
return (True, None)
|
|
166
|
+
elif "DependencyViolation" in (error or ""):
|
|
167
|
+
# Dependency violations should be retried
|
|
168
|
+
if attempt < self.max_retries - 1:
|
|
169
|
+
wait_time = 2**attempt # Exponential backoff
|
|
170
|
+
logger.debug(
|
|
171
|
+
f"Dependency violation for {resource_id}, "
|
|
172
|
+
f"retrying in {wait_time}s (attempt {attempt + 1}/{self.max_retries})"
|
|
173
|
+
)
|
|
174
|
+
time.sleep(wait_time)
|
|
175
|
+
continue
|
|
176
|
+
else:
|
|
177
|
+
# Non-retryable error
|
|
178
|
+
return (False, error)
|
|
179
|
+
|
|
180
|
+
except Exception as e:
|
|
181
|
+
error_msg = f"Unexpected error deleting {resource_type} {resource_id}: {str(e)}"
|
|
182
|
+
logger.error(error_msg)
|
|
183
|
+
if attempt < self.max_retries - 1:
|
|
184
|
+
time.sleep(2**attempt)
|
|
185
|
+
continue
|
|
186
|
+
return (False, error_msg)
|
|
187
|
+
|
|
188
|
+
# All retries exhausted
|
|
189
|
+
error_msg = f"Failed to delete {resource_type} {resource_id} after {self.max_retries} attempts"
|
|
190
|
+
logger.error(error_msg)
|
|
191
|
+
return (False, error_msg)
|
|
192
|
+
|
|
193
|
+
def _attempt_deletion(
|
|
194
|
+
self,
|
|
195
|
+
service: str,
|
|
196
|
+
method: str,
|
|
197
|
+
id_field: str,
|
|
198
|
+
resource_id: str,
|
|
199
|
+
resource_type: str,
|
|
200
|
+
region: str,
|
|
201
|
+
arn: str,
|
|
202
|
+
) -> tuple[bool, Optional[str]]:
|
|
203
|
+
"""Attempt a single deletion operation.
|
|
204
|
+
|
|
205
|
+
Args:
|
|
206
|
+
service: AWS service name
|
|
207
|
+
method: Boto3 method name
|
|
208
|
+
id_field: Parameter name for resource ID
|
|
209
|
+
resource_id: Resource identifier
|
|
210
|
+
resource_type: AWS resource type
|
|
211
|
+
region: AWS region
|
|
212
|
+
arn: Resource ARN
|
|
213
|
+
|
|
214
|
+
Returns:
|
|
215
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
216
|
+
"""
|
|
217
|
+
try:
|
|
218
|
+
# Run prerequisite cleanup if needed (e.g., empty S3 bucket, detach IAM policies)
|
|
219
|
+
prep_success, prep_error = self._prepare_for_deletion(
|
|
220
|
+
resource_type=resource_type,
|
|
221
|
+
resource_id=resource_id,
|
|
222
|
+
region=region,
|
|
223
|
+
arn=arn,
|
|
224
|
+
)
|
|
225
|
+
if not prep_success:
|
|
226
|
+
return (False, prep_error)
|
|
227
|
+
|
|
228
|
+
# Create boto3 client for the service
|
|
229
|
+
client = create_boto_client(
|
|
230
|
+
service_name=service,
|
|
231
|
+
region_name=region,
|
|
232
|
+
profile_name=self.aws_profile,
|
|
233
|
+
)
|
|
234
|
+
|
|
235
|
+
# Build parameters based on resource type
|
|
236
|
+
params = self._build_deletion_params(
|
|
237
|
+
resource_type=resource_type,
|
|
238
|
+
id_field=id_field,
|
|
239
|
+
resource_id=resource_id,
|
|
240
|
+
arn=arn,
|
|
241
|
+
)
|
|
242
|
+
|
|
243
|
+
# Call the deletion method
|
|
244
|
+
deletion_method = getattr(client, method)
|
|
245
|
+
deletion_method(**params)
|
|
246
|
+
|
|
247
|
+
return (True, None)
|
|
248
|
+
|
|
249
|
+
except ClientError as e:
|
|
250
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
251
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
252
|
+
|
|
253
|
+
# Handle specific error cases
|
|
254
|
+
if error_code in [
|
|
255
|
+
"InvalidInstanceID.NotFound",
|
|
256
|
+
"NoSuchEntity",
|
|
257
|
+
"ResourceNotFoundException",
|
|
258
|
+
"WAFNonexistentItemException",
|
|
259
|
+
"NoSuchHostedZone",
|
|
260
|
+
]:
|
|
261
|
+
# Resource already deleted
|
|
262
|
+
logger.info(f"Resource {resource_id} already deleted")
|
|
263
|
+
return (True, None)
|
|
264
|
+
elif error_code == "DependencyViolation":
|
|
265
|
+
# Dependencies still exist
|
|
266
|
+
logger.debug(f"Dependency violation for {resource_id}: {error_message}")
|
|
267
|
+
return (False, f"DependencyViolation: {error_message}")
|
|
268
|
+
else:
|
|
269
|
+
# Other client errors
|
|
270
|
+
logger.error(f"Failed to delete {resource_id}: {error_code} - {error_message}")
|
|
271
|
+
return (False, f"{error_code}: {error_message}")
|
|
272
|
+
|
|
273
|
+
except Exception as e:
|
|
274
|
+
error_msg = f"Unexpected error: {str(e)}"
|
|
275
|
+
logger.error(f"Failed to delete {resource_id}: {error_msg}")
|
|
276
|
+
return (False, error_msg)
|
|
277
|
+
|
|
278
|
+
def _build_deletion_params(
|
|
279
|
+
self,
|
|
280
|
+
resource_type: str,
|
|
281
|
+
id_field: str,
|
|
282
|
+
resource_id: str,
|
|
283
|
+
arn: str,
|
|
284
|
+
) -> dict[str, Any]:
|
|
285
|
+
"""Build deletion parameters for boto3 call.
|
|
286
|
+
|
|
287
|
+
Args:
|
|
288
|
+
resource_type: AWS resource type
|
|
289
|
+
id_field: Parameter name for resource ID
|
|
290
|
+
resource_id: Resource identifier
|
|
291
|
+
arn: Resource ARN
|
|
292
|
+
|
|
293
|
+
Returns:
|
|
294
|
+
Dictionary of parameters for boto3 method call
|
|
295
|
+
"""
|
|
296
|
+
# Handle list parameters (e.g., InstanceIds, AlarmNames)
|
|
297
|
+
if id_field.endswith("s"): # Plural form indicates list
|
|
298
|
+
return {id_field: [resource_id]}
|
|
299
|
+
|
|
300
|
+
# Handle ARN-based parameters
|
|
301
|
+
if "Arn" in id_field:
|
|
302
|
+
return {id_field: arn}
|
|
303
|
+
|
|
304
|
+
# Handle special cases
|
|
305
|
+
if resource_type == "AWS::RDS::DBInstance":
|
|
306
|
+
# Skip final snapshot for faster deletion
|
|
307
|
+
return {
|
|
308
|
+
id_field: resource_id,
|
|
309
|
+
"SkipFinalSnapshot": True,
|
|
310
|
+
"DeleteAutomatedBackups": True,
|
|
311
|
+
}
|
|
312
|
+
elif resource_type == "AWS::KMS::Key":
|
|
313
|
+
# Schedule deletion with minimum waiting period
|
|
314
|
+
return {
|
|
315
|
+
id_field: resource_id,
|
|
316
|
+
"PendingWindowInDays": 7,
|
|
317
|
+
}
|
|
318
|
+
elif resource_type == "AWS::SecretsManager::Secret":
|
|
319
|
+
# Immediate deletion (no recovery window)
|
|
320
|
+
return {
|
|
321
|
+
id_field: resource_id,
|
|
322
|
+
"ForceDeleteWithoutRecovery": True,
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
# Standard parameter
|
|
326
|
+
return {id_field: resource_id}
|
|
327
|
+
|
|
328
|
+
def _prepare_for_deletion(
|
|
329
|
+
self,
|
|
330
|
+
resource_type: str,
|
|
331
|
+
resource_id: str,
|
|
332
|
+
region: str,
|
|
333
|
+
arn: str,
|
|
334
|
+
) -> tuple[bool, Optional[str]]:
|
|
335
|
+
"""Run prerequisite cleanup before resource deletion.
|
|
336
|
+
|
|
337
|
+
Some resources require cleanup before they can be deleted (e.g., S3 buckets
|
|
338
|
+
must be emptied, IAM roles must have policies detached).
|
|
339
|
+
|
|
340
|
+
Args:
|
|
341
|
+
resource_type: AWS resource type
|
|
342
|
+
resource_id: Resource identifier
|
|
343
|
+
region: AWS region
|
|
344
|
+
arn: Resource ARN
|
|
345
|
+
|
|
346
|
+
Returns:
|
|
347
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
348
|
+
"""
|
|
349
|
+
if resource_type not in RESOURCES_WITH_PREREQUISITES:
|
|
350
|
+
return (True, None)
|
|
351
|
+
|
|
352
|
+
try:
|
|
353
|
+
if resource_type == "AWS::S3::Bucket":
|
|
354
|
+
return self._empty_s3_bucket(resource_id, region)
|
|
355
|
+
elif resource_type == "AWS::IAM::Role":
|
|
356
|
+
return self._cleanup_iam_role(resource_id)
|
|
357
|
+
elif resource_type == "AWS::IAM::User":
|
|
358
|
+
return self._cleanup_iam_user(resource_id)
|
|
359
|
+
elif resource_type == "AWS::Events::Rule":
|
|
360
|
+
return self._cleanup_eventbridge_rule(resource_id, region)
|
|
361
|
+
elif resource_type == "AWS::Route53::HostedZone":
|
|
362
|
+
return self._cleanup_route53_hosted_zone(resource_id)
|
|
363
|
+
elif resource_type == "AWS::Backup::BackupVault":
|
|
364
|
+
return self._cleanup_backup_vault(resource_id)
|
|
365
|
+
elif resource_type == "AWS::WAFv2::WebACL":
|
|
366
|
+
return self._cleanup_waf_webacl(resource_id, arn, region)
|
|
367
|
+
elif resource_type == "AWS::WAFv2::RuleGroup":
|
|
368
|
+
return self._cleanup_waf_rulegroup(resource_id, arn, region)
|
|
369
|
+
except Exception as e:
|
|
370
|
+
error_msg = f"Prerequisite cleanup failed for {resource_type} {resource_id}: {str(e)}"
|
|
371
|
+
logger.error(error_msg)
|
|
372
|
+
return (False, error_msg)
|
|
373
|
+
|
|
374
|
+
return (True, None)
|
|
375
|
+
|
|
376
|
+
def _empty_s3_bucket(self, bucket_name: str, region: str) -> tuple[bool, Optional[str]]:
|
|
377
|
+
"""Empty an S3 bucket before deletion.
|
|
378
|
+
|
|
379
|
+
Handles both versioned and non-versioned buckets by deleting all objects,
|
|
380
|
+
versions, and delete markers.
|
|
381
|
+
|
|
382
|
+
Args:
|
|
383
|
+
bucket_name: Name of the S3 bucket
|
|
384
|
+
region: AWS region (used for client creation)
|
|
385
|
+
|
|
386
|
+
Returns:
|
|
387
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
388
|
+
"""
|
|
389
|
+
try:
|
|
390
|
+
s3_client = create_boto_client(
|
|
391
|
+
service_name="s3",
|
|
392
|
+
region_name=region,
|
|
393
|
+
profile_name=self.aws_profile,
|
|
394
|
+
)
|
|
395
|
+
|
|
396
|
+
# Check if bucket has object lock (cannot empty these buckets easily)
|
|
397
|
+
try:
|
|
398
|
+
lock_config = s3_client.get_object_lock_configuration(Bucket=bucket_name)
|
|
399
|
+
if lock_config.get("ObjectLockConfiguration", {}).get("ObjectLockEnabled") == "Enabled":
|
|
400
|
+
return (False, "Bucket has Object Lock enabled - cannot empty automatically")
|
|
401
|
+
except ClientError as e:
|
|
402
|
+
# ObjectLockConfigurationNotFoundError means no lock - that's fine
|
|
403
|
+
if e.response.get("Error", {}).get("Code") != "ObjectLockConfigurationNotFoundError":
|
|
404
|
+
raise
|
|
405
|
+
|
|
406
|
+
deleted_count = 0
|
|
407
|
+
|
|
408
|
+
# Delete all object versions (handles versioned buckets)
|
|
409
|
+
paginator = s3_client.get_paginator("list_object_versions")
|
|
410
|
+
try:
|
|
411
|
+
for page in paginator.paginate(Bucket=bucket_name):
|
|
412
|
+
objects_to_delete = []
|
|
413
|
+
|
|
414
|
+
# Collect versions
|
|
415
|
+
for version in page.get("Versions", []):
|
|
416
|
+
objects_to_delete.append(
|
|
417
|
+
{
|
|
418
|
+
"Key": version["Key"],
|
|
419
|
+
"VersionId": version["VersionId"],
|
|
420
|
+
}
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
# Collect delete markers
|
|
424
|
+
for marker in page.get("DeleteMarkers", []):
|
|
425
|
+
objects_to_delete.append(
|
|
426
|
+
{
|
|
427
|
+
"Key": marker["Key"],
|
|
428
|
+
"VersionId": marker["VersionId"],
|
|
429
|
+
}
|
|
430
|
+
)
|
|
431
|
+
|
|
432
|
+
if objects_to_delete:
|
|
433
|
+
# Batch delete (max 1000 per request)
|
|
434
|
+
for i in range(0, len(objects_to_delete), 1000):
|
|
435
|
+
batch = objects_to_delete[i : i + 1000]
|
|
436
|
+
s3_client.delete_objects(
|
|
437
|
+
Bucket=bucket_name,
|
|
438
|
+
Delete={"Objects": batch, "Quiet": True},
|
|
439
|
+
)
|
|
440
|
+
deleted_count += len(batch)
|
|
441
|
+
|
|
442
|
+
except ClientError as e:
|
|
443
|
+
# If versioning was never enabled, fall back to simple object listing
|
|
444
|
+
if e.response.get("Error", {}).get("Code") == "NoSuchBucket":
|
|
445
|
+
return (True, None) # Bucket already gone
|
|
446
|
+
|
|
447
|
+
# Try non-versioned approach
|
|
448
|
+
paginator = s3_client.get_paginator("list_objects_v2")
|
|
449
|
+
for page in paginator.paginate(Bucket=bucket_name):
|
|
450
|
+
objects_to_delete = [{"Key": obj["Key"]} for obj in page.get("Contents", [])]
|
|
451
|
+
|
|
452
|
+
if objects_to_delete:
|
|
453
|
+
for i in range(0, len(objects_to_delete), 1000):
|
|
454
|
+
batch = objects_to_delete[i : i + 1000]
|
|
455
|
+
s3_client.delete_objects(
|
|
456
|
+
Bucket=bucket_name,
|
|
457
|
+
Delete={"Objects": batch, "Quiet": True},
|
|
458
|
+
)
|
|
459
|
+
deleted_count += len(batch)
|
|
460
|
+
|
|
461
|
+
logger.info(f"Emptied S3 bucket {bucket_name}: deleted {deleted_count} objects/versions")
|
|
462
|
+
return (True, None)
|
|
463
|
+
|
|
464
|
+
except ClientError as e:
|
|
465
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
466
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
467
|
+
|
|
468
|
+
if error_code == "NoSuchBucket":
|
|
469
|
+
return (True, None) # Bucket already deleted
|
|
470
|
+
|
|
471
|
+
return (False, f"Failed to empty bucket: {error_code}: {error_message}")
|
|
472
|
+
|
|
473
|
+
def _cleanup_iam_role(self, role_name: str) -> tuple[bool, Optional[str]]:
|
|
474
|
+
"""Clean up IAM role before deletion.
|
|
475
|
+
|
|
476
|
+
Detaches managed policies, deletes inline policies, and removes the role
|
|
477
|
+
from any instance profiles.
|
|
478
|
+
|
|
479
|
+
Args:
|
|
480
|
+
role_name: Name of the IAM role
|
|
481
|
+
|
|
482
|
+
Returns:
|
|
483
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
484
|
+
"""
|
|
485
|
+
try:
|
|
486
|
+
iam_client = create_boto_client(
|
|
487
|
+
service_name="iam",
|
|
488
|
+
region_name="us-east-1", # IAM is global
|
|
489
|
+
profile_name=self.aws_profile,
|
|
490
|
+
)
|
|
491
|
+
|
|
492
|
+
# 1. Detach all managed policies
|
|
493
|
+
paginator = iam_client.get_paginator("list_attached_role_policies")
|
|
494
|
+
for page in paginator.paginate(RoleName=role_name):
|
|
495
|
+
for policy in page.get("AttachedPolicies", []):
|
|
496
|
+
iam_client.detach_role_policy(
|
|
497
|
+
RoleName=role_name,
|
|
498
|
+
PolicyArn=policy["PolicyArn"],
|
|
499
|
+
)
|
|
500
|
+
logger.debug(f"Detached policy {policy['PolicyArn']} from role {role_name}")
|
|
501
|
+
|
|
502
|
+
# 2. Delete all inline policies
|
|
503
|
+
paginator = iam_client.get_paginator("list_role_policies")
|
|
504
|
+
for page in paginator.paginate(RoleName=role_name):
|
|
505
|
+
for policy_name in page.get("PolicyNames", []):
|
|
506
|
+
iam_client.delete_role_policy(
|
|
507
|
+
RoleName=role_name,
|
|
508
|
+
PolicyName=policy_name,
|
|
509
|
+
)
|
|
510
|
+
logger.debug(f"Deleted inline policy {policy_name} from role {role_name}")
|
|
511
|
+
|
|
512
|
+
# 3. Remove role from all instance profiles
|
|
513
|
+
paginator = iam_client.get_paginator("list_instance_profiles_for_role")
|
|
514
|
+
for page in paginator.paginate(RoleName=role_name):
|
|
515
|
+
for profile in page.get("InstanceProfiles", []):
|
|
516
|
+
iam_client.remove_role_from_instance_profile(
|
|
517
|
+
InstanceProfileName=profile["InstanceProfileName"],
|
|
518
|
+
RoleName=role_name,
|
|
519
|
+
)
|
|
520
|
+
logger.debug(f"Removed role {role_name} from instance profile {profile['InstanceProfileName']}")
|
|
521
|
+
|
|
522
|
+
logger.info(f"Cleaned up IAM role {role_name} for deletion")
|
|
523
|
+
return (True, None)
|
|
524
|
+
|
|
525
|
+
except ClientError as e:
|
|
526
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
527
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
528
|
+
|
|
529
|
+
if error_code == "NoSuchEntity":
|
|
530
|
+
return (True, None) # Role already deleted
|
|
531
|
+
|
|
532
|
+
return (False, f"Failed to cleanup IAM role: {error_code}: {error_message}")
|
|
533
|
+
|
|
534
|
+
def _cleanup_iam_user(self, user_name: str) -> tuple[bool, Optional[str]]:
|
|
535
|
+
"""Clean up IAM user before deletion.
|
|
536
|
+
|
|
537
|
+
Removes access keys, MFA devices, signing certificates, SSH keys,
|
|
538
|
+
service-specific credentials, detaches policies, deletes inline policies,
|
|
539
|
+
and removes from groups.
|
|
540
|
+
|
|
541
|
+
Args:
|
|
542
|
+
user_name: Name of the IAM user
|
|
543
|
+
|
|
544
|
+
Returns:
|
|
545
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
546
|
+
"""
|
|
547
|
+
try:
|
|
548
|
+
iam_client = create_boto_client(
|
|
549
|
+
service_name="iam",
|
|
550
|
+
region_name="us-east-1", # IAM is global
|
|
551
|
+
profile_name=self.aws_profile,
|
|
552
|
+
)
|
|
553
|
+
|
|
554
|
+
# 1. Delete access keys
|
|
555
|
+
paginator = iam_client.get_paginator("list_access_keys")
|
|
556
|
+
for page in paginator.paginate(UserName=user_name):
|
|
557
|
+
for key in page.get("AccessKeyMetadata", []):
|
|
558
|
+
iam_client.delete_access_key(
|
|
559
|
+
UserName=user_name,
|
|
560
|
+
AccessKeyId=key["AccessKeyId"],
|
|
561
|
+
)
|
|
562
|
+
logger.debug(f"Deleted access key {key['AccessKeyId']} for user {user_name}")
|
|
563
|
+
|
|
564
|
+
# 2. Deactivate and delete MFA devices
|
|
565
|
+
paginator = iam_client.get_paginator("list_mfa_devices")
|
|
566
|
+
for page in paginator.paginate(UserName=user_name):
|
|
567
|
+
for device in page.get("MFADevices", []):
|
|
568
|
+
iam_client.deactivate_mfa_device(
|
|
569
|
+
UserName=user_name,
|
|
570
|
+
SerialNumber=device["SerialNumber"],
|
|
571
|
+
)
|
|
572
|
+
# Only delete virtual MFA devices (not hardware)
|
|
573
|
+
if "arn:aws:iam::" in device["SerialNumber"] and "mfa/" in device["SerialNumber"]:
|
|
574
|
+
try:
|
|
575
|
+
iam_client.delete_virtual_mfa_device(
|
|
576
|
+
SerialNumber=device["SerialNumber"],
|
|
577
|
+
)
|
|
578
|
+
except ClientError:
|
|
579
|
+
pass # May fail for hardware devices
|
|
580
|
+
logger.debug(f"Deactivated MFA device {device['SerialNumber']} for user {user_name}")
|
|
581
|
+
|
|
582
|
+
# 3. Delete signing certificates
|
|
583
|
+
paginator = iam_client.get_paginator("list_signing_certificates")
|
|
584
|
+
for page in paginator.paginate(UserName=user_name):
|
|
585
|
+
for cert in page.get("Certificates", []):
|
|
586
|
+
iam_client.delete_signing_certificate(
|
|
587
|
+
UserName=user_name,
|
|
588
|
+
CertificateId=cert["CertificateId"],
|
|
589
|
+
)
|
|
590
|
+
logger.debug(f"Deleted signing certificate {cert['CertificateId']} for user {user_name}")
|
|
591
|
+
|
|
592
|
+
# 4. Delete SSH public keys
|
|
593
|
+
paginator = iam_client.get_paginator("list_ssh_public_keys")
|
|
594
|
+
for page in paginator.paginate(UserName=user_name):
|
|
595
|
+
for key in page.get("SSHPublicKeys", []):
|
|
596
|
+
iam_client.delete_ssh_public_key(
|
|
597
|
+
UserName=user_name,
|
|
598
|
+
SSHPublicKeyId=key["SSHPublicKeyId"],
|
|
599
|
+
)
|
|
600
|
+
logger.debug(f"Deleted SSH key {key['SSHPublicKeyId']} for user {user_name}")
|
|
601
|
+
|
|
602
|
+
# 5. Delete service-specific credentials
|
|
603
|
+
try:
|
|
604
|
+
response = iam_client.list_service_specific_credentials(UserName=user_name)
|
|
605
|
+
for cred in response.get("ServiceSpecificCredentials", []):
|
|
606
|
+
iam_client.delete_service_specific_credential(
|
|
607
|
+
UserName=user_name,
|
|
608
|
+
ServiceSpecificCredentialId=cred["ServiceSpecificCredentialId"],
|
|
609
|
+
)
|
|
610
|
+
cred_id = cred["ServiceSpecificCredentialId"]
|
|
611
|
+
logger.debug(f"Deleted service credential {cred_id} for user {user_name}")
|
|
612
|
+
except ClientError:
|
|
613
|
+
pass # Service-specific credentials may not exist
|
|
614
|
+
|
|
615
|
+
# 6. Detach managed policies
|
|
616
|
+
paginator = iam_client.get_paginator("list_attached_user_policies")
|
|
617
|
+
for page in paginator.paginate(UserName=user_name):
|
|
618
|
+
for policy in page.get("AttachedPolicies", []):
|
|
619
|
+
iam_client.detach_user_policy(
|
|
620
|
+
UserName=user_name,
|
|
621
|
+
PolicyArn=policy["PolicyArn"],
|
|
622
|
+
)
|
|
623
|
+
logger.debug(f"Detached policy {policy['PolicyArn']} from user {user_name}")
|
|
624
|
+
|
|
625
|
+
# 7. Delete inline policies
|
|
626
|
+
paginator = iam_client.get_paginator("list_user_policies")
|
|
627
|
+
for page in paginator.paginate(UserName=user_name):
|
|
628
|
+
for policy_name in page.get("PolicyNames", []):
|
|
629
|
+
iam_client.delete_user_policy(
|
|
630
|
+
UserName=user_name,
|
|
631
|
+
PolicyName=policy_name,
|
|
632
|
+
)
|
|
633
|
+
logger.debug(f"Deleted inline policy {policy_name} from user {user_name}")
|
|
634
|
+
|
|
635
|
+
# 8. Remove user from all groups
|
|
636
|
+
paginator = iam_client.get_paginator("list_groups_for_user")
|
|
637
|
+
for page in paginator.paginate(UserName=user_name):
|
|
638
|
+
for group in page.get("Groups", []):
|
|
639
|
+
iam_client.remove_user_from_group(
|
|
640
|
+
GroupName=group["GroupName"],
|
|
641
|
+
UserName=user_name,
|
|
642
|
+
)
|
|
643
|
+
logger.debug(f"Removed user {user_name} from group {group['GroupName']}")
|
|
644
|
+
|
|
645
|
+
# 9. Delete login profile (console password)
|
|
646
|
+
try:
|
|
647
|
+
iam_client.delete_login_profile(UserName=user_name)
|
|
648
|
+
logger.debug(f"Deleted login profile for user {user_name}")
|
|
649
|
+
except ClientError as e:
|
|
650
|
+
if e.response.get("Error", {}).get("Code") != "NoSuchEntity":
|
|
651
|
+
raise # Re-raise if not "already deleted"
|
|
652
|
+
|
|
653
|
+
logger.info(f"Cleaned up IAM user {user_name} for deletion")
|
|
654
|
+
return (True, None)
|
|
655
|
+
|
|
656
|
+
except ClientError as e:
|
|
657
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
658
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
659
|
+
|
|
660
|
+
if error_code == "NoSuchEntity":
|
|
661
|
+
return (True, None) # User already deleted
|
|
662
|
+
|
|
663
|
+
return (False, f"Failed to cleanup IAM user: {error_code}: {error_message}")
|
|
664
|
+
|
|
665
|
+
def _cleanup_eventbridge_rule(self, rule_name: str, region: str) -> tuple[bool, Optional[str]]:
|
|
666
|
+
"""Remove all targets from an EventBridge rule before deletion.
|
|
667
|
+
|
|
668
|
+
EventBridge rules cannot be deleted if they have targets attached.
|
|
669
|
+
This method removes all targets first.
|
|
670
|
+
|
|
671
|
+
Args:
|
|
672
|
+
rule_name: Name of the EventBridge rule
|
|
673
|
+
region: AWS region
|
|
674
|
+
|
|
675
|
+
Returns:
|
|
676
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
677
|
+
"""
|
|
678
|
+
try:
|
|
679
|
+
events_client = create_boto_client(
|
|
680
|
+
service_name="events",
|
|
681
|
+
region_name=region,
|
|
682
|
+
profile_name=self.aws_profile,
|
|
683
|
+
)
|
|
684
|
+
|
|
685
|
+
# List all targets for this rule
|
|
686
|
+
targets_to_remove = []
|
|
687
|
+
paginator = events_client.get_paginator("list_targets_by_rule")
|
|
688
|
+
|
|
689
|
+
try:
|
|
690
|
+
for page in paginator.paginate(Rule=rule_name):
|
|
691
|
+
for target in page.get("Targets", []):
|
|
692
|
+
targets_to_remove.append(target["Id"])
|
|
693
|
+
except ClientError as e:
|
|
694
|
+
if e.response.get("Error", {}).get("Code") == "ResourceNotFoundException":
|
|
695
|
+
return (True, None) # Rule already deleted
|
|
696
|
+
raise
|
|
697
|
+
|
|
698
|
+
# Remove targets in batches of 10 (API limit)
|
|
699
|
+
if targets_to_remove:
|
|
700
|
+
for i in range(0, len(targets_to_remove), 10):
|
|
701
|
+
batch = targets_to_remove[i : i + 10]
|
|
702
|
+
events_client.remove_targets(
|
|
703
|
+
Rule=rule_name,
|
|
704
|
+
Ids=batch,
|
|
705
|
+
)
|
|
706
|
+
logger.debug(f"Removed {len(batch)} targets from rule {rule_name}")
|
|
707
|
+
|
|
708
|
+
logger.info(f"Cleaned up EventBridge rule {rule_name}: removed {len(targets_to_remove)} targets")
|
|
709
|
+
return (True, None)
|
|
710
|
+
|
|
711
|
+
except ClientError as e:
|
|
712
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
713
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
714
|
+
|
|
715
|
+
if error_code == "ResourceNotFoundException":
|
|
716
|
+
return (True, None) # Rule already deleted
|
|
717
|
+
|
|
718
|
+
return (False, f"Failed to cleanup EventBridge rule: {error_code}: {error_message}")
|
|
719
|
+
|
|
720
|
+
def _cleanup_route53_hosted_zone(self, zone_id: str) -> tuple[bool, Optional[str]]:
|
|
721
|
+
"""Delete all records from a Route53 hosted zone before deletion.
|
|
722
|
+
|
|
723
|
+
Route53 hosted zones cannot be deleted if they contain records other than
|
|
724
|
+
the default NS and SOA records. This method deletes all other records first.
|
|
725
|
+
|
|
726
|
+
Args:
|
|
727
|
+
zone_id: Hosted zone ID (with or without /hostedzone/ prefix)
|
|
728
|
+
|
|
729
|
+
Returns:
|
|
730
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
731
|
+
"""
|
|
732
|
+
try:
|
|
733
|
+
route53_client = create_boto_client(
|
|
734
|
+
service_name="route53",
|
|
735
|
+
region_name="us-east-1", # Route53 is global
|
|
736
|
+
profile_name=self.aws_profile,
|
|
737
|
+
)
|
|
738
|
+
|
|
739
|
+
# Normalize zone ID (remove /hostedzone/ prefix if present)
|
|
740
|
+
if zone_id.startswith("/hostedzone/"):
|
|
741
|
+
zone_id = zone_id.replace("/hostedzone/", "")
|
|
742
|
+
|
|
743
|
+
# List all records in the zone
|
|
744
|
+
records_to_delete = []
|
|
745
|
+
paginator = route53_client.get_paginator("list_resource_record_sets")
|
|
746
|
+
|
|
747
|
+
try:
|
|
748
|
+
for page in paginator.paginate(HostedZoneId=zone_id):
|
|
749
|
+
for record in page.get("ResourceRecordSets", []):
|
|
750
|
+
# Skip NS and SOA records at zone apex (cannot be deleted)
|
|
751
|
+
if record["Type"] in ["NS", "SOA"]:
|
|
752
|
+
continue
|
|
753
|
+
records_to_delete.append(record)
|
|
754
|
+
except ClientError as e:
|
|
755
|
+
if e.response.get("Error", {}).get("Code") == "NoSuchHostedZone":
|
|
756
|
+
return (True, None) # Zone already deleted
|
|
757
|
+
raise
|
|
758
|
+
|
|
759
|
+
# Delete records in batches
|
|
760
|
+
if records_to_delete:
|
|
761
|
+
# Route53 allows up to 1000 changes per request
|
|
762
|
+
for i in range(0, len(records_to_delete), 100):
|
|
763
|
+
batch = records_to_delete[i : i + 100]
|
|
764
|
+
changes = [{"Action": "DELETE", "ResourceRecordSet": record} for record in batch]
|
|
765
|
+
route53_client.change_resource_record_sets(
|
|
766
|
+
HostedZoneId=zone_id,
|
|
767
|
+
ChangeBatch={"Changes": changes},
|
|
768
|
+
)
|
|
769
|
+
logger.debug(f"Deleted {len(batch)} records from zone {zone_id}")
|
|
770
|
+
|
|
771
|
+
logger.info(f"Cleaned up Route53 zone {zone_id}: deleted {len(records_to_delete)} records")
|
|
772
|
+
return (True, None)
|
|
773
|
+
|
|
774
|
+
except ClientError as e:
|
|
775
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
776
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
777
|
+
|
|
778
|
+
if error_code == "NoSuchHostedZone":
|
|
779
|
+
return (True, None) # Zone already deleted
|
|
780
|
+
|
|
781
|
+
return (False, f"Failed to cleanup Route53 zone: {error_code}: {error_message}")
|
|
782
|
+
|
|
783
|
+
def _cleanup_backup_vault(self, vault_name: str) -> tuple[bool, Optional[str]]:
|
|
784
|
+
"""Delete all recovery points from a Backup vault before deletion.
|
|
785
|
+
|
|
786
|
+
Backup vaults cannot be deleted if they contain recovery points.
|
|
787
|
+
This method deletes all recovery points first.
|
|
788
|
+
|
|
789
|
+
Args:
|
|
790
|
+
vault_name: Name of the backup vault
|
|
791
|
+
|
|
792
|
+
Returns:
|
|
793
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
794
|
+
"""
|
|
795
|
+
try:
|
|
796
|
+
backup_client = create_boto_client(
|
|
797
|
+
service_name="backup",
|
|
798
|
+
region_name="us-east-1", # Will be overridden by the vault's region
|
|
799
|
+
profile_name=self.aws_profile,
|
|
800
|
+
)
|
|
801
|
+
|
|
802
|
+
# List all recovery points in the vault
|
|
803
|
+
recovery_points = []
|
|
804
|
+
paginator = backup_client.get_paginator("list_recovery_points_by_backup_vault")
|
|
805
|
+
|
|
806
|
+
try:
|
|
807
|
+
for page in paginator.paginate(BackupVaultName=vault_name):
|
|
808
|
+
for rp in page.get("RecoveryPoints", []):
|
|
809
|
+
recovery_points.append(rp["RecoveryPointArn"])
|
|
810
|
+
except ClientError as e:
|
|
811
|
+
error_code = e.response.get("Error", {}).get("Code", "")
|
|
812
|
+
if error_code in ["ResourceNotFoundException", "AccessDeniedException"]:
|
|
813
|
+
return (True, None) # Vault already deleted or no access
|
|
814
|
+
raise
|
|
815
|
+
|
|
816
|
+
# Delete each recovery point
|
|
817
|
+
for rp_arn in recovery_points:
|
|
818
|
+
try:
|
|
819
|
+
backup_client.delete_recovery_point(
|
|
820
|
+
BackupVaultName=vault_name,
|
|
821
|
+
RecoveryPointArn=rp_arn,
|
|
822
|
+
)
|
|
823
|
+
logger.debug(f"Deleted recovery point {rp_arn} from vault {vault_name}")
|
|
824
|
+
except ClientError as e:
|
|
825
|
+
# Continue on individual failures
|
|
826
|
+
logger.warning(f"Failed to delete recovery point {rp_arn}: {e}")
|
|
827
|
+
|
|
828
|
+
logger.info(f"Cleaned up Backup vault {vault_name}: deleted {len(recovery_points)} recovery points")
|
|
829
|
+
return (True, None)
|
|
830
|
+
|
|
831
|
+
except ClientError as e:
|
|
832
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
833
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
834
|
+
|
|
835
|
+
if error_code == "ResourceNotFoundException":
|
|
836
|
+
return (True, None) # Vault already deleted
|
|
837
|
+
|
|
838
|
+
return (False, f"Failed to cleanup Backup vault: {error_code}: {error_message}")
|
|
839
|
+
|
|
840
|
+
def _cleanup_waf_webacl(self, webacl_id: str, arn: str, region: str) -> tuple[bool, Optional[str]]:
|
|
841
|
+
"""Disassociate all resources from a WAF WebACL and delete it.
|
|
842
|
+
|
|
843
|
+
WAF WebACLs cannot be deleted if they are associated with resources.
|
|
844
|
+
This method disassociates all resources and then deletes the WebACL.
|
|
845
|
+
The deletion is done here because it requires a LockToken.
|
|
846
|
+
|
|
847
|
+
Args:
|
|
848
|
+
webacl_id: WebACL ID
|
|
849
|
+
arn: WebACL ARN
|
|
850
|
+
region: AWS region
|
|
851
|
+
|
|
852
|
+
Returns:
|
|
853
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
854
|
+
"""
|
|
855
|
+
try:
|
|
856
|
+
# Determine scope from ARN
|
|
857
|
+
scope = "CLOUDFRONT" if "global" in arn or "cloudfront" in arn.lower() else "REGIONAL"
|
|
858
|
+
waf_region = "us-east-1" if scope == "CLOUDFRONT" else region
|
|
859
|
+
|
|
860
|
+
wafv2_client = create_boto_client(
|
|
861
|
+
service_name="wafv2",
|
|
862
|
+
region_name=waf_region,
|
|
863
|
+
profile_name=self.aws_profile,
|
|
864
|
+
)
|
|
865
|
+
|
|
866
|
+
# Extract name from ARN (format: ...webacl/name/id)
|
|
867
|
+
arn_parts = arn.split("/")
|
|
868
|
+
webacl_name = arn_parts[-2] if len(arn_parts) >= 2 else webacl_id
|
|
869
|
+
|
|
870
|
+
# Get WebACL to retrieve LockToken
|
|
871
|
+
try:
|
|
872
|
+
response = wafv2_client.get_web_acl(Name=webacl_name, Scope=scope, Id=webacl_id)
|
|
873
|
+
lock_token = response["LockToken"]
|
|
874
|
+
except ClientError as e:
|
|
875
|
+
if e.response.get("Error", {}).get("Code") == "WAFNonexistentItemException":
|
|
876
|
+
return (True, None) # WebACL already deleted
|
|
877
|
+
raise
|
|
878
|
+
|
|
879
|
+
# Disassociate from all resources (only for REGIONAL scope)
|
|
880
|
+
if scope == "REGIONAL":
|
|
881
|
+
try:
|
|
882
|
+
resources = wafv2_client.list_resources_for_web_acl(
|
|
883
|
+
WebACLArn=arn, ResourceType="APPLICATION_LOAD_BALANCER"
|
|
884
|
+
)
|
|
885
|
+
for resource_arn in resources.get("ResourceArns", []):
|
|
886
|
+
wafv2_client.disassociate_web_acl(ResourceArn=resource_arn)
|
|
887
|
+
logger.debug(f"Disassociated {resource_arn} from WebACL {webacl_name}")
|
|
888
|
+
except ClientError:
|
|
889
|
+
pass # May not have associations
|
|
890
|
+
|
|
891
|
+
# Also check API Gateway
|
|
892
|
+
try:
|
|
893
|
+
resources = wafv2_client.list_resources_for_web_acl(WebACLArn=arn, ResourceType="API_GATEWAY")
|
|
894
|
+
for resource_arn in resources.get("ResourceArns", []):
|
|
895
|
+
wafv2_client.disassociate_web_acl(ResourceArn=resource_arn)
|
|
896
|
+
logger.debug(f"Disassociated {resource_arn} from WebACL {webacl_name}")
|
|
897
|
+
except ClientError:
|
|
898
|
+
pass
|
|
899
|
+
|
|
900
|
+
# Delete the WebACL
|
|
901
|
+
wafv2_client.delete_web_acl(
|
|
902
|
+
Name=webacl_name,
|
|
903
|
+
Scope=scope,
|
|
904
|
+
Id=webacl_id,
|
|
905
|
+
LockToken=lock_token,
|
|
906
|
+
)
|
|
907
|
+
|
|
908
|
+
logger.info(f"Deleted WAF WebACL {webacl_name}")
|
|
909
|
+
return (True, None)
|
|
910
|
+
|
|
911
|
+
except ClientError as e:
|
|
912
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
913
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
914
|
+
|
|
915
|
+
if error_code == "WAFNonexistentItemException":
|
|
916
|
+
return (True, None) # WebACL already deleted
|
|
917
|
+
|
|
918
|
+
return (False, f"Failed to cleanup WAF WebACL: {error_code}: {error_message}")
|
|
919
|
+
|
|
920
|
+
def _cleanup_waf_rulegroup(self, rulegroup_id: str, arn: str, region: str) -> tuple[bool, Optional[str]]:
|
|
921
|
+
"""Delete a WAF RuleGroup (requires LockToken).
|
|
922
|
+
|
|
923
|
+
WAF RuleGroup deletion requires a LockToken obtained from get_rule_group.
|
|
924
|
+
This method handles the full deletion.
|
|
925
|
+
|
|
926
|
+
Args:
|
|
927
|
+
rulegroup_id: RuleGroup ID
|
|
928
|
+
arn: RuleGroup ARN
|
|
929
|
+
region: AWS region
|
|
930
|
+
|
|
931
|
+
Returns:
|
|
932
|
+
Tuple of (success: bool, error_message: Optional[str])
|
|
933
|
+
"""
|
|
934
|
+
try:
|
|
935
|
+
# Determine scope from ARN
|
|
936
|
+
scope = "CLOUDFRONT" if "global" in arn or "cloudfront" in arn.lower() else "REGIONAL"
|
|
937
|
+
waf_region = "us-east-1" if scope == "CLOUDFRONT" else region
|
|
938
|
+
|
|
939
|
+
wafv2_client = create_boto_client(
|
|
940
|
+
service_name="wafv2",
|
|
941
|
+
region_name=waf_region,
|
|
942
|
+
profile_name=self.aws_profile,
|
|
943
|
+
)
|
|
944
|
+
|
|
945
|
+
# Extract name from ARN (format: ...rulegroup/name/id)
|
|
946
|
+
arn_parts = arn.split("/")
|
|
947
|
+
rulegroup_name = arn_parts[-2] if len(arn_parts) >= 2 else rulegroup_id
|
|
948
|
+
|
|
949
|
+
# Get RuleGroup to retrieve LockToken
|
|
950
|
+
try:
|
|
951
|
+
response = wafv2_client.get_rule_group(Name=rulegroup_name, Scope=scope, Id=rulegroup_id)
|
|
952
|
+
lock_token = response["LockToken"]
|
|
953
|
+
except ClientError as e:
|
|
954
|
+
if e.response.get("Error", {}).get("Code") == "WAFNonexistentItemException":
|
|
955
|
+
return (True, None) # RuleGroup already deleted
|
|
956
|
+
raise
|
|
957
|
+
|
|
958
|
+
# Delete the RuleGroup
|
|
959
|
+
wafv2_client.delete_rule_group(
|
|
960
|
+
Name=rulegroup_name,
|
|
961
|
+
Scope=scope,
|
|
962
|
+
Id=rulegroup_id,
|
|
963
|
+
LockToken=lock_token,
|
|
964
|
+
)
|
|
965
|
+
|
|
966
|
+
logger.info(f"Deleted WAF RuleGroup {rulegroup_name}")
|
|
967
|
+
return (True, None)
|
|
968
|
+
|
|
969
|
+
except ClientError as e:
|
|
970
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
971
|
+
error_message = e.response.get("Error", {}).get("Message", str(e))
|
|
972
|
+
|
|
973
|
+
if error_code == "WAFNonexistentItemException":
|
|
974
|
+
return (True, None) # RuleGroup already deleted
|
|
975
|
+
|
|
976
|
+
return (False, f"Failed to cleanup WAF RuleGroup: {error_code}: {error_message}")
|