aws-inventory-manager 0.2.0__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.2.0.dist-info/METADATA +508 -0
- aws_inventory_manager-0.2.0.dist-info/RECORD +65 -0
- aws_inventory_manager-0.2.0.dist-info/WHEEL +5 -0
- aws_inventory_manager-0.2.0.dist-info/entry_points.txt +2 -0
- aws_inventory_manager-0.2.0.dist-info/licenses/LICENSE +21 -0
- aws_inventory_manager-0.2.0.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 +5 -0
- src/cli/config.py +130 -0
- src/cli/main.py +1450 -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 +180 -0
- src/delta/reporter.py +225 -0
- src/models/__init__.py +17 -0
- src/models/cost_report.py +87 -0
- src/models/delta_report.py +111 -0
- src/models/inventory.py +124 -0
- src/models/resource.py +99 -0
- src/models/snapshot.py +108 -0
- src/snapshot/__init__.py +6 -0
- src/snapshot/capturer.py +347 -0
- src/snapshot/filter.py +245 -0
- src/snapshot/inventory_storage.py +264 -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/eks.py +200 -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 +112 -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 +72 -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 +259 -0
- src/utils/__init__.py +12 -0
- src/utils/export.py +87 -0
- src/utils/hash.py +60 -0
- src/utils/logging.py +63 -0
- src/utils/paths.py +51 -0
- src/utils/progress.py +41 -0
src/cost/__init__.py
ADDED
src/cost/analyzer.py
ADDED
|
@@ -0,0 +1,226 @@
|
|
|
1
|
+
"""Cost analyzer for inventory snapshots."""
|
|
2
|
+
|
|
3
|
+
import logging
|
|
4
|
+
from concurrent.futures import ThreadPoolExecutor
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from typing import Any, Dict, Optional, Set
|
|
7
|
+
|
|
8
|
+
from ..models.cost_report import CostBreakdown, CostReport
|
|
9
|
+
from ..models.snapshot import Snapshot
|
|
10
|
+
from .explorer import CostExplorerClient
|
|
11
|
+
|
|
12
|
+
logger = logging.getLogger(__name__)
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class CostAnalyzer:
|
|
16
|
+
"""Analyze costs for inventory snapshots."""
|
|
17
|
+
|
|
18
|
+
def __init__(self, cost_explorer: CostExplorerClient):
|
|
19
|
+
"""Initialize cost analyzer.
|
|
20
|
+
|
|
21
|
+
Args:
|
|
22
|
+
cost_explorer: Cost Explorer client instance
|
|
23
|
+
"""
|
|
24
|
+
self.cost_explorer = cost_explorer
|
|
25
|
+
|
|
26
|
+
def analyze(
|
|
27
|
+
self,
|
|
28
|
+
snapshot: Snapshot,
|
|
29
|
+
start_date: Optional[datetime] = None,
|
|
30
|
+
end_date: Optional[datetime] = None,
|
|
31
|
+
granularity: str = "MONTHLY",
|
|
32
|
+
has_deltas: bool = False,
|
|
33
|
+
delta_report: Optional[Any] = None,
|
|
34
|
+
) -> CostReport:
|
|
35
|
+
"""Analyze costs and separate snapshot resources.
|
|
36
|
+
|
|
37
|
+
This implementation uses a simplified heuristic approach:
|
|
38
|
+
1. Get total costs by service
|
|
39
|
+
2. Estimate baseline portion based on resource counts in snapshot
|
|
40
|
+
3. Remaining costs are attributed to non-baseline resources
|
|
41
|
+
|
|
42
|
+
Note: For precise cost attribution, AWS would need to provide
|
|
43
|
+
per-resource cost data, which Cost Explorer doesn't directly expose.
|
|
44
|
+
This gives a good approximation based on service-level costs.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
snapshot: The baseline snapshot
|
|
48
|
+
start_date: Start date for cost analysis (default: snapshot date)
|
|
49
|
+
end_date: End date for cost analysis (default: today)
|
|
50
|
+
granularity: Cost granularity - DAILY or MONTHLY
|
|
51
|
+
|
|
52
|
+
Returns:
|
|
53
|
+
CostReport with baseline and non-baseline cost breakdown
|
|
54
|
+
"""
|
|
55
|
+
# Default date range: from snapshot creation to today
|
|
56
|
+
if not start_date:
|
|
57
|
+
start_date = snapshot.created_at
|
|
58
|
+
# Remove timezone for Cost Explorer API (uses dates only, no time)
|
|
59
|
+
start_date = start_date.replace(tzinfo=None)
|
|
60
|
+
|
|
61
|
+
if not end_date:
|
|
62
|
+
end_date = datetime.now()
|
|
63
|
+
|
|
64
|
+
# Ensure both dates are timezone-naive for comparison
|
|
65
|
+
if hasattr(start_date, "tzinfo") and start_date.tzinfo is not None:
|
|
66
|
+
start_date = start_date.replace(tzinfo=None)
|
|
67
|
+
if hasattr(end_date, "tzinfo") and end_date.tzinfo is not None:
|
|
68
|
+
end_date = end_date.replace(tzinfo=None)
|
|
69
|
+
|
|
70
|
+
# Ensure start_date is before end_date
|
|
71
|
+
# AWS Cost Explorer requires at least 1 day difference
|
|
72
|
+
if start_date >= end_date:
|
|
73
|
+
# If dates are the same or inverted, set end_date to start_date + 1 day
|
|
74
|
+
end_date = start_date + timedelta(days=1)
|
|
75
|
+
|
|
76
|
+
logger.debug(f"Analyzing costs from {start_date.strftime('%Y-%m-%d')} " f"to {end_date.strftime('%Y-%m-%d')}")
|
|
77
|
+
|
|
78
|
+
# Execute data completeness check and cost retrieval in parallel
|
|
79
|
+
with ThreadPoolExecutor(max_workers=2) as executor:
|
|
80
|
+
# Submit both tasks
|
|
81
|
+
completeness_future = executor.submit(self.cost_explorer.check_data_completeness, end_date)
|
|
82
|
+
costs_future = executor.submit(self.cost_explorer.get_costs_by_service, start_date, end_date, granularity)
|
|
83
|
+
|
|
84
|
+
# Wait for both to complete
|
|
85
|
+
is_complete, data_through, lag_days = completeness_future.result()
|
|
86
|
+
service_costs = costs_future.result()
|
|
87
|
+
|
|
88
|
+
# If no deltas (no resource changes), ALL costs are baseline
|
|
89
|
+
if not has_deltas:
|
|
90
|
+
logger.debug("No resource changes detected - all costs are from snapshot resources")
|
|
91
|
+
baseline_costs = service_costs.copy()
|
|
92
|
+
non_baseline_costs: Dict[str, float] = {}
|
|
93
|
+
baseline_total = sum(baseline_costs.values())
|
|
94
|
+
non_baseline_total = 0.0
|
|
95
|
+
total_cost = baseline_total
|
|
96
|
+
baseline_pct = 100.0
|
|
97
|
+
non_baseline_pct = 0.0
|
|
98
|
+
else:
|
|
99
|
+
# There are deltas - we can't accurately split costs without per-resource pricing
|
|
100
|
+
# Show total only with a note that we can't split accurately
|
|
101
|
+
logger.debug("Resource changes detected - showing total costs only")
|
|
102
|
+
baseline_costs = service_costs.copy()
|
|
103
|
+
non_baseline_costs = {}
|
|
104
|
+
baseline_total = sum(baseline_costs.values())
|
|
105
|
+
non_baseline_total = 0.0
|
|
106
|
+
total_cost = baseline_total
|
|
107
|
+
baseline_pct = 100.0
|
|
108
|
+
non_baseline_pct = 0.0
|
|
109
|
+
# Note: We could enhance this in the future to track specific resource costs
|
|
110
|
+
# For now, we show total and list the delta resources separately
|
|
111
|
+
|
|
112
|
+
# Create cost breakdowns
|
|
113
|
+
baseline_breakdown = CostBreakdown(
|
|
114
|
+
total=baseline_total,
|
|
115
|
+
by_service=baseline_costs,
|
|
116
|
+
percentage=baseline_pct,
|
|
117
|
+
)
|
|
118
|
+
|
|
119
|
+
non_baseline_breakdown = CostBreakdown(
|
|
120
|
+
total=non_baseline_total,
|
|
121
|
+
by_service=non_baseline_costs,
|
|
122
|
+
percentage=non_baseline_pct,
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
# Create cost report
|
|
126
|
+
report = CostReport(
|
|
127
|
+
generated_at=datetime.now(),
|
|
128
|
+
baseline_snapshot_name=snapshot.name,
|
|
129
|
+
period_start=start_date,
|
|
130
|
+
period_end=end_date,
|
|
131
|
+
baseline_costs=baseline_breakdown,
|
|
132
|
+
non_baseline_costs=non_baseline_breakdown,
|
|
133
|
+
total_cost=total_cost,
|
|
134
|
+
data_complete=is_complete,
|
|
135
|
+
data_through=data_through,
|
|
136
|
+
lag_days=lag_days,
|
|
137
|
+
)
|
|
138
|
+
|
|
139
|
+
logger.info(
|
|
140
|
+
f"Cost analysis complete: Baseline=${baseline_total:.2f}, "
|
|
141
|
+
f"Non-baseline=${non_baseline_total:.2f}, Total=${total_cost:.2f}"
|
|
142
|
+
)
|
|
143
|
+
|
|
144
|
+
return report
|
|
145
|
+
|
|
146
|
+
def _get_baseline_service_mapping(self, snapshot: Snapshot) -> Set[str]:
|
|
147
|
+
"""Get set of AWS service names that have baseline resources.
|
|
148
|
+
|
|
149
|
+
Maps our resource types (e.g., 'AWS::EC2::Instance') to Cost Explorer
|
|
150
|
+
service names (e.g., 'Amazon Elastic Compute Cloud - Compute').
|
|
151
|
+
|
|
152
|
+
Args:
|
|
153
|
+
snapshot: Baseline snapshot
|
|
154
|
+
|
|
155
|
+
Returns:
|
|
156
|
+
Set of AWS service names from Cost Explorer
|
|
157
|
+
"""
|
|
158
|
+
# Mapping from our resource types to Cost Explorer service names
|
|
159
|
+
service_name_map = {
|
|
160
|
+
"AWS::EC2::Instance": "Amazon Elastic Compute Cloud - Compute",
|
|
161
|
+
"AWS::EC2::Volume": "Amazon Elastic Compute Cloud - Compute",
|
|
162
|
+
"AWS::EC2::VPC": "Amazon Elastic Compute Cloud - Compute",
|
|
163
|
+
"AWS::EC2::SecurityGroup": "Amazon Elastic Compute Cloud - Compute",
|
|
164
|
+
"AWS::EC2::Subnet": "Amazon Elastic Compute Cloud - Compute",
|
|
165
|
+
"AWS::EC2::VPCEndpoint::Interface": "Amazon Elastic Compute Cloud - Compute",
|
|
166
|
+
"AWS::EC2::VPCEndpoint::Gateway": "Amazon Elastic Compute Cloud - Compute",
|
|
167
|
+
"AWS::Lambda::Function": "AWS Lambda",
|
|
168
|
+
"AWS::Lambda::LayerVersion": "AWS Lambda",
|
|
169
|
+
"AWS::S3::Bucket": "Amazon Simple Storage Service",
|
|
170
|
+
"AWS::RDS::DBInstance": "Amazon Relational Database Service",
|
|
171
|
+
"AWS::RDS::DBCluster": "Amazon Relational Database Service",
|
|
172
|
+
"AWS::IAM::Role": "AWS Identity and Access Management",
|
|
173
|
+
"AWS::IAM::User": "AWS Identity and Access Management",
|
|
174
|
+
"AWS::IAM::Policy": "AWS Identity and Access Management",
|
|
175
|
+
"AWS::IAM::Group": "AWS Identity and Access Management",
|
|
176
|
+
"AWS::CloudWatch::Alarm": "Amazon CloudWatch",
|
|
177
|
+
"AWS::CloudWatch::CompositeAlarm": "Amazon CloudWatch",
|
|
178
|
+
"AWS::Logs::LogGroup": "Amazon CloudWatch",
|
|
179
|
+
"AWS::SNS::Topic": "Amazon Simple Notification Service",
|
|
180
|
+
"AWS::SQS::Queue": "Amazon Simple Queue Service",
|
|
181
|
+
"AWS::DynamoDB::Table": "Amazon DynamoDB",
|
|
182
|
+
"AWS::ElasticLoadBalancing::LoadBalancer": "Elastic Load Balancing",
|
|
183
|
+
"AWS::ElasticLoadBalancingV2::LoadBalancer::Application": "Elastic Load Balancing",
|
|
184
|
+
"AWS::ElasticLoadBalancingV2::LoadBalancer::Network": "Elastic Load Balancing",
|
|
185
|
+
"AWS::ElasticLoadBalancingV2::LoadBalancer::Gateway": "Elastic Load Balancing",
|
|
186
|
+
"AWS::CloudFormation::Stack": "AWS CloudFormation",
|
|
187
|
+
"AWS::ApiGateway::RestApi": "Amazon API Gateway",
|
|
188
|
+
"AWS::ApiGatewayV2::Api::HTTP": "Amazon API Gateway",
|
|
189
|
+
"AWS::ApiGatewayV2::Api::WebSocket": "Amazon API Gateway",
|
|
190
|
+
"AWS::Events::EventBus": "Amazon EventBridge",
|
|
191
|
+
"AWS::Events::Rule": "Amazon EventBridge",
|
|
192
|
+
"AWS::SecretsManager::Secret": "AWS Secrets Manager",
|
|
193
|
+
"AWS::KMS::Key": "AWS Key Management Service",
|
|
194
|
+
"AWS::SSM::Parameter": "AWS Systems Manager",
|
|
195
|
+
"AWS::SSM::Document": "AWS Systems Manager",
|
|
196
|
+
"AWS::Route53::HostedZone": "Amazon Route 53",
|
|
197
|
+
"AWS::ECS::Cluster": "Amazon EC2 Container Service",
|
|
198
|
+
"AWS::ECS::Service": "Amazon EC2 Container Service",
|
|
199
|
+
"AWS::ECS::TaskDefinition": "Amazon EC2 Container Service",
|
|
200
|
+
"AWS::StepFunctions::StateMachine": "AWS Step Functions",
|
|
201
|
+
"AWS::WAFv2::WebACL::Regional": "AWS WAF",
|
|
202
|
+
"AWS::WAFv2::WebACL::CloudFront": "AWS WAF",
|
|
203
|
+
"AWS::EKS::Cluster": "Amazon Elastic Kubernetes Service",
|
|
204
|
+
"AWS::EKS::Nodegroup": "Amazon Elastic Kubernetes Service",
|
|
205
|
+
"AWS::EKS::FargateProfile": "Amazon Elastic Kubernetes Service",
|
|
206
|
+
"AWS::CodePipeline::Pipeline": "AWS CodePipeline",
|
|
207
|
+
"AWS::CodeBuild::Project": "AWS CodeBuild",
|
|
208
|
+
"AWS::Backup::BackupPlan": "AWS Backup",
|
|
209
|
+
"AWS::Backup::BackupVault": "AWS Backup",
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
baseline_services = set()
|
|
213
|
+
|
|
214
|
+
# Get unique resource types from snapshot
|
|
215
|
+
resource_types = set()
|
|
216
|
+
for resource in snapshot.resources:
|
|
217
|
+
resource_types.add(resource.resource_type)
|
|
218
|
+
|
|
219
|
+
# Map to Cost Explorer service names
|
|
220
|
+
for resource_type in resource_types:
|
|
221
|
+
if resource_type in service_name_map:
|
|
222
|
+
baseline_services.add(service_name_map[resource_type])
|
|
223
|
+
|
|
224
|
+
logger.debug(f"Baseline services: {baseline_services}")
|
|
225
|
+
|
|
226
|
+
return baseline_services
|
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]")
|