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/cost/explorer.py
ADDED
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""Cost Explorer integration for retrieving cost data."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timedelta
|
|
5
|
+
from typing import Dict, List, Optional, Tuple
|
|
6
|
+
|
|
7
|
+
import boto3
|
|
8
|
+
from botocore.exceptions import ClientError
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class CostExplorerClient:
|
|
14
|
+
"""Wrapper for AWS Cost Explorer API."""
|
|
15
|
+
|
|
16
|
+
def __init__(self, profile_name: Optional[str] = None):
|
|
17
|
+
"""Initialize Cost Explorer client.
|
|
18
|
+
|
|
19
|
+
Args:
|
|
20
|
+
profile_name: AWS profile name (optional)
|
|
21
|
+
"""
|
|
22
|
+
if profile_name:
|
|
23
|
+
session = boto3.Session(profile_name=profile_name)
|
|
24
|
+
self.client = session.client("ce", region_name="us-east-1") # Cost Explorer is global
|
|
25
|
+
else:
|
|
26
|
+
self.client = boto3.client("ce", region_name="us-east-1")
|
|
27
|
+
|
|
28
|
+
def get_cost_and_usage(
|
|
29
|
+
self,
|
|
30
|
+
start_date: datetime,
|
|
31
|
+
end_date: datetime,
|
|
32
|
+
granularity: str = "MONTHLY",
|
|
33
|
+
metrics: Optional[List[str]] = None,
|
|
34
|
+
group_by: Optional[List[Dict[str, str]]] = None,
|
|
35
|
+
filter_expression: Optional[Dict] = None,
|
|
36
|
+
) -> Dict:
|
|
37
|
+
"""Get cost and usage data from Cost Explorer.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
start_date: Start date for cost data (inclusive)
|
|
41
|
+
end_date: End date for cost data (exclusive)
|
|
42
|
+
granularity: Time granularity - DAILY or MONTHLY
|
|
43
|
+
metrics: Cost metrics to retrieve (default: UnblendedCost)
|
|
44
|
+
group_by: Dimensions to group by (e.g., SERVICE, REGION)
|
|
45
|
+
filter_expression: Filter to apply to cost data
|
|
46
|
+
|
|
47
|
+
Returns:
|
|
48
|
+
Cost and usage data from Cost Explorer API
|
|
49
|
+
|
|
50
|
+
Raises:
|
|
51
|
+
CostExplorerError: If Cost Explorer is not enabled or API call fails
|
|
52
|
+
"""
|
|
53
|
+
if metrics is None:
|
|
54
|
+
metrics = ["UnblendedCost"]
|
|
55
|
+
|
|
56
|
+
try:
|
|
57
|
+
params = {
|
|
58
|
+
"TimePeriod": {
|
|
59
|
+
"Start": start_date.strftime("%Y-%m-%d"),
|
|
60
|
+
"End": end_date.strftime("%Y-%m-%d"),
|
|
61
|
+
},
|
|
62
|
+
"Granularity": granularity,
|
|
63
|
+
"Metrics": metrics,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
if group_by:
|
|
67
|
+
params["GroupBy"] = group_by # type: ignore[assignment]
|
|
68
|
+
|
|
69
|
+
if filter_expression:
|
|
70
|
+
params["Filter"] = filter_expression
|
|
71
|
+
|
|
72
|
+
logger.info(
|
|
73
|
+
f"Retrieving cost data from {start_date.strftime('%Y-%m-%d')} " f"to {end_date.strftime('%Y-%m-%d')}"
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
response = self.client.get_cost_and_usage(**params)
|
|
77
|
+
|
|
78
|
+
logger.info(f"Retrieved {len(response.get('ResultsByTime', []))} time periods")
|
|
79
|
+
|
|
80
|
+
return response # type: ignore[return-value]
|
|
81
|
+
|
|
82
|
+
except ClientError as e:
|
|
83
|
+
error_code = e.response.get("Error", {}).get("Code", "Unknown")
|
|
84
|
+
|
|
85
|
+
if error_code == "AccessDeniedException":
|
|
86
|
+
raise CostExplorerError(
|
|
87
|
+
"Access denied to Cost Explorer. Ensure your IAM user/role has the "
|
|
88
|
+
"'ce:GetCostAndUsage' permission."
|
|
89
|
+
)
|
|
90
|
+
elif error_code == "DataUnavailableException":
|
|
91
|
+
raise CostExplorerError(
|
|
92
|
+
"Cost data is not yet available for the specified time period. "
|
|
93
|
+
"Cost Explorer data typically has a 24-48 hour delay."
|
|
94
|
+
)
|
|
95
|
+
else:
|
|
96
|
+
raise CostExplorerError(f"Cost Explorer API error: {e}")
|
|
97
|
+
|
|
98
|
+
except Exception as e:
|
|
99
|
+
logger.error(f"Unexpected error retrieving cost data: {e}")
|
|
100
|
+
raise CostExplorerError(f"Failed to retrieve cost data: {e}")
|
|
101
|
+
|
|
102
|
+
def get_costs_by_service(
|
|
103
|
+
self,
|
|
104
|
+
start_date: datetime,
|
|
105
|
+
end_date: datetime,
|
|
106
|
+
granularity: str = "MONTHLY",
|
|
107
|
+
) -> Dict[str, float]:
|
|
108
|
+
"""Get total costs grouped by AWS service.
|
|
109
|
+
|
|
110
|
+
Args:
|
|
111
|
+
start_date: Start date for cost data
|
|
112
|
+
end_date: End date for cost data
|
|
113
|
+
granularity: Time granularity
|
|
114
|
+
|
|
115
|
+
Returns:
|
|
116
|
+
Dictionary mapping service name to total cost
|
|
117
|
+
"""
|
|
118
|
+
response = self.get_cost_and_usage(
|
|
119
|
+
start_date=start_date,
|
|
120
|
+
end_date=end_date,
|
|
121
|
+
granularity=granularity,
|
|
122
|
+
group_by=[{"Type": "DIMENSION", "Key": "SERVICE"}],
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
service_costs: Dict[str, float] = {}
|
|
126
|
+
|
|
127
|
+
for time_period in response.get("ResultsByTime", []):
|
|
128
|
+
for group in time_period.get("Groups", []):
|
|
129
|
+
service_name = group["Keys"][0]
|
|
130
|
+
cost = float(group["Metrics"]["UnblendedCost"]["Amount"])
|
|
131
|
+
|
|
132
|
+
if service_name in service_costs:
|
|
133
|
+
service_costs[service_name] += cost
|
|
134
|
+
else:
|
|
135
|
+
service_costs[service_name] = cost
|
|
136
|
+
|
|
137
|
+
return service_costs
|
|
138
|
+
|
|
139
|
+
def get_total_cost(
|
|
140
|
+
self,
|
|
141
|
+
start_date: datetime,
|
|
142
|
+
end_date: datetime,
|
|
143
|
+
) -> float:
|
|
144
|
+
"""Get total cost for the specified period.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
start_date: Start date for cost data
|
|
148
|
+
end_date: End date for cost data
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Total cost amount
|
|
152
|
+
"""
|
|
153
|
+
response = self.get_cost_and_usage(
|
|
154
|
+
start_date=start_date,
|
|
155
|
+
end_date=end_date,
|
|
156
|
+
granularity="MONTHLY",
|
|
157
|
+
)
|
|
158
|
+
|
|
159
|
+
total_cost = 0.0
|
|
160
|
+
|
|
161
|
+
for time_period in response.get("ResultsByTime", []):
|
|
162
|
+
cost = float(time_period["Total"]["UnblendedCost"]["Amount"])
|
|
163
|
+
total_cost += cost
|
|
164
|
+
|
|
165
|
+
return total_cost
|
|
166
|
+
|
|
167
|
+
def check_data_completeness(
|
|
168
|
+
self,
|
|
169
|
+
end_date: datetime,
|
|
170
|
+
) -> Tuple[bool, Optional[datetime], int]:
|
|
171
|
+
"""Check if cost data is complete up to the specified date.
|
|
172
|
+
|
|
173
|
+
Cost Explorer typically has a 24-48 hour delay in data availability.
|
|
174
|
+
|
|
175
|
+
Args:
|
|
176
|
+
end_date: The date to check data completeness for
|
|
177
|
+
|
|
178
|
+
Returns:
|
|
179
|
+
Tuple of (is_complete, data_available_through, lag_days)
|
|
180
|
+
"""
|
|
181
|
+
# Cost Explorer data typically lags 1-2 days
|
|
182
|
+
today = datetime.now().date()
|
|
183
|
+
end_date_only = end_date.date()
|
|
184
|
+
|
|
185
|
+
# Calculate lag
|
|
186
|
+
lag_days = (today - end_date_only).days
|
|
187
|
+
|
|
188
|
+
# Data is considered incomplete if less than 2 days old
|
|
189
|
+
is_complete = lag_days >= 2
|
|
190
|
+
|
|
191
|
+
# Estimate data available through date
|
|
192
|
+
if lag_days < 2:
|
|
193
|
+
data_available_through = datetime.combine(today - timedelta(days=2), datetime.min.time())
|
|
194
|
+
else:
|
|
195
|
+
data_available_through = end_date
|
|
196
|
+
|
|
197
|
+
logger.info(
|
|
198
|
+
f"Cost data completeness: {'Complete' if is_complete else 'Incomplete'}, "
|
|
199
|
+
f"available through {data_available_through.strftime('%Y-%m-%d')}, "
|
|
200
|
+
f"lag: {lag_days} days"
|
|
201
|
+
)
|
|
202
|
+
|
|
203
|
+
return is_complete, data_available_through, lag_days
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
class CostExplorerError(Exception):
|
|
207
|
+
"""Exception raised for Cost Explorer errors."""
|
|
208
|
+
|
|
209
|
+
pass
|
src/cost/reporter.py
ADDED
|
@@ -0,0 +1,237 @@
|
|
|
1
|
+
"""Cost 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.cost_report import CostReport
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class CostReporter:
|
|
13
|
+
"""Format and display cost reports."""
|
|
14
|
+
|
|
15
|
+
def __init__(self, console: Optional[Console] = None):
|
|
16
|
+
"""Initialize cost 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: CostReport, show_services: bool = True, has_deltas: bool = False) -> None:
|
|
24
|
+
"""Display cost report to console.
|
|
25
|
+
|
|
26
|
+
Args:
|
|
27
|
+
report: CostReport to display
|
|
28
|
+
show_services: Whether to show service-level breakdown
|
|
29
|
+
has_deltas: Whether there are resource changes (deltas)
|
|
30
|
+
"""
|
|
31
|
+
# Header
|
|
32
|
+
self.console.print()
|
|
33
|
+
self.console.print(
|
|
34
|
+
Panel(
|
|
35
|
+
f"[bold]Cost Analysis Report[/bold]\n"
|
|
36
|
+
f"Snapshot: {report.baseline_snapshot_name}\n"
|
|
37
|
+
f"Period: {report.period_start.strftime('%Y-%m-%d')} to {report.period_end.strftime('%Y-%m-%d')}\n"
|
|
38
|
+
f"Generated: {report.generated_at.strftime('%Y-%m-%d %H:%M:%S UTC')}",
|
|
39
|
+
style="cyan",
|
|
40
|
+
)
|
|
41
|
+
)
|
|
42
|
+
self.console.print()
|
|
43
|
+
|
|
44
|
+
# Data completeness warning
|
|
45
|
+
if not report.data_complete and report.data_through:
|
|
46
|
+
self.console.print(
|
|
47
|
+
f"⚠️ [yellow]Note: Cost data has {report.lag_days} day lag. "
|
|
48
|
+
f"Data available through {report.data_through.strftime('%Y-%m-%d')}[/yellow]\n"
|
|
49
|
+
)
|
|
50
|
+
|
|
51
|
+
# If no deltas, show simplified view
|
|
52
|
+
if not has_deltas:
|
|
53
|
+
self.console.print(
|
|
54
|
+
"✓ [green]No resource changes detected - all costs are from snapshot resources[/green]\n"
|
|
55
|
+
)
|
|
56
|
+
self._display_snapshot_costs(report)
|
|
57
|
+
else:
|
|
58
|
+
# Summary table with baseline/non-baseline split
|
|
59
|
+
self._display_summary(report)
|
|
60
|
+
|
|
61
|
+
# Service breakdown
|
|
62
|
+
if show_services and report.baseline_costs.by_service:
|
|
63
|
+
self.console.print()
|
|
64
|
+
self._display_service_breakdown(report, has_deltas)
|
|
65
|
+
|
|
66
|
+
def _display_snapshot_costs(self, report: CostReport) -> None:
|
|
67
|
+
"""Display snapshot costs (no splitting since there are no changes)."""
|
|
68
|
+
table = Table(title="Snapshot Costs", show_header=True, header_style="bold cyan")
|
|
69
|
+
table.add_column("Total Cost", justify="right", style="bold green", width=20)
|
|
70
|
+
table.add_row(f"${report.baseline_costs.total:,.2f}")
|
|
71
|
+
self.console.print(table)
|
|
72
|
+
|
|
73
|
+
def _display_summary(self, report: CostReport) -> None:
|
|
74
|
+
"""Display cost summary."""
|
|
75
|
+
table = Table(title="Cost Summary", show_header=True, header_style="bold magenta")
|
|
76
|
+
table.add_column("Category", style="cyan", width=25)
|
|
77
|
+
table.add_column("Amount (USD)", justify="right", style="green", width=15)
|
|
78
|
+
table.add_column("Percentage", justify="right", width=12)
|
|
79
|
+
table.add_column("Visual", width=30)
|
|
80
|
+
|
|
81
|
+
# Baseline costs
|
|
82
|
+
baseline_bar = self._create_progress_bar(report.baseline_percentage, color="blue")
|
|
83
|
+
table.add_row(
|
|
84
|
+
'💰 Baseline ("Dial Tone")',
|
|
85
|
+
f"${report.baseline_costs.total:,.2f}",
|
|
86
|
+
f"{report.baseline_percentage:.1f}%",
|
|
87
|
+
baseline_bar,
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
# Non-baseline costs
|
|
91
|
+
non_baseline_bar = self._create_progress_bar(report.non_baseline_percentage, color="yellow")
|
|
92
|
+
table.add_row(
|
|
93
|
+
"📊 Non-Baseline (Projects)",
|
|
94
|
+
f"${report.non_baseline_costs.total:,.2f}",
|
|
95
|
+
f"{report.non_baseline_percentage:.1f}%",
|
|
96
|
+
non_baseline_bar,
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
# Separator
|
|
100
|
+
table.add_row("━" * 25, "━" * 15, "━" * 12, "━" * 30, style="dim")
|
|
101
|
+
|
|
102
|
+
# Total
|
|
103
|
+
table.add_row("[bold]Total", f"[bold]${report.total_cost:,.2f}", "[bold]100.0%", "")
|
|
104
|
+
|
|
105
|
+
self.console.print(table)
|
|
106
|
+
|
|
107
|
+
def _display_service_breakdown(self, report: CostReport, has_deltas: bool = False) -> None:
|
|
108
|
+
"""Display service-level cost breakdown."""
|
|
109
|
+
# Get top services
|
|
110
|
+
top_baseline = report.get_top_services(limit=10, baseline=True)
|
|
111
|
+
|
|
112
|
+
if top_baseline:
|
|
113
|
+
title = "Costs by Service" if not has_deltas else "Top Baseline Services"
|
|
114
|
+
self.console.print(f"[bold cyan]{title}:[/bold cyan]")
|
|
115
|
+
baseline_table = Table(show_header=True, box=None, padding=(0, 2))
|
|
116
|
+
baseline_table.add_column("Service", style="white")
|
|
117
|
+
baseline_table.add_column("Cost", justify="right", style="green")
|
|
118
|
+
baseline_table.add_column("% of Total", justify="right", style="dim")
|
|
119
|
+
|
|
120
|
+
for service, cost in top_baseline.items():
|
|
121
|
+
pct = (cost / report.baseline_costs.total * 100) if report.baseline_costs.total > 0 else 0
|
|
122
|
+
baseline_table.add_row(self._shorten_service_name(service), f"${cost:,.2f}", f"{pct:.1f}%")
|
|
123
|
+
|
|
124
|
+
self.console.print(baseline_table)
|
|
125
|
+
self.console.print()
|
|
126
|
+
|
|
127
|
+
# Only show non-baseline section if there are actual deltas
|
|
128
|
+
if has_deltas:
|
|
129
|
+
top_non_baseline = report.get_top_services(limit=5, baseline=False)
|
|
130
|
+
if top_non_baseline:
|
|
131
|
+
self.console.print("[bold yellow]Top Non-Baseline Services:[/bold yellow]")
|
|
132
|
+
non_baseline_table = Table(show_header=True, box=None, padding=(0, 2))
|
|
133
|
+
non_baseline_table.add_column("Service", style="white")
|
|
134
|
+
non_baseline_table.add_column("Cost", justify="right", style="green")
|
|
135
|
+
non_baseline_table.add_column("% of Non-Baseline", justify="right", style="dim")
|
|
136
|
+
|
|
137
|
+
for service, cost in top_non_baseline.items():
|
|
138
|
+
pct = (cost / report.non_baseline_costs.total * 100) if report.non_baseline_costs.total > 0 else 0
|
|
139
|
+
non_baseline_table.add_row(self._shorten_service_name(service), f"${cost:,.2f}", f"{pct:.1f}%")
|
|
140
|
+
|
|
141
|
+
self.console.print(non_baseline_table)
|
|
142
|
+
|
|
143
|
+
def _create_progress_bar(self, percentage: float, color: str = "green") -> str:
|
|
144
|
+
"""Create a text-based progress bar.
|
|
145
|
+
|
|
146
|
+
Args:
|
|
147
|
+
percentage: Percentage value (0-100)
|
|
148
|
+
color: Color for the bar
|
|
149
|
+
|
|
150
|
+
Returns:
|
|
151
|
+
Formatted progress bar string
|
|
152
|
+
"""
|
|
153
|
+
width = 20
|
|
154
|
+
filled = int((percentage / 100) * width)
|
|
155
|
+
bar = "█" * filled + "░" * (width - filled)
|
|
156
|
+
return f"[{color}]{bar}[/{color}]"
|
|
157
|
+
|
|
158
|
+
def _shorten_service_name(self, service_name: str) -> str:
|
|
159
|
+
"""Shorten AWS service names for display.
|
|
160
|
+
|
|
161
|
+
Args:
|
|
162
|
+
service_name: Full AWS service name
|
|
163
|
+
|
|
164
|
+
Returns:
|
|
165
|
+
Shortened service name
|
|
166
|
+
"""
|
|
167
|
+
# Common abbreviations
|
|
168
|
+
replacements = {
|
|
169
|
+
"Amazon Elastic Compute Cloud - Compute": "EC2",
|
|
170
|
+
"Amazon Simple Storage Service": "S3",
|
|
171
|
+
"AWS Lambda": "Lambda",
|
|
172
|
+
"Amazon Relational Database Service": "RDS",
|
|
173
|
+
"AWS Identity and Access Management": "IAM",
|
|
174
|
+
"Amazon Virtual Private Cloud": "VPC",
|
|
175
|
+
"Amazon CloudWatch": "CloudWatch",
|
|
176
|
+
"Amazon Simple Notification Service": "SNS",
|
|
177
|
+
"Amazon Simple Queue Service": "SQS",
|
|
178
|
+
"Amazon DynamoDB": "DynamoDB",
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
return replacements.get(service_name, service_name)
|
|
182
|
+
|
|
183
|
+
def export_json(self, report: CostReport, filepath: str) -> None:
|
|
184
|
+
"""Export cost report to JSON file.
|
|
185
|
+
|
|
186
|
+
Args:
|
|
187
|
+
report: CostReport to export
|
|
188
|
+
filepath: Destination file path
|
|
189
|
+
"""
|
|
190
|
+
from ..utils.export import export_to_json
|
|
191
|
+
|
|
192
|
+
export_to_json(report.to_dict(), filepath)
|
|
193
|
+
self.console.print(f"[green]✓ Cost report exported to {filepath}[/green]")
|
|
194
|
+
|
|
195
|
+
def export_csv(self, report: CostReport, filepath: str) -> None:
|
|
196
|
+
"""Export cost report to CSV file.
|
|
197
|
+
|
|
198
|
+
Args:
|
|
199
|
+
report: CostReport to export
|
|
200
|
+
filepath: Destination file path
|
|
201
|
+
"""
|
|
202
|
+
from ..utils.export import export_to_csv
|
|
203
|
+
|
|
204
|
+
# Flatten into rows - one row per service
|
|
205
|
+
rows = []
|
|
206
|
+
|
|
207
|
+
# Baseline services
|
|
208
|
+
for service, cost in report.baseline_costs.by_service.items():
|
|
209
|
+
pct = (cost / report.baseline_costs.total * 100) if report.baseline_costs.total > 0 else 0
|
|
210
|
+
rows.append(
|
|
211
|
+
{
|
|
212
|
+
"category": "baseline",
|
|
213
|
+
"service": service,
|
|
214
|
+
"cost": cost,
|
|
215
|
+
"percentage_of_category": pct,
|
|
216
|
+
"percentage_of_total": (cost / report.total_cost * 100) if report.total_cost > 0 else 0,
|
|
217
|
+
}
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
# Non-baseline services
|
|
221
|
+
for service, cost in report.non_baseline_costs.by_service.items():
|
|
222
|
+
pct = (cost / report.non_baseline_costs.total * 100) if report.non_baseline_costs.total > 0 else 0
|
|
223
|
+
rows.append(
|
|
224
|
+
{
|
|
225
|
+
"category": "non_baseline",
|
|
226
|
+
"service": service,
|
|
227
|
+
"cost": cost,
|
|
228
|
+
"percentage_of_category": pct,
|
|
229
|
+
"percentage_of_total": (cost / report.total_cost * 100) if report.total_cost > 0 else 0,
|
|
230
|
+
}
|
|
231
|
+
)
|
|
232
|
+
|
|
233
|
+
if rows:
|
|
234
|
+
export_to_csv(rows, filepath)
|
|
235
|
+
self.console.print(f"[green]✓ Cost report exported to {filepath}[/green]")
|
|
236
|
+
else:
|
|
237
|
+
self.console.print("[yellow]⚠ No cost data to export[/yellow]")
|
src/delta/__init__.py
ADDED
src/delta/calculator.py
ADDED
|
@@ -0,0 +1,206 @@
|
|
|
1
|
+
"""Delta calculator for comparing snapshots."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from datetime import datetime, timezone
|
|
5
|
+
from typing import List, Optional
|
|
6
|
+
|
|
7
|
+
from ..models.delta_report import DeltaReport, ResourceChange
|
|
8
|
+
from ..models.resource import Resource
|
|
9
|
+
from ..models.snapshot import Snapshot
|
|
10
|
+
|
|
11
|
+
logger = logging.getLogger(__name__)
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class DeltaCalculator:
|
|
15
|
+
"""Calculate differences between two snapshots."""
|
|
16
|
+
|
|
17
|
+
def __init__(self, reference_snapshot: Snapshot, current_snapshot: Snapshot):
|
|
18
|
+
"""Initialize delta calculator.
|
|
19
|
+
|
|
20
|
+
Args:
|
|
21
|
+
reference_snapshot: The reference snapshot to compare against
|
|
22
|
+
current_snapshot: The current snapshot
|
|
23
|
+
"""
|
|
24
|
+
self.reference = reference_snapshot
|
|
25
|
+
self.current = current_snapshot
|
|
26
|
+
|
|
27
|
+
# Index resources by ARN for fast lookup
|
|
28
|
+
self.reference_index = {r.arn: r for r in reference_snapshot.resources}
|
|
29
|
+
self.current_index = {r.arn: r for r in current_snapshot.resources}
|
|
30
|
+
|
|
31
|
+
def calculate(
|
|
32
|
+
self,
|
|
33
|
+
resource_type_filter: Optional[List[str]] = None,
|
|
34
|
+
region_filter: Optional[List[str]] = None,
|
|
35
|
+
include_drift_details: bool = False,
|
|
36
|
+
) -> DeltaReport:
|
|
37
|
+
"""Calculate delta between reference and current snapshots.
|
|
38
|
+
|
|
39
|
+
Args:
|
|
40
|
+
resource_type_filter: Optional list of resource types to include
|
|
41
|
+
region_filter: Optional list of regions to include
|
|
42
|
+
include_drift_details: Whether to calculate field-level configuration diffs
|
|
43
|
+
|
|
44
|
+
Returns:
|
|
45
|
+
DeltaReport with added, deleted, and modified resources
|
|
46
|
+
"""
|
|
47
|
+
logger.info("Calculating delta between reference and current snapshots")
|
|
48
|
+
|
|
49
|
+
added_resources = []
|
|
50
|
+
deleted_resources = []
|
|
51
|
+
modified_resources = []
|
|
52
|
+
|
|
53
|
+
reference_arns = set(self.reference_index.keys())
|
|
54
|
+
current_arns = set(self.current_index.keys())
|
|
55
|
+
|
|
56
|
+
# Find added resources (in current but not in reference)
|
|
57
|
+
added_arns = current_arns - reference_arns
|
|
58
|
+
for arn in added_arns:
|
|
59
|
+
resource = self.current_index[arn]
|
|
60
|
+
if self._matches_filters(resource, resource_type_filter, region_filter):
|
|
61
|
+
added_resources.append(resource)
|
|
62
|
+
|
|
63
|
+
# Find deleted resources (in reference but not in current)
|
|
64
|
+
deleted_arns = reference_arns - current_arns
|
|
65
|
+
for arn in deleted_arns:
|
|
66
|
+
resource = self.reference_index[arn]
|
|
67
|
+
if self._matches_filters(resource, resource_type_filter, region_filter):
|
|
68
|
+
deleted_resources.append(resource)
|
|
69
|
+
|
|
70
|
+
# Find modified resources (in both but with different config)
|
|
71
|
+
common_arns = reference_arns & current_arns
|
|
72
|
+
for arn in common_arns:
|
|
73
|
+
reference_resource = self.reference_index[arn]
|
|
74
|
+
current_resource = self.current_index[arn]
|
|
75
|
+
|
|
76
|
+
if self._matches_filters(current_resource, resource_type_filter, region_filter):
|
|
77
|
+
# Compare config hashes to detect modifications
|
|
78
|
+
if reference_resource.config_hash != current_resource.config_hash:
|
|
79
|
+
change = ResourceChange(
|
|
80
|
+
resource=current_resource,
|
|
81
|
+
baseline_resource=reference_resource,
|
|
82
|
+
change_type="modified",
|
|
83
|
+
old_config_hash=reference_resource.config_hash,
|
|
84
|
+
new_config_hash=current_resource.config_hash,
|
|
85
|
+
)
|
|
86
|
+
modified_resources.append(change)
|
|
87
|
+
|
|
88
|
+
# Calculate drift details if requested
|
|
89
|
+
drift_report = None
|
|
90
|
+
if include_drift_details and modified_resources:
|
|
91
|
+
from .differ import ConfigDiffer
|
|
92
|
+
from .models import DriftReport as ConfigDriftReport
|
|
93
|
+
|
|
94
|
+
drift_report = ConfigDriftReport()
|
|
95
|
+
differ = ConfigDiffer()
|
|
96
|
+
|
|
97
|
+
for change in modified_resources:
|
|
98
|
+
# Only calculate diffs if both resources have raw_config
|
|
99
|
+
if change.baseline_resource.raw_config and change.resource.raw_config:
|
|
100
|
+
diffs = differ.compare(
|
|
101
|
+
resource_arn=change.resource.arn,
|
|
102
|
+
old_config=change.baseline_resource.raw_config,
|
|
103
|
+
new_config=change.resource.raw_config,
|
|
104
|
+
)
|
|
105
|
+
for diff in diffs:
|
|
106
|
+
drift_report.add_diff(diff)
|
|
107
|
+
|
|
108
|
+
# Create delta report
|
|
109
|
+
report = DeltaReport(
|
|
110
|
+
generated_at=datetime.now(timezone.utc),
|
|
111
|
+
baseline_snapshot_name=self.reference.name,
|
|
112
|
+
current_snapshot_name=self.current.name,
|
|
113
|
+
added_resources=added_resources,
|
|
114
|
+
deleted_resources=deleted_resources,
|
|
115
|
+
modified_resources=modified_resources,
|
|
116
|
+
baseline_resource_count=len(self.reference.resources),
|
|
117
|
+
current_resource_count=len(self.current.resources),
|
|
118
|
+
drift_report=drift_report,
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
logger.info(
|
|
122
|
+
f"Delta calculated: {len(added_resources)} added, "
|
|
123
|
+
f"{len(deleted_resources)} deleted, {len(modified_resources)} modified"
|
|
124
|
+
)
|
|
125
|
+
|
|
126
|
+
return report
|
|
127
|
+
|
|
128
|
+
def _matches_filters(
|
|
129
|
+
self,
|
|
130
|
+
resource: Resource,
|
|
131
|
+
resource_type_filter: Optional[List[str]],
|
|
132
|
+
region_filter: Optional[List[str]],
|
|
133
|
+
) -> bool:
|
|
134
|
+
"""Check if resource matches the specified filters.
|
|
135
|
+
|
|
136
|
+
Args:
|
|
137
|
+
resource: Resource to check
|
|
138
|
+
resource_type_filter: Optional list of resource types to include
|
|
139
|
+
region_filter: Optional list of regions to include
|
|
140
|
+
|
|
141
|
+
Returns:
|
|
142
|
+
True if resource matches all filters
|
|
143
|
+
"""
|
|
144
|
+
# Check resource type filter
|
|
145
|
+
if resource_type_filter:
|
|
146
|
+
if resource.resource_type not in resource_type_filter:
|
|
147
|
+
return False
|
|
148
|
+
|
|
149
|
+
# Check region filter
|
|
150
|
+
if region_filter:
|
|
151
|
+
if resource.region not in region_filter:
|
|
152
|
+
return False
|
|
153
|
+
|
|
154
|
+
return True
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
def compare_to_current_state(
|
|
158
|
+
reference_snapshot: Snapshot,
|
|
159
|
+
profile_name: Optional[str] = None,
|
|
160
|
+
regions: Optional[List[str]] = None,
|
|
161
|
+
resource_type_filter: Optional[List[str]] = None,
|
|
162
|
+
region_filter: Optional[List[str]] = None,
|
|
163
|
+
include_drift_details: bool = False,
|
|
164
|
+
) -> DeltaReport:
|
|
165
|
+
"""Compare reference snapshot to current AWS state.
|
|
166
|
+
|
|
167
|
+
This is a convenience function that captures current state and calculates delta.
|
|
168
|
+
|
|
169
|
+
Args:
|
|
170
|
+
reference_snapshot: The reference snapshot to compare against
|
|
171
|
+
profile_name: AWS profile name (optional)
|
|
172
|
+
regions: Regions to scan (defaults to reference snapshot regions)
|
|
173
|
+
resource_type_filter: Optional list of resource types to include in delta
|
|
174
|
+
region_filter: Optional list of regions to include in delta
|
|
175
|
+
include_drift_details: Whether to calculate field-level configuration diffs
|
|
176
|
+
|
|
177
|
+
Returns:
|
|
178
|
+
DeltaReport with changes
|
|
179
|
+
"""
|
|
180
|
+
from ..aws.credentials import get_account_id
|
|
181
|
+
from ..snapshot.capturer import create_snapshot
|
|
182
|
+
|
|
183
|
+
# Use reference snapshot regions if not specified
|
|
184
|
+
if not regions:
|
|
185
|
+
regions = reference_snapshot.regions
|
|
186
|
+
|
|
187
|
+
# Get account ID
|
|
188
|
+
account_id = get_account_id(profile_name)
|
|
189
|
+
|
|
190
|
+
# Capture current state (don't save it, just use for comparison)
|
|
191
|
+
logger.info("Capturing current AWS state for comparison...")
|
|
192
|
+
current_snapshot = create_snapshot(
|
|
193
|
+
name=f"temp-current-{datetime.now(timezone.utc).strftime('%Y%m%d-%H%M%S')}",
|
|
194
|
+
regions=regions,
|
|
195
|
+
account_id=account_id,
|
|
196
|
+
profile_name=profile_name,
|
|
197
|
+
set_active=False,
|
|
198
|
+
)
|
|
199
|
+
|
|
200
|
+
# Calculate delta
|
|
201
|
+
calculator = DeltaCalculator(reference_snapshot, current_snapshot)
|
|
202
|
+
return calculator.calculate(
|
|
203
|
+
resource_type_filter=resource_type_filter,
|
|
204
|
+
region_filter=region_filter,
|
|
205
|
+
include_drift_details=include_drift_details,
|
|
206
|
+
)
|