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/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
@@ -0,0 +1,5 @@
1
+ """Delta tracking module for comparing snapshots to current state."""
2
+
3
+ from typing import List
4
+
5
+ __all__: List[str] = []
@@ -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
+ )