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/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
|