aws-inventory-manager 0.13.2__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.
Potentially problematic release.
This version of aws-inventory-manager might be problematic. Click here for more details.
- aws_inventory_manager-0.13.2.dist-info/LICENSE +21 -0
- aws_inventory_manager-0.13.2.dist-info/METADATA +1226 -0
- aws_inventory_manager-0.13.2.dist-info/RECORD +145 -0
- aws_inventory_manager-0.13.2.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.13.2.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.13.2.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 +3626 -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/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 +451 -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/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 +749 -0
- src/storage/inventory_store.py +320 -0
- src/storage/resource_store.py +413 -0
- src/storage/schema.py +288 -0
- src/storage/snapshot_store.py +346 -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 +379 -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 +949 -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 +2251 -0
- src/web/templates/pages/snapshot_detail.html +271 -0
- src/web/templates/pages/snapshots.html +429 -0
src/restore/cleaner.py
ADDED
|
@@ -0,0 +1,461 @@
|
|
|
1
|
+
"""Resource cleaner for restoration operations.
|
|
2
|
+
|
|
3
|
+
Main orchestrator for resource cleanup/restoration with preview and execution modes.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import uuid
|
|
10
|
+
from datetime import datetime
|
|
11
|
+
from typing import Any, Optional
|
|
12
|
+
|
|
13
|
+
from src.delta.calculator import DeltaCalculator
|
|
14
|
+
from src.models.deletion_operation import DeletionOperation, OperationMode, OperationStatus
|
|
15
|
+
from src.models.deletion_record import DeletionRecord, DeletionStatus
|
|
16
|
+
from src.restore.audit import AuditStorage
|
|
17
|
+
from src.restore.dependency import DependencyResolver
|
|
18
|
+
from src.restore.safety import SafetyChecker
|
|
19
|
+
from src.snapshot.storage import SnapshotStorage
|
|
20
|
+
|
|
21
|
+
logger = logging.getLogger(__name__)
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
class ResourceCleaner:
|
|
25
|
+
"""Resource cleaner orchestrator.
|
|
26
|
+
|
|
27
|
+
Coordinates resource cleanup/restoration operations with safety checks,
|
|
28
|
+
dependency resolution, and audit logging. Supports both preview (dry-run)
|
|
29
|
+
and execution modes.
|
|
30
|
+
|
|
31
|
+
Attributes:
|
|
32
|
+
snapshot_storage: Snapshot storage for baseline comparison
|
|
33
|
+
safety_checker: Safety checker for protection rules
|
|
34
|
+
audit_storage: Audit storage for deletion logs
|
|
35
|
+
dependency_resolver: Dependency resolver for deletion ordering
|
|
36
|
+
"""
|
|
37
|
+
|
|
38
|
+
def __init__(
|
|
39
|
+
self,
|
|
40
|
+
snapshot_storage: SnapshotStorage,
|
|
41
|
+
safety_checker: SafetyChecker,
|
|
42
|
+
audit_storage: AuditStorage,
|
|
43
|
+
) -> None:
|
|
44
|
+
"""Initialize resource cleaner.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
snapshot_storage: Snapshot storage instance
|
|
48
|
+
safety_checker: Safety checker with protection rules
|
|
49
|
+
audit_storage: Audit storage for logging
|
|
50
|
+
"""
|
|
51
|
+
self.snapshot_storage = snapshot_storage
|
|
52
|
+
self.safety_checker = safety_checker
|
|
53
|
+
self.audit_storage = audit_storage
|
|
54
|
+
self.dependency_resolver = DependencyResolver()
|
|
55
|
+
|
|
56
|
+
def preview(
|
|
57
|
+
self,
|
|
58
|
+
baseline_snapshot: str,
|
|
59
|
+
account_id: str,
|
|
60
|
+
aws_profile: Optional[str] = None,
|
|
61
|
+
resource_types: Optional[list[str]] = None,
|
|
62
|
+
regions: Optional[list[str]] = None,
|
|
63
|
+
) -> DeletionOperation:
|
|
64
|
+
"""Preview resources that would be deleted (dry-run mode).
|
|
65
|
+
|
|
66
|
+
Identifies resources created after baseline snapshot and applies protection
|
|
67
|
+
rules without performing any deletions.
|
|
68
|
+
|
|
69
|
+
Args:
|
|
70
|
+
baseline_snapshot: Name of baseline snapshot to compare against
|
|
71
|
+
account_id: AWS account ID to validate
|
|
72
|
+
aws_profile: AWS profile name (optional)
|
|
73
|
+
resource_types: Filter by resource types (optional)
|
|
74
|
+
regions: Filter by regions (optional)
|
|
75
|
+
|
|
76
|
+
Returns:
|
|
77
|
+
DeletionOperation in planned status with resource counts
|
|
78
|
+
|
|
79
|
+
Raises:
|
|
80
|
+
ValueError: If snapshot not found or account ID mismatch
|
|
81
|
+
"""
|
|
82
|
+
# Validate snapshot exists and load it
|
|
83
|
+
try:
|
|
84
|
+
snapshot = self.snapshot_storage.load_snapshot(baseline_snapshot)
|
|
85
|
+
except (FileNotFoundError, ValueError):
|
|
86
|
+
raise ValueError(f"Snapshot '{baseline_snapshot}' not found")
|
|
87
|
+
|
|
88
|
+
# Validate account ID matches
|
|
89
|
+
snapshot_account = snapshot.account_id
|
|
90
|
+
if snapshot_account != account_id:
|
|
91
|
+
raise ValueError(
|
|
92
|
+
f"Account ID mismatch: snapshot has {snapshot_account}, " f"current credentials have {account_id}"
|
|
93
|
+
)
|
|
94
|
+
|
|
95
|
+
# Collect current resources
|
|
96
|
+
current_resources = self._collect_current_resources(account_id, regions)
|
|
97
|
+
|
|
98
|
+
# Create a temporary snapshot for current state
|
|
99
|
+
from src.models.snapshot import Snapshot
|
|
100
|
+
|
|
101
|
+
current_snapshot = Snapshot(
|
|
102
|
+
name="current",
|
|
103
|
+
created_at=datetime.utcnow(),
|
|
104
|
+
account_id=account_id,
|
|
105
|
+
regions=regions or snapshot.regions,
|
|
106
|
+
resources=[self._dict_to_resource(r) for r in current_resources],
|
|
107
|
+
resource_count=len(current_resources),
|
|
108
|
+
)
|
|
109
|
+
|
|
110
|
+
# Calculate delta (new resources since baseline)
|
|
111
|
+
delta_calc = DeltaCalculator(reference_snapshot=snapshot, current_snapshot=current_snapshot)
|
|
112
|
+
delta_result = delta_calc.calculate()
|
|
113
|
+
|
|
114
|
+
# Get added resources (convert back to dict format for filtering)
|
|
115
|
+
new_resources = [self._resource_to_dict(r) for r in delta_result.added_resources]
|
|
116
|
+
|
|
117
|
+
# Apply filters
|
|
118
|
+
filtered_resources = self._apply_filters(
|
|
119
|
+
new_resources,
|
|
120
|
+
resource_types=resource_types,
|
|
121
|
+
regions=regions,
|
|
122
|
+
)
|
|
123
|
+
|
|
124
|
+
# Apply protection rules and count protected resources
|
|
125
|
+
protected_count = 0
|
|
126
|
+
|
|
127
|
+
for resource in filtered_resources:
|
|
128
|
+
is_protected, reason = self.safety_checker.is_protected(resource)
|
|
129
|
+
|
|
130
|
+
if is_protected:
|
|
131
|
+
protected_count += 1
|
|
132
|
+
|
|
133
|
+
# Create operation
|
|
134
|
+
operation = DeletionOperation(
|
|
135
|
+
operation_id=f"op_{uuid.uuid4()}",
|
|
136
|
+
baseline_snapshot=baseline_snapshot,
|
|
137
|
+
timestamp=datetime.utcnow(),
|
|
138
|
+
account_id=account_id,
|
|
139
|
+
mode=OperationMode.DRY_RUN,
|
|
140
|
+
status=OperationStatus.PLANNED,
|
|
141
|
+
total_resources=len(filtered_resources),
|
|
142
|
+
succeeded_count=0,
|
|
143
|
+
failed_count=0,
|
|
144
|
+
skipped_count=protected_count,
|
|
145
|
+
aws_profile=aws_profile,
|
|
146
|
+
filters=self._build_filters_dict(resource_types, regions),
|
|
147
|
+
)
|
|
148
|
+
|
|
149
|
+
return operation
|
|
150
|
+
|
|
151
|
+
def execute(
|
|
152
|
+
self,
|
|
153
|
+
baseline_snapshot: str,
|
|
154
|
+
account_id: str,
|
|
155
|
+
confirmed: bool = False,
|
|
156
|
+
aws_profile: Optional[str] = None,
|
|
157
|
+
resource_types: Optional[list[str]] = None,
|
|
158
|
+
regions: Optional[list[str]] = None,
|
|
159
|
+
) -> DeletionOperation:
|
|
160
|
+
"""Execute resource deletion to restore to baseline (execution mode).
|
|
161
|
+
|
|
162
|
+
Performs actual deletion of resources created after baseline snapshot with
|
|
163
|
+
protection checks and audit logging.
|
|
164
|
+
|
|
165
|
+
Args:
|
|
166
|
+
baseline_snapshot: Name of baseline snapshot to restore to
|
|
167
|
+
account_id: AWS account ID to validate
|
|
168
|
+
confirmed: Must be True to proceed with deletion
|
|
169
|
+
aws_profile: AWS profile name (optional)
|
|
170
|
+
resource_types: Filter by resource types (optional)
|
|
171
|
+
regions: Filter by regions (optional)
|
|
172
|
+
|
|
173
|
+
Returns:
|
|
174
|
+
DeletionOperation with execution results
|
|
175
|
+
|
|
176
|
+
Raises:
|
|
177
|
+
ValueError: If not confirmed or snapshot not found or account ID mismatch
|
|
178
|
+
"""
|
|
179
|
+
# Require confirmation for destructive operations
|
|
180
|
+
if not confirmed:
|
|
181
|
+
raise ValueError("Deletion requires explicit confirmation. Set confirmed=True or use --confirm flag.")
|
|
182
|
+
|
|
183
|
+
# Validate snapshot exists and load it
|
|
184
|
+
try:
|
|
185
|
+
snapshot = self.snapshot_storage.load_snapshot(baseline_snapshot)
|
|
186
|
+
except (FileNotFoundError, ValueError):
|
|
187
|
+
raise ValueError(f"Snapshot '{baseline_snapshot}' not found")
|
|
188
|
+
|
|
189
|
+
# Validate account ID matches
|
|
190
|
+
snapshot_account = snapshot.account_id
|
|
191
|
+
if snapshot_account != account_id:
|
|
192
|
+
raise ValueError(
|
|
193
|
+
f"Account ID mismatch: snapshot has {snapshot_account}, " f"current credentials have {account_id}"
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
# Collect current resources
|
|
197
|
+
current_resources = self._collect_current_resources(account_id, regions)
|
|
198
|
+
|
|
199
|
+
# Create a temporary snapshot for current state
|
|
200
|
+
from src.models.snapshot import Snapshot
|
|
201
|
+
|
|
202
|
+
current_snapshot = Snapshot(
|
|
203
|
+
name="current",
|
|
204
|
+
created_at=datetime.utcnow(),
|
|
205
|
+
account_id=account_id,
|
|
206
|
+
regions=regions or snapshot.regions,
|
|
207
|
+
resources=[self._dict_to_resource(r) for r in current_resources],
|
|
208
|
+
resource_count=len(current_resources),
|
|
209
|
+
)
|
|
210
|
+
|
|
211
|
+
# Calculate delta (new resources since baseline)
|
|
212
|
+
delta_calc = DeltaCalculator(reference_snapshot=snapshot, current_snapshot=current_snapshot)
|
|
213
|
+
delta_result = delta_calc.calculate()
|
|
214
|
+
|
|
215
|
+
# Get added resources (convert back to dict format for filtering)
|
|
216
|
+
new_resources = [self._resource_to_dict(r) for r in delta_result.added_resources]
|
|
217
|
+
|
|
218
|
+
# Apply filters
|
|
219
|
+
filtered_resources = self._apply_filters(
|
|
220
|
+
new_resources,
|
|
221
|
+
resource_types=resource_types,
|
|
222
|
+
regions=regions,
|
|
223
|
+
)
|
|
224
|
+
|
|
225
|
+
# Import at module level to avoid UnboundLocalError
|
|
226
|
+
|
|
227
|
+
# Execute deletions with protection checks
|
|
228
|
+
succeeded_count = 0
|
|
229
|
+
failed_count = 0
|
|
230
|
+
skipped_count = 0
|
|
231
|
+
deletion_records = []
|
|
232
|
+
operation_id = f"op_{uuid.uuid4()}"
|
|
233
|
+
|
|
234
|
+
for resource in filtered_resources:
|
|
235
|
+
record_id = f"rec_{uuid.uuid4()}"
|
|
236
|
+
|
|
237
|
+
# Check protection rules
|
|
238
|
+
is_protected, reason = self.safety_checker.is_protected(resource)
|
|
239
|
+
|
|
240
|
+
if is_protected:
|
|
241
|
+
skipped_count += 1
|
|
242
|
+
# Create deletion record for skipped resource
|
|
243
|
+
record = DeletionRecord(
|
|
244
|
+
record_id=record_id,
|
|
245
|
+
operation_id=operation_id,
|
|
246
|
+
resource_id=resource.get("resource_id", ""),
|
|
247
|
+
resource_arn=resource.get("arn", ""),
|
|
248
|
+
resource_type=resource.get("resource_type", ""),
|
|
249
|
+
region=resource.get("region", ""),
|
|
250
|
+
status=DeletionStatus.SKIPPED,
|
|
251
|
+
protection_reason=reason or "Protected by safety rules",
|
|
252
|
+
timestamp=datetime.utcnow(),
|
|
253
|
+
)
|
|
254
|
+
deletion_records.append(record)
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
# Attempt deletion
|
|
258
|
+
success = self._delete_resource(resource, aws_profile)
|
|
259
|
+
|
|
260
|
+
if success:
|
|
261
|
+
succeeded_count += 1
|
|
262
|
+
# Create successful deletion record
|
|
263
|
+
record = DeletionRecord(
|
|
264
|
+
record_id=record_id,
|
|
265
|
+
operation_id=operation_id,
|
|
266
|
+
resource_id=resource.get("resource_id", ""),
|
|
267
|
+
resource_arn=resource.get("arn", ""),
|
|
268
|
+
resource_type=resource.get("resource_type", ""),
|
|
269
|
+
region=resource.get("region", ""),
|
|
270
|
+
status=DeletionStatus.SUCCEEDED,
|
|
271
|
+
timestamp=datetime.utcnow(),
|
|
272
|
+
)
|
|
273
|
+
else:
|
|
274
|
+
failed_count += 1
|
|
275
|
+
# Create failed deletion record
|
|
276
|
+
record = DeletionRecord(
|
|
277
|
+
record_id=record_id,
|
|
278
|
+
operation_id=operation_id,
|
|
279
|
+
resource_id=resource.get("resource_id", ""),
|
|
280
|
+
resource_arn=resource.get("arn", ""),
|
|
281
|
+
resource_type=resource.get("resource_type", ""),
|
|
282
|
+
region=resource.get("region", ""),
|
|
283
|
+
status=DeletionStatus.FAILED,
|
|
284
|
+
error_code="DeletionFailed",
|
|
285
|
+
error_message="Resource deletion failed",
|
|
286
|
+
timestamp=datetime.utcnow(),
|
|
287
|
+
)
|
|
288
|
+
deletion_records.append(record)
|
|
289
|
+
|
|
290
|
+
# Determine final status
|
|
291
|
+
if failed_count > 0:
|
|
292
|
+
if succeeded_count > 0:
|
|
293
|
+
final_status = OperationStatus.PARTIAL
|
|
294
|
+
else:
|
|
295
|
+
final_status = OperationStatus.FAILED
|
|
296
|
+
else:
|
|
297
|
+
final_status = OperationStatus.COMPLETED
|
|
298
|
+
|
|
299
|
+
# Create operation (use same operation_id from records)
|
|
300
|
+
operation = DeletionOperation(
|
|
301
|
+
operation_id=operation_id,
|
|
302
|
+
baseline_snapshot=baseline_snapshot,
|
|
303
|
+
timestamp=datetime.utcnow(),
|
|
304
|
+
account_id=account_id,
|
|
305
|
+
mode=OperationMode.EXECUTE,
|
|
306
|
+
status=final_status,
|
|
307
|
+
total_resources=len(filtered_resources),
|
|
308
|
+
succeeded_count=succeeded_count,
|
|
309
|
+
failed_count=failed_count,
|
|
310
|
+
skipped_count=skipped_count,
|
|
311
|
+
aws_profile=aws_profile,
|
|
312
|
+
filters=self._build_filters_dict(resource_types, regions),
|
|
313
|
+
)
|
|
314
|
+
|
|
315
|
+
# Log operation to audit storage with deletion records
|
|
316
|
+
self.audit_storage.log_operation(operation, deletion_records)
|
|
317
|
+
|
|
318
|
+
return operation
|
|
319
|
+
|
|
320
|
+
def _delete_resource(self, resource: dict, aws_profile: Optional[str] = None) -> bool:
|
|
321
|
+
"""Delete a single AWS resource.
|
|
322
|
+
|
|
323
|
+
Args:
|
|
324
|
+
resource: Resource dictionary with type, ARN, region
|
|
325
|
+
aws_profile: AWS profile name (optional)
|
|
326
|
+
|
|
327
|
+
Returns:
|
|
328
|
+
True if deletion succeeded, False otherwise
|
|
329
|
+
"""
|
|
330
|
+
from src.restore.deleter import ResourceDeleter
|
|
331
|
+
|
|
332
|
+
deleter = ResourceDeleter(aws_profile=aws_profile)
|
|
333
|
+
|
|
334
|
+
resource_type = resource.get("resource_type", "")
|
|
335
|
+
resource_id = resource.get("resource_id", "")
|
|
336
|
+
region = resource.get("region", "")
|
|
337
|
+
arn = resource.get("arn", "")
|
|
338
|
+
|
|
339
|
+
success, error_message = deleter.delete_resource(
|
|
340
|
+
resource_type=resource_type,
|
|
341
|
+
resource_id=resource_id,
|
|
342
|
+
region=region,
|
|
343
|
+
arn=arn,
|
|
344
|
+
)
|
|
345
|
+
|
|
346
|
+
if not success:
|
|
347
|
+
logger.warning(f"Failed to delete {resource_type} {resource_id}: {error_message}")
|
|
348
|
+
|
|
349
|
+
return success
|
|
350
|
+
|
|
351
|
+
def _collect_current_resources(self, account_id: str, regions: Optional[list[str]] = None) -> list[dict]:
|
|
352
|
+
"""Collect current resources from AWS account.
|
|
353
|
+
|
|
354
|
+
This is a placeholder that would normally call AWS APIs to collect
|
|
355
|
+
current resource state. For testing, this is mocked.
|
|
356
|
+
|
|
357
|
+
Args:
|
|
358
|
+
account_id: AWS account ID
|
|
359
|
+
regions: Regions to collect from (optional)
|
|
360
|
+
|
|
361
|
+
Returns:
|
|
362
|
+
List of current resources
|
|
363
|
+
"""
|
|
364
|
+
# Placeholder - in real implementation, would use snapshot capturer
|
|
365
|
+
# or similar mechanism to collect current state
|
|
366
|
+
return []
|
|
367
|
+
|
|
368
|
+
def _apply_filters(
|
|
369
|
+
self,
|
|
370
|
+
resources: list[dict],
|
|
371
|
+
resource_types: Optional[list[str]] = None,
|
|
372
|
+
regions: Optional[list[str]] = None,
|
|
373
|
+
) -> list[dict]:
|
|
374
|
+
"""Apply filters to resource list.
|
|
375
|
+
|
|
376
|
+
Args:
|
|
377
|
+
resources: List of resources to filter
|
|
378
|
+
resource_types: Filter by resource types (optional)
|
|
379
|
+
regions: Filter by regions (optional)
|
|
380
|
+
|
|
381
|
+
Returns:
|
|
382
|
+
Filtered list of resources
|
|
383
|
+
"""
|
|
384
|
+
filtered = resources
|
|
385
|
+
|
|
386
|
+
if resource_types:
|
|
387
|
+
filtered = [r for r in filtered if r.get("resource_type") in resource_types]
|
|
388
|
+
|
|
389
|
+
if regions:
|
|
390
|
+
filtered = [r for r in filtered if r.get("region") in regions]
|
|
391
|
+
|
|
392
|
+
return filtered
|
|
393
|
+
|
|
394
|
+
def _build_filters_dict(
|
|
395
|
+
self,
|
|
396
|
+
resource_types: Optional[list[str]] = None,
|
|
397
|
+
regions: Optional[list[str]] = None,
|
|
398
|
+
) -> Optional[dict]:
|
|
399
|
+
"""Build filters dictionary for operation metadata.
|
|
400
|
+
|
|
401
|
+
Args:
|
|
402
|
+
resource_types: Resource types filter
|
|
403
|
+
regions: Regions filter
|
|
404
|
+
|
|
405
|
+
Returns:
|
|
406
|
+
Filters dictionary or None if no filters
|
|
407
|
+
"""
|
|
408
|
+
filters = {}
|
|
409
|
+
|
|
410
|
+
if resource_types:
|
|
411
|
+
filters["resource_types"] = resource_types
|
|
412
|
+
|
|
413
|
+
if regions:
|
|
414
|
+
filters["regions"] = regions
|
|
415
|
+
|
|
416
|
+
return filters if filters else None
|
|
417
|
+
|
|
418
|
+
def _resource_to_dict(self, resource: Any) -> dict:
|
|
419
|
+
"""Convert Resource object to dictionary format.
|
|
420
|
+
|
|
421
|
+
Args:
|
|
422
|
+
resource: Resource object from snapshot
|
|
423
|
+
|
|
424
|
+
Returns:
|
|
425
|
+
Dictionary representation of resource
|
|
426
|
+
"""
|
|
427
|
+
# Resource objects have these attributes
|
|
428
|
+
return {
|
|
429
|
+
"resource_id": resource.name, # Resource uses 'name' field
|
|
430
|
+
"resource_type": resource.resource_type,
|
|
431
|
+
"region": resource.region,
|
|
432
|
+
"arn": resource.arn,
|
|
433
|
+
"tags": resource.tags,
|
|
434
|
+
"estimated_monthly_cost": getattr(resource, "estimated_monthly_cost", None),
|
|
435
|
+
}
|
|
436
|
+
|
|
437
|
+
def _dict_to_resource(self, resource_dict: dict) -> Any:
|
|
438
|
+
"""Convert dictionary to Resource object.
|
|
439
|
+
|
|
440
|
+
Args:
|
|
441
|
+
resource_dict: Dictionary representation of resource
|
|
442
|
+
|
|
443
|
+
Returns:
|
|
444
|
+
Resource object
|
|
445
|
+
"""
|
|
446
|
+
import hashlib
|
|
447
|
+
|
|
448
|
+
from src.models.resource import Resource
|
|
449
|
+
|
|
450
|
+
# Generate config hash from resource dict for comparison
|
|
451
|
+
config_str = f"{resource_dict.get('arn', '')}{resource_dict.get('resource_type', '')}"
|
|
452
|
+
config_hash = hashlib.sha256(config_str.encode()).hexdigest()
|
|
453
|
+
|
|
454
|
+
return Resource(
|
|
455
|
+
arn=resource_dict.get("arn", ""),
|
|
456
|
+
resource_type=resource_dict.get("resource_type", ""),
|
|
457
|
+
name=resource_dict.get("resource_id", ""),
|
|
458
|
+
region=resource_dict.get("region", ""),
|
|
459
|
+
config_hash=config_hash,
|
|
460
|
+
tags=resource_dict.get("tags", {}),
|
|
461
|
+
)
|
src/restore/config.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Protection rules configuration loader.
|
|
2
|
+
|
|
3
|
+
Loads protection rules from YAML config files and CLI options.
|
|
4
|
+
"""
|
|
5
|
+
|
|
6
|
+
from __future__ import annotations
|
|
7
|
+
|
|
8
|
+
import logging
|
|
9
|
+
import os
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
from typing import Optional
|
|
12
|
+
|
|
13
|
+
import yaml
|
|
14
|
+
|
|
15
|
+
from src.models.protection_rule import ProtectionRule, RuleType
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
# Default config file locations (checked in order)
|
|
20
|
+
CONFIG_LOCATIONS = [
|
|
21
|
+
".awsinv-restore.yaml", # Project-local
|
|
22
|
+
".awsinv-restore.yml",
|
|
23
|
+
os.path.expanduser("~/.awsinv/restore.yaml"), # User-level
|
|
24
|
+
os.path.expanduser("~/.awsinv/restore.yml"),
|
|
25
|
+
]
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def find_config_file() -> Optional[Path]:
|
|
29
|
+
"""Find the first available config file.
|
|
30
|
+
|
|
31
|
+
Searches in order:
|
|
32
|
+
1. .awsinv-restore.yaml (current directory)
|
|
33
|
+
2. .awsinv-restore.yml (current directory)
|
|
34
|
+
3. ~/.awsinv/restore.yaml (user home)
|
|
35
|
+
4. ~/.awsinv/restore.yml (user home)
|
|
36
|
+
|
|
37
|
+
Returns:
|
|
38
|
+
Path to config file if found, None otherwise
|
|
39
|
+
"""
|
|
40
|
+
for location in CONFIG_LOCATIONS:
|
|
41
|
+
path = Path(location)
|
|
42
|
+
if path.exists():
|
|
43
|
+
logger.debug(f"Found config file: {path}")
|
|
44
|
+
return path
|
|
45
|
+
return None
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def load_config_file(config_path: Optional[str] = None) -> dict:
|
|
49
|
+
"""Load configuration from YAML file.
|
|
50
|
+
|
|
51
|
+
Args:
|
|
52
|
+
config_path: Explicit path to config file (optional).
|
|
53
|
+
If not provided, searches default locations.
|
|
54
|
+
|
|
55
|
+
Returns:
|
|
56
|
+
Configuration dictionary, empty dict if no config found
|
|
57
|
+
"""
|
|
58
|
+
if config_path:
|
|
59
|
+
path = Path(config_path)
|
|
60
|
+
if not path.exists():
|
|
61
|
+
raise FileNotFoundError(f"Config file not found: {config_path}")
|
|
62
|
+
else:
|
|
63
|
+
path = find_config_file()
|
|
64
|
+
if not path:
|
|
65
|
+
logger.debug("No config file found, using defaults")
|
|
66
|
+
return {}
|
|
67
|
+
|
|
68
|
+
logger.info(f"Loading config from: {path}")
|
|
69
|
+
|
|
70
|
+
with open(path) as f:
|
|
71
|
+
config = yaml.safe_load(f) or {}
|
|
72
|
+
|
|
73
|
+
return config
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
def parse_protect_tag(tag_string: str) -> tuple[str, str]:
|
|
77
|
+
"""Parse a protect-tag CLI argument.
|
|
78
|
+
|
|
79
|
+
Args:
|
|
80
|
+
tag_string: Tag in format "key=value"
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
Tuple of (key, value)
|
|
84
|
+
|
|
85
|
+
Raises:
|
|
86
|
+
ValueError: If format is invalid
|
|
87
|
+
"""
|
|
88
|
+
if "=" not in tag_string:
|
|
89
|
+
raise ValueError(f"Invalid tag format: '{tag_string}'. Expected 'key=value'")
|
|
90
|
+
|
|
91
|
+
parts = tag_string.split("=", 1)
|
|
92
|
+
key = parts[0].strip()
|
|
93
|
+
value = parts[1].strip()
|
|
94
|
+
|
|
95
|
+
if not key:
|
|
96
|
+
raise ValueError(f"Empty tag key in: '{tag_string}'")
|
|
97
|
+
|
|
98
|
+
return key, value
|
|
99
|
+
|
|
100
|
+
|
|
101
|
+
def build_protection_rules(
|
|
102
|
+
config: dict,
|
|
103
|
+
cli_protect_tags: Optional[list[str]] = None,
|
|
104
|
+
) -> list[ProtectionRule]:
|
|
105
|
+
"""Build protection rules from config and CLI options.
|
|
106
|
+
|
|
107
|
+
CLI options are merged with config file rules. CLI rules have
|
|
108
|
+
higher priority (lower priority number = checked first).
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
config: Configuration dictionary from YAML file
|
|
112
|
+
cli_protect_tags: List of "key=value" tag strings from CLI
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
List of ProtectionRule objects, sorted by priority
|
|
116
|
+
"""
|
|
117
|
+
rules = []
|
|
118
|
+
rule_counter = 0
|
|
119
|
+
|
|
120
|
+
# 1. CLI protect-tags (highest priority: 1-10)
|
|
121
|
+
if cli_protect_tags:
|
|
122
|
+
for tag_string in cli_protect_tags:
|
|
123
|
+
try:
|
|
124
|
+
key, value = parse_protect_tag(tag_string)
|
|
125
|
+
rule_counter += 1
|
|
126
|
+
rule = ProtectionRule(
|
|
127
|
+
rule_id=f"cli-tag-{rule_counter}",
|
|
128
|
+
rule_type=RuleType.TAG,
|
|
129
|
+
enabled=True,
|
|
130
|
+
priority=rule_counter, # CLI rules get priority 1, 2, 3...
|
|
131
|
+
patterns={"tag_key": key, "tag_values": [value]},
|
|
132
|
+
description=f"CLI protection: {key}={value}",
|
|
133
|
+
)
|
|
134
|
+
rules.append(rule)
|
|
135
|
+
logger.debug(f"Added CLI protection rule: {key}={value}")
|
|
136
|
+
except ValueError as e:
|
|
137
|
+
logger.warning(f"Skipping invalid protect-tag: {e}")
|
|
138
|
+
|
|
139
|
+
# 2. Config file global tag rules (priority 11-50)
|
|
140
|
+
protection_config = config.get("protection", {})
|
|
141
|
+
global_rules = protection_config.get("global", [])
|
|
142
|
+
|
|
143
|
+
for i, rule_config in enumerate(global_rules):
|
|
144
|
+
rule_counter += 1
|
|
145
|
+
priority = 10 + rule_counter
|
|
146
|
+
|
|
147
|
+
if isinstance(rule_config, dict):
|
|
148
|
+
# Complex rule with property/value/type
|
|
149
|
+
prop = rule_config.get("property", "")
|
|
150
|
+
value = rule_config.get("value", "")
|
|
151
|
+
|
|
152
|
+
# Handle tag:Name format
|
|
153
|
+
if prop.startswith("tag:"):
|
|
154
|
+
tag_key = prop[4:] # Remove "tag:" prefix
|
|
155
|
+
rule = ProtectionRule(
|
|
156
|
+
rule_id=f"config-global-{i + 1}",
|
|
157
|
+
rule_type=RuleType.TAG,
|
|
158
|
+
enabled=True,
|
|
159
|
+
priority=priority,
|
|
160
|
+
patterns={"tag_key": tag_key, "tag_values": [value]},
|
|
161
|
+
description=f"Config global: {tag_key}={value}",
|
|
162
|
+
)
|
|
163
|
+
rules.append(rule)
|
|
164
|
+
elif isinstance(rule_config, str):
|
|
165
|
+
# Simple string format "key=value"
|
|
166
|
+
try:
|
|
167
|
+
key, value = parse_protect_tag(rule_config)
|
|
168
|
+
rule = ProtectionRule(
|
|
169
|
+
rule_id=f"config-global-{i + 1}",
|
|
170
|
+
rule_type=RuleType.TAG,
|
|
171
|
+
enabled=True,
|
|
172
|
+
priority=priority,
|
|
173
|
+
patterns={"tag_key": key, "tag_values": [value]},
|
|
174
|
+
description=f"Config global: {key}={value}",
|
|
175
|
+
)
|
|
176
|
+
rules.append(rule)
|
|
177
|
+
except ValueError:
|
|
178
|
+
logger.warning(f"Skipping invalid global rule: {rule_config}")
|
|
179
|
+
|
|
180
|
+
# 3. Excluded types (priority 51-100) - these are TYPE rules
|
|
181
|
+
excluded_types = protection_config.get("excluded_types", [])
|
|
182
|
+
if excluded_types:
|
|
183
|
+
rule = ProtectionRule(
|
|
184
|
+
rule_id="config-excluded-types",
|
|
185
|
+
rule_type=RuleType.TYPE,
|
|
186
|
+
enabled=True,
|
|
187
|
+
priority=51,
|
|
188
|
+
patterns={"resource_types": excluded_types},
|
|
189
|
+
description=f"Excluded types: {', '.join(excluded_types)}",
|
|
190
|
+
)
|
|
191
|
+
rules.append(rule)
|
|
192
|
+
|
|
193
|
+
# Sort by priority
|
|
194
|
+
rules.sort(key=lambda r: r.priority)
|
|
195
|
+
|
|
196
|
+
logger.info(f"Built {len(rules)} protection rules")
|
|
197
|
+
return rules
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def get_skip_aws_managed(config: dict) -> bool:
|
|
201
|
+
"""Get skip_aws_managed setting from config.
|
|
202
|
+
|
|
203
|
+
Args:
|
|
204
|
+
config: Configuration dictionary
|
|
205
|
+
|
|
206
|
+
Returns:
|
|
207
|
+
True if AWS-managed resources should be skipped (default: True)
|
|
208
|
+
"""
|
|
209
|
+
return config.get("skip_aws_managed", True)
|