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.
Files changed (152) hide show
  1. aws_inventory_manager-0.17.12.dist-info/LICENSE +21 -0
  2. aws_inventory_manager-0.17.12.dist-info/METADATA +1292 -0
  3. aws_inventory_manager-0.17.12.dist-info/RECORD +152 -0
  4. aws_inventory_manager-0.17.12.dist-info/WHEEL +5 -0
  5. aws_inventory_manager-0.17.12.dist-info/entry_points.txt +2 -0
  6. aws_inventory_manager-0.17.12.dist-info/top_level.txt +1 -0
  7. src/__init__.py +3 -0
  8. src/aws/__init__.py +11 -0
  9. src/aws/client.py +128 -0
  10. src/aws/credentials.py +191 -0
  11. src/aws/rate_limiter.py +177 -0
  12. src/cli/__init__.py +12 -0
  13. src/cli/config.py +130 -0
  14. src/cli/main.py +4046 -0
  15. src/cloudtrail/__init__.py +5 -0
  16. src/cloudtrail/query.py +642 -0
  17. src/config_service/__init__.py +21 -0
  18. src/config_service/collector.py +346 -0
  19. src/config_service/detector.py +256 -0
  20. src/config_service/resource_type_mapping.py +328 -0
  21. src/cost/__init__.py +5 -0
  22. src/cost/analyzer.py +226 -0
  23. src/cost/explorer.py +209 -0
  24. src/cost/reporter.py +237 -0
  25. src/delta/__init__.py +5 -0
  26. src/delta/calculator.py +206 -0
  27. src/delta/differ.py +185 -0
  28. src/delta/formatters.py +272 -0
  29. src/delta/models.py +154 -0
  30. src/delta/reporter.py +234 -0
  31. src/matching/__init__.py +6 -0
  32. src/matching/config.py +52 -0
  33. src/matching/normalizer.py +450 -0
  34. src/matching/prompts.py +33 -0
  35. src/models/__init__.py +21 -0
  36. src/models/config_diff.py +135 -0
  37. src/models/cost_report.py +87 -0
  38. src/models/deletion_operation.py +104 -0
  39. src/models/deletion_record.py +97 -0
  40. src/models/delta_report.py +122 -0
  41. src/models/efs_resource.py +80 -0
  42. src/models/elasticache_resource.py +90 -0
  43. src/models/group.py +318 -0
  44. src/models/inventory.py +133 -0
  45. src/models/protection_rule.py +123 -0
  46. src/models/report.py +288 -0
  47. src/models/resource.py +111 -0
  48. src/models/security_finding.py +102 -0
  49. src/models/snapshot.py +122 -0
  50. src/restore/__init__.py +20 -0
  51. src/restore/audit.py +175 -0
  52. src/restore/cleaner.py +461 -0
  53. src/restore/config.py +209 -0
  54. src/restore/deleter.py +976 -0
  55. src/restore/dependency.py +254 -0
  56. src/restore/safety.py +115 -0
  57. src/security/__init__.py +0 -0
  58. src/security/checks/__init__.py +0 -0
  59. src/security/checks/base.py +56 -0
  60. src/security/checks/ec2_checks.py +88 -0
  61. src/security/checks/elasticache_checks.py +149 -0
  62. src/security/checks/iam_checks.py +102 -0
  63. src/security/checks/rds_checks.py +140 -0
  64. src/security/checks/s3_checks.py +95 -0
  65. src/security/checks/secrets_checks.py +96 -0
  66. src/security/checks/sg_checks.py +142 -0
  67. src/security/cis_mapper.py +97 -0
  68. src/security/models.py +53 -0
  69. src/security/reporter.py +174 -0
  70. src/security/scanner.py +87 -0
  71. src/snapshot/__init__.py +6 -0
  72. src/snapshot/capturer.py +453 -0
  73. src/snapshot/filter.py +259 -0
  74. src/snapshot/inventory_storage.py +236 -0
  75. src/snapshot/report_formatter.py +250 -0
  76. src/snapshot/reporter.py +189 -0
  77. src/snapshot/resource_collectors/__init__.py +5 -0
  78. src/snapshot/resource_collectors/apigateway.py +140 -0
  79. src/snapshot/resource_collectors/backup.py +136 -0
  80. src/snapshot/resource_collectors/base.py +81 -0
  81. src/snapshot/resource_collectors/cloudformation.py +55 -0
  82. src/snapshot/resource_collectors/cloudwatch.py +109 -0
  83. src/snapshot/resource_collectors/codebuild.py +69 -0
  84. src/snapshot/resource_collectors/codepipeline.py +82 -0
  85. src/snapshot/resource_collectors/dynamodb.py +65 -0
  86. src/snapshot/resource_collectors/ec2.py +240 -0
  87. src/snapshot/resource_collectors/ecs.py +215 -0
  88. src/snapshot/resource_collectors/efs_collector.py +102 -0
  89. src/snapshot/resource_collectors/eks.py +200 -0
  90. src/snapshot/resource_collectors/elasticache_collector.py +79 -0
  91. src/snapshot/resource_collectors/elb.py +126 -0
  92. src/snapshot/resource_collectors/eventbridge.py +156 -0
  93. src/snapshot/resource_collectors/glue.py +199 -0
  94. src/snapshot/resource_collectors/iam.py +188 -0
  95. src/snapshot/resource_collectors/kms.py +111 -0
  96. src/snapshot/resource_collectors/lambda_func.py +139 -0
  97. src/snapshot/resource_collectors/rds.py +109 -0
  98. src/snapshot/resource_collectors/route53.py +86 -0
  99. src/snapshot/resource_collectors/s3.py +105 -0
  100. src/snapshot/resource_collectors/secretsmanager.py +70 -0
  101. src/snapshot/resource_collectors/sns.py +68 -0
  102. src/snapshot/resource_collectors/sqs.py +82 -0
  103. src/snapshot/resource_collectors/ssm.py +160 -0
  104. src/snapshot/resource_collectors/stepfunctions.py +74 -0
  105. src/snapshot/resource_collectors/vpcendpoints.py +79 -0
  106. src/snapshot/resource_collectors/waf.py +159 -0
  107. src/snapshot/storage.py +351 -0
  108. src/storage/__init__.py +21 -0
  109. src/storage/audit_store.py +419 -0
  110. src/storage/database.py +294 -0
  111. src/storage/group_store.py +763 -0
  112. src/storage/inventory_store.py +320 -0
  113. src/storage/resource_store.py +416 -0
  114. src/storage/schema.py +339 -0
  115. src/storage/snapshot_store.py +363 -0
  116. src/utils/__init__.py +12 -0
  117. src/utils/export.py +305 -0
  118. src/utils/hash.py +60 -0
  119. src/utils/logging.py +63 -0
  120. src/utils/pagination.py +41 -0
  121. src/utils/paths.py +51 -0
  122. src/utils/progress.py +41 -0
  123. src/utils/unsupported_resources.py +306 -0
  124. src/web/__init__.py +5 -0
  125. src/web/app.py +97 -0
  126. src/web/dependencies.py +69 -0
  127. src/web/routes/__init__.py +1 -0
  128. src/web/routes/api/__init__.py +18 -0
  129. src/web/routes/api/charts.py +156 -0
  130. src/web/routes/api/cleanup.py +186 -0
  131. src/web/routes/api/filters.py +253 -0
  132. src/web/routes/api/groups.py +305 -0
  133. src/web/routes/api/inventories.py +80 -0
  134. src/web/routes/api/queries.py +202 -0
  135. src/web/routes/api/resources.py +393 -0
  136. src/web/routes/api/snapshots.py +314 -0
  137. src/web/routes/api/views.py +260 -0
  138. src/web/routes/pages.py +198 -0
  139. src/web/services/__init__.py +1 -0
  140. src/web/templates/base.html +955 -0
  141. src/web/templates/components/navbar.html +31 -0
  142. src/web/templates/components/sidebar.html +104 -0
  143. src/web/templates/pages/audit_logs.html +86 -0
  144. src/web/templates/pages/cleanup.html +279 -0
  145. src/web/templates/pages/dashboard.html +227 -0
  146. src/web/templates/pages/diff.html +175 -0
  147. src/web/templates/pages/error.html +30 -0
  148. src/web/templates/pages/groups.html +721 -0
  149. src/web/templates/pages/queries.html +246 -0
  150. src/web/templates/pages/resources.html +2429 -0
  151. src/web/templates/pages/snapshot_detail.html +271 -0
  152. 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}")