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.

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