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/delta/reporter.py ADDED
@@ -0,0 +1,234 @@
1
+ """Delta report formatting and display."""
2
+
3
+ from typing import Optional
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.table import Table
8
+
9
+ from ..models.delta_report import DeltaReport
10
+
11
+
12
+ class DeltaReporter:
13
+ """Format and display delta reports."""
14
+
15
+ def __init__(self, console: Optional[Console] = None):
16
+ """Initialize delta reporter.
17
+
18
+ Args:
19
+ console: Rich console instance (creates new one if not provided)
20
+ """
21
+ self.console = console or Console()
22
+
23
+ def display(self, report: DeltaReport, show_details: bool = False) -> None:
24
+ """Display delta report to console.
25
+
26
+ Args:
27
+ report: DeltaReport to display
28
+ show_details: Whether to show detailed resource information
29
+ """
30
+ # Header
31
+ self.console.print()
32
+ self.console.print(
33
+ Panel(
34
+ f"[bold]Resource Delta Report[/bold]\n"
35
+ f"Reference Snapshot: {report.baseline_snapshot_name}\n"
36
+ f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S UTC')}",
37
+ style="cyan",
38
+ )
39
+ )
40
+ self.console.print()
41
+
42
+ # Check if there are any changes
43
+ if not report.has_changes:
44
+ self.console.print(
45
+ "[green]✓ No changes detected - environment matches reference snapshot[/green]", style="bold"
46
+ )
47
+ self.console.print()
48
+ self.console.print(f"Total resources: {report.baseline_resource_count}")
49
+ return
50
+
51
+ # Summary statistics
52
+ self._display_summary(report)
53
+
54
+ # Group changes by service
55
+ grouped = report.group_by_service()
56
+
57
+ # Display changes by service
58
+ for service_type in sorted(grouped.keys()):
59
+ changes = grouped[service_type]
60
+ if any(changes.values()):
61
+ self._display_service_changes(service_type, changes, show_details)
62
+
63
+ # Display drift details if available
64
+ if report.drift_report is not None and report.drift_report.total_changes > 0:
65
+ from .formatters import DriftFormatter
66
+
67
+ self.console.print()
68
+ self.console.print("[bold cyan]═" * 50 + "[/bold cyan]")
69
+ formatter = DriftFormatter(self.console)
70
+ formatter.display(report.drift_report)
71
+
72
+ def _display_summary(self, report: DeltaReport) -> None:
73
+ """Display summary statistics."""
74
+ table = Table(title="Summary", show_header=True, header_style="bold magenta")
75
+ table.add_column("Change Type", style="cyan", width=15)
76
+ table.add_column("Count", justify="right", style="yellow", width=10)
77
+
78
+ added_count = len(report.added_resources)
79
+ deleted_count = len(report.deleted_resources)
80
+ modified_count = len(report.modified_resources)
81
+ unchanged_count = report.unchanged_count
82
+
83
+ if added_count > 0:
84
+ table.add_row("➕ Added", f"[green]{added_count}[/green]")
85
+ if deleted_count > 0:
86
+ table.add_row("➖ Deleted", f"[red]{deleted_count}[/red]")
87
+ if modified_count > 0:
88
+ table.add_row("🔄 Modified", f"[yellow]{modified_count}[/yellow]")
89
+ if unchanged_count > 0:
90
+ table.add_row("✓ Unchanged", str(unchanged_count))
91
+
92
+ table.add_row("━" * 15, "━" * 10, style="dim")
93
+ table.add_row("[bold]Total Changes", f"[bold]{report.total_changes}")
94
+
95
+ self.console.print(table)
96
+ self.console.print()
97
+
98
+ def _display_service_changes(self, service_type: str, changes: dict, show_details: bool) -> None:
99
+ """Display changes for a specific service type.
100
+
101
+ Args:
102
+ service_type: AWS resource type (e.g., 'AWS::EC2::Instance')
103
+ changes: Dictionary with 'added', 'deleted', 'modified' lists
104
+ show_details: Whether to show detailed information
105
+ """
106
+ # Count total changes for this service
107
+ total = len(changes["added"]) + len(changes["deleted"]) + len(changes["modified"])
108
+ if total == 0:
109
+ return
110
+
111
+ self.console.print(f"[bold cyan]{service_type}[/bold cyan] ({total} changes)")
112
+
113
+ # Create table for this service
114
+ table = Table(show_header=True, box=None, padding=(0, 2))
115
+ table.add_column("Change", width=8)
116
+ table.add_column("Name", style="white")
117
+ table.add_column("Region", width=15)
118
+ table.add_column("ARN" if show_details else "Tags", style="dim")
119
+
120
+ # Added resources
121
+ for resource in changes["added"]:
122
+ tags_str = self._format_tags(resource.tags)
123
+ arn_or_tags = resource.arn if show_details else tags_str
124
+ table.add_row("[green]➕ Add[/green]", resource.name, resource.region, arn_or_tags)
125
+
126
+ # Deleted resources
127
+ for resource in changes["deleted"]:
128
+ tags_str = self._format_tags(resource.tags)
129
+ arn_or_tags = resource.arn if show_details else tags_str
130
+ table.add_row("[red]➖ Del[/red]", resource.name, resource.region, arn_or_tags)
131
+
132
+ # Modified resources
133
+ for change in changes["modified"]:
134
+ tags_str = self._format_tags(change.resource.tags)
135
+ arn_or_tags = change.resource.arn if show_details else tags_str
136
+ table.add_row("[yellow]🔄 Mod[/yellow]", change.resource.name, change.resource.region, arn_or_tags)
137
+
138
+ self.console.print(table)
139
+ self.console.print()
140
+
141
+ def _format_tags(self, tags: dict) -> str:
142
+ """Format tags dictionary as a string.
143
+
144
+ Args:
145
+ tags: Dictionary of tag key-value pairs
146
+
147
+ Returns:
148
+ Formatted string like "Env=prod, App=web"
149
+ """
150
+ if not tags:
151
+ return "-"
152
+
153
+ # Show up to 3 most important tags
154
+ important_keys = ["Name", "Environment", "Project", "Team", "Application"]
155
+ selected_tags = []
156
+
157
+ # First add important tags if present
158
+ for key in important_keys:
159
+ if key in tags:
160
+ selected_tags.append(f"{key}={tags[key]}")
161
+
162
+ # Add other tags up to limit
163
+ for key, value in tags.items():
164
+ if key not in important_keys and len(selected_tags) < 3:
165
+ selected_tags.append(f"{key}={value}")
166
+
167
+ return ", ".join(selected_tags[:3])
168
+
169
+ def export_json(self, report: DeltaReport, filepath: str) -> None:
170
+ """Export delta report to JSON file.
171
+
172
+ Args:
173
+ report: DeltaReport to export
174
+ filepath: Destination file path
175
+ """
176
+ from ..utils.export import export_to_json
177
+
178
+ export_to_json(report.to_dict(), filepath)
179
+ self.console.print(f"[green]✓ Delta report exported to {filepath}[/green]")
180
+
181
+ def export_csv(self, report: DeltaReport, filepath: str) -> None:
182
+ """Export delta report to CSV file.
183
+
184
+ Args:
185
+ report: DeltaReport to export
186
+ filepath: Destination file path
187
+ """
188
+ from ..utils.export import export_to_csv
189
+
190
+ # Flatten the report into rows
191
+ rows = []
192
+
193
+ for resource in report.added_resources:
194
+ rows.append(
195
+ {
196
+ "change_type": "added",
197
+ "resource_type": resource.resource_type,
198
+ "name": resource.name,
199
+ "arn": resource.arn,
200
+ "region": resource.region,
201
+ "tags": str(resource.tags),
202
+ "created_at": resource.created_at.isoformat() if resource.created_at else None,
203
+ }
204
+ )
205
+
206
+ for resource in report.deleted_resources:
207
+ rows.append(
208
+ {
209
+ "change_type": "deleted",
210
+ "resource_type": resource.resource_type,
211
+ "name": resource.name,
212
+ "arn": resource.arn,
213
+ "region": resource.region,
214
+ "tags": str(resource.tags),
215
+ "created_at": resource.created_at.isoformat() if resource.created_at else None,
216
+ }
217
+ )
218
+
219
+ for change in report.modified_resources:
220
+ rows.append(
221
+ {
222
+ "change_type": "modified",
223
+ "resource_type": change.resource.resource_type,
224
+ "name": change.resource.name,
225
+ "arn": change.resource.arn,
226
+ "region": change.resource.region,
227
+ "tags": str(change.resource.tags),
228
+ "old_hash": change.old_config_hash,
229
+ "new_hash": change.new_config_hash,
230
+ }
231
+ )
232
+
233
+ export_to_csv(rows, filepath)
234
+ self.console.print(f"[green]✓ Delta report exported to {filepath}[/green]")
src/models/__init__.py ADDED
@@ -0,0 +1,21 @@
1
+ """Data models for AWS Baseline Snapshot tool."""
2
+
3
+ from .cost_report import CostBreakdown, CostReport
4
+ from .delta_report import DeltaReport, ResourceChange
5
+ from .group import GroupMember, ResourceGroup, extract_resource_name
6
+ from .inventory import Inventory
7
+ from .resource import Resource
8
+ from .snapshot import Snapshot
9
+
10
+ __all__ = [
11
+ "Snapshot",
12
+ "Resource",
13
+ "DeltaReport",
14
+ "ResourceChange",
15
+ "CostReport",
16
+ "CostBreakdown",
17
+ "Inventory",
18
+ "ResourceGroup",
19
+ "GroupMember",
20
+ "extract_resource_name",
21
+ ]
@@ -0,0 +1,135 @@
1
+ """Configuration diff model for representing field-level changes between snapshots."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from dataclasses import dataclass
6
+ from enum import Enum
7
+ from typing import Any
8
+
9
+
10
+ class ChangeCategory(Enum):
11
+ """Categories of configuration changes."""
12
+
13
+ TAGS = "tags"
14
+ CONFIGURATION = "configuration"
15
+ SECURITY = "security"
16
+ PERMISSIONS = "permissions"
17
+
18
+
19
+ # Security-critical field patterns that should be flagged
20
+ SECURITY_CRITICAL_FIELDS = {
21
+ "PubliclyAccessible",
22
+ "public",
23
+ "encryption",
24
+ "kms",
25
+ "SecurityGroups",
26
+ "IpPermissions",
27
+ "IpPermissionsEgress",
28
+ "Policy",
29
+ "BucketPolicy",
30
+ "Acl",
31
+ "HttpTokens", # IMDSv2
32
+ "MetadataOptions",
33
+ }
34
+
35
+
36
+ @dataclass
37
+ class ConfigDiff:
38
+ """Represents a field-level configuration change between two resource snapshots.
39
+
40
+ Attributes:
41
+ resource_arn: AWS ARN of the resource that changed
42
+ field_path: Dot-notation path to the changed field (e.g., "Tags.Environment")
43
+ old_value: Previous value of the field (None if field was added)
44
+ new_value: New value of the field (None if field was removed)
45
+ category: Category of the change (tags/configuration/security/permissions)
46
+ """
47
+
48
+ resource_arn: str
49
+ field_path: str
50
+ old_value: Any
51
+ new_value: Any
52
+ category: ChangeCategory
53
+
54
+ def __post_init__(self) -> None:
55
+ """Validate ConfigDiff fields after initialization."""
56
+ # Validate ARN format (basic check)
57
+ if not self.resource_arn or not self.resource_arn.startswith("arn:"):
58
+ raise ValueError(f"Invalid ARN format: {self.resource_arn}")
59
+
60
+ # Validate field_path is not empty
61
+ if not self.field_path:
62
+ raise ValueError("field_path cannot be empty")
63
+
64
+ # Validate category is ChangeCategory enum
65
+ if not isinstance(self.category, ChangeCategory):
66
+ raise ValueError(f"Invalid category type: {type(self.category)}. Must be ChangeCategory enum.")
67
+
68
+ def with_path_prefix(self, prefix: str) -> ConfigDiff:
69
+ """Create a new ConfigDiff with a prefix added to the field path.
70
+
71
+ Args:
72
+ prefix: Prefix to add to the field path
73
+
74
+ Returns:
75
+ New ConfigDiff instance with prefixed field path
76
+ """
77
+ return ConfigDiff(
78
+ resource_arn=self.resource_arn,
79
+ field_path=f"{prefix}.{self.field_path}",
80
+ old_value=self.old_value,
81
+ new_value=self.new_value,
82
+ category=self.category,
83
+ )
84
+
85
+ def is_security_critical(self) -> bool:
86
+ """Check if this configuration change affects security-related settings.
87
+
88
+ Returns:
89
+ True if the change is security-critical, False otherwise
90
+ """
91
+ # Check if field path contains any security-critical keywords
92
+ field_lower = self.field_path.lower()
93
+ return any(keyword.lower() in field_lower for keyword in SECURITY_CRITICAL_FIELDS)
94
+
95
+ def to_dict(self) -> dict[str, Any]:
96
+ """Convert ConfigDiff to dictionary representation.
97
+
98
+ Returns:
99
+ Dictionary with all diff attributes
100
+ """
101
+ return {
102
+ "resource_arn": self.resource_arn,
103
+ "field_path": self.field_path,
104
+ "old_value": self.old_value,
105
+ "new_value": self.new_value,
106
+ "category": self.category.value,
107
+ "security_critical": self.is_security_critical(),
108
+ }
109
+
110
+ @classmethod
111
+ def from_dict(cls, data: dict[str, Any]) -> ConfigDiff:
112
+ """Create ConfigDiff from dictionary representation.
113
+
114
+ Args:
115
+ data: Dictionary with diff attributes
116
+
117
+ Returns:
118
+ ConfigDiff instance
119
+
120
+ Raises:
121
+ ValueError: If category value is invalid
122
+ """
123
+ category_str = data.get("category", "").lower()
124
+ try:
125
+ category = ChangeCategory(category_str)
126
+ except ValueError:
127
+ raise ValueError(f"Invalid category value: {category_str}")
128
+
129
+ return cls(
130
+ resource_arn=data["resource_arn"],
131
+ field_path=data["field_path"],
132
+ old_value=data["old_value"],
133
+ new_value=data["new_value"],
134
+ category=category,
135
+ )
@@ -0,0 +1,87 @@
1
+ """Cost report models for cost analysis and tracking."""
2
+
3
+ from dataclasses import dataclass, field
4
+ from datetime import datetime
5
+ from typing import Any, Dict, Optional
6
+
7
+
8
+ @dataclass
9
+ class CostBreakdown:
10
+ """Represents cost breakdown for baseline or non-baseline resources."""
11
+
12
+ total: float
13
+ by_service: Dict[str, float] = field(default_factory=dict)
14
+ percentage: float = 0.0
15
+
16
+ def to_dict(self) -> Dict[str, Any]:
17
+ """Convert to dictionary for serialization."""
18
+ return {
19
+ "total": self.total,
20
+ "by_service": self.by_service,
21
+ "percentage": self.percentage,
22
+ }
23
+
24
+
25
+ @dataclass
26
+ class CostReport:
27
+ """Represents cost analysis separating baseline vs non-baseline costs."""
28
+
29
+ generated_at: datetime
30
+ baseline_snapshot_name: str
31
+ period_start: datetime
32
+ period_end: datetime
33
+ baseline_costs: CostBreakdown
34
+ non_baseline_costs: CostBreakdown
35
+ total_cost: float
36
+ data_complete: bool = True
37
+ data_through: Optional[datetime] = None
38
+ lag_days: int = 0
39
+
40
+ def to_dict(self) -> Dict[str, Any]:
41
+ """Convert to dictionary for serialization."""
42
+ return {
43
+ "generated_at": self.generated_at.isoformat(),
44
+ "baseline_snapshot_name": self.baseline_snapshot_name,
45
+ "period_start": self.period_start.isoformat(),
46
+ "period_end": self.period_end.isoformat(),
47
+ "baseline_costs": self.baseline_costs.to_dict(),
48
+ "non_baseline_costs": self.non_baseline_costs.to_dict(),
49
+ "total_cost": self.total_cost,
50
+ "data_complete": self.data_complete,
51
+ "data_through": self.data_through.isoformat() if self.data_through else None,
52
+ "lag_days": self.lag_days,
53
+ "summary": {
54
+ "baseline_total": self.baseline_costs.total,
55
+ "baseline_percentage": self.baseline_costs.percentage,
56
+ "non_baseline_total": self.non_baseline_costs.total,
57
+ "non_baseline_percentage": self.non_baseline_costs.percentage,
58
+ "total": self.total_cost,
59
+ },
60
+ }
61
+
62
+ @property
63
+ def baseline_percentage(self) -> float:
64
+ """Get baseline cost percentage."""
65
+ return self.baseline_costs.percentage
66
+
67
+ @property
68
+ def non_baseline_percentage(self) -> float:
69
+ """Get non-baseline cost percentage."""
70
+ return self.non_baseline_costs.percentage
71
+
72
+ def get_top_services(self, limit: int = 5, baseline: bool = True) -> Dict[str, float]:
73
+ """Get top N services by cost.
74
+
75
+ Args:
76
+ limit: Number of top services to return
77
+ baseline: If True, return baseline services; if False, non-baseline
78
+
79
+ Returns:
80
+ Dictionary of service name to cost, sorted by cost descending
81
+ """
82
+ services = self.baseline_costs.by_service if baseline else self.non_baseline_costs.by_service
83
+
84
+ # Sort by cost descending
85
+ sorted_services = sorted(services.items(), key=lambda x: x[1], reverse=True)
86
+
87
+ return dict(sorted_services[:limit])
@@ -0,0 +1,104 @@
1
+ """Deletion operation model.
2
+
3
+ Represents a complete restore operation with metadata, filters, and execution context.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import Optional
12
+
13
+
14
+ class OperationMode(Enum):
15
+ """Operation execution mode."""
16
+
17
+ DRY_RUN = "dry-run"
18
+ EXECUTE = "execute"
19
+
20
+
21
+ class OperationStatus(Enum):
22
+ """Operation execution status with state transitions."""
23
+
24
+ PLANNED = "planned"
25
+ EXECUTING = "executing"
26
+ COMPLETED = "completed"
27
+ PARTIAL = "partial"
28
+ FAILED = "failed"
29
+
30
+
31
+ @dataclass
32
+ class DeletionOperation:
33
+ """Deletion operation entity.
34
+
35
+ Represents a complete restore operation with metadata, filters, and execution
36
+ context. Tracks overall progress and status of resource deletion.
37
+
38
+ State transitions:
39
+ planned → executing → completed (all succeeded)
40
+ planned → executing → partial (some failed)
41
+ planned → executing → failed (critical error)
42
+
43
+ Attributes:
44
+ operation_id: Unique identifier for the operation
45
+ baseline_snapshot: Name of baseline snapshot to compare against
46
+ timestamp: When operation was initiated (ISO 8601 UTC)
47
+ account_id: AWS account ID (12-digit number)
48
+ mode: dry-run or execute
49
+ status: Current execution status
50
+ total_resources: Total resources identified for deletion
51
+ succeeded_count: Number successfully deleted (default: 0)
52
+ failed_count: Number that failed to delete (default: 0)
53
+ skipped_count: Number skipped due to protections (default: 0)
54
+ aws_profile: AWS profile used for credentials (optional)
55
+ filters: Resource type and region filters (optional)
56
+ started_at: When execution started (optional, execute mode only)
57
+ completed_at: When execution completed (optional)
58
+ duration_seconds: Total execution duration (optional)
59
+ """
60
+
61
+ operation_id: str
62
+ baseline_snapshot: str
63
+ timestamp: datetime
64
+ account_id: str
65
+ mode: OperationMode
66
+ status: OperationStatus
67
+ total_resources: int
68
+ succeeded_count: int = 0
69
+ failed_count: int = 0
70
+ skipped_count: int = 0
71
+ aws_profile: Optional[str] = None
72
+ filters: Optional[dict] = field(default=None)
73
+ started_at: Optional[datetime] = None
74
+ completed_at: Optional[datetime] = None
75
+ duration_seconds: Optional[float] = None
76
+
77
+ def validate(self) -> bool:
78
+ """Validate operation invariants.
79
+
80
+ Validation rules:
81
+ - succeeded_count + failed_count + skipped_count == total_resources
82
+ - completed_at must be after started_at
83
+ - dry-run mode must have planned status
84
+
85
+ Returns:
86
+ True if validation passes
87
+
88
+ Raises:
89
+ ValueError: If any validation rule fails
90
+ """
91
+ # Count validation
92
+ if self.succeeded_count + self.failed_count + self.skipped_count != self.total_resources:
93
+ raise ValueError("Resource counts don't match total")
94
+
95
+ # Timing validation
96
+ if self.completed_at and self.started_at:
97
+ if self.completed_at < self.started_at:
98
+ raise ValueError("Completion time before start time")
99
+
100
+ # Mode/status consistency
101
+ if self.mode == OperationMode.DRY_RUN and self.status != OperationStatus.PLANNED:
102
+ raise ValueError("Dry-run mode must have planned status")
103
+
104
+ return True
@@ -0,0 +1,97 @@
1
+ """Deletion record model.
2
+
3
+ Individual resource deletion attempt with result and metadata.
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ from dataclasses import dataclass, field
9
+ from datetime import datetime
10
+ from enum import Enum
11
+ from typing import Optional
12
+
13
+
14
+ class DeletionStatus(Enum):
15
+ """Individual resource deletion status."""
16
+
17
+ SUCCEEDED = "succeeded"
18
+ FAILED = "failed"
19
+ SKIPPED = "skipped"
20
+
21
+
22
+ @dataclass
23
+ class DeletionRecord:
24
+ """Deletion record entity.
25
+
26
+ Represents an individual resource deletion attempt with result and metadata.
27
+ Each record belongs to a DeletionOperation and tracks the outcome for a single
28
+ resource.
29
+
30
+ Validation rules:
31
+ - status=succeeded: no error_code or protection_reason
32
+ - status=failed: requires error_code
33
+ - status=skipped: requires protection_reason
34
+ - resource_arn must start with "arn:aws:"
35
+ - estimated_monthly_cost must be >= 0 if provided
36
+
37
+ Attributes:
38
+ record_id: Unique identifier for this record
39
+ operation_id: Parent operation identifier
40
+ resource_arn: AWS Resource Name (ARN format)
41
+ resource_id: Resource identifier (ID, name)
42
+ resource_type: AWS resource type (format: aws:service:type)
43
+ region: AWS region
44
+ timestamp: When deletion was attempted (ISO 8601 UTC)
45
+ status: Deletion outcome (succeeded, failed, skipped)
46
+ error_code: AWS error code if failed (optional)
47
+ error_message: Human-readable error if failed (optional)
48
+ protection_reason: Why resource was skipped (optional)
49
+ deletion_tier: Tier (1-5) for deletion ordering (optional)
50
+ tags: Resource tags at deletion time (optional)
51
+ estimated_monthly_cost: Estimated cost in USD (optional)
52
+ """
53
+
54
+ record_id: str
55
+ operation_id: str
56
+ resource_arn: str
57
+ resource_id: str
58
+ resource_type: str
59
+ region: str
60
+ timestamp: datetime
61
+ status: DeletionStatus
62
+ error_code: Optional[str] = None
63
+ error_message: Optional[str] = None
64
+ protection_reason: Optional[str] = None
65
+ deletion_tier: Optional[int] = None
66
+ tags: Optional[dict] = field(default=None)
67
+ estimated_monthly_cost: Optional[float] = None
68
+
69
+ def validate(self) -> bool:
70
+ """Validate record invariants.
71
+
72
+ Returns:
73
+ True if validation passes
74
+
75
+ Raises:
76
+ ValueError: If any validation rule fails
77
+ """
78
+ # Status-specific validation
79
+ if self.status == DeletionStatus.FAILED:
80
+ if not self.error_code:
81
+ raise ValueError("Failed status requires error_code")
82
+ elif self.status == DeletionStatus.SKIPPED:
83
+ if not self.protection_reason:
84
+ raise ValueError("Skipped status requires protection_reason")
85
+ elif self.status == DeletionStatus.SUCCEEDED:
86
+ if self.error_code or self.protection_reason:
87
+ raise ValueError("Succeeded status cannot have error or protection reason")
88
+
89
+ # ARN format validation
90
+ if not self.resource_arn.startswith("arn:aws:"):
91
+ raise ValueError("Invalid ARN format")
92
+
93
+ # Cost validation
94
+ if self.estimated_monthly_cost is not None and self.estimated_monthly_cost < 0:
95
+ raise ValueError("Cost cannot be negative")
96
+
97
+ return True