aws-cost-calculator-cli 1.6.0__py3-none-any.whl → 1.9.1__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-cost-calculator-cli might be problematic. Click here for more details.
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/METADATA +154 -23
- aws_cost_calculator_cli-1.9.1.dist-info/RECORD +15 -0
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/WHEEL +1 -1
- cost_calculator/api_client.py +2 -1
- cost_calculator/cli.py +392 -10
- cost_calculator/cur.py +244 -0
- cost_calculator/executor.py +59 -92
- cost_calculator/forensics.py +323 -0
- aws_cost_calculator_cli-1.6.0.dist-info/RECORD +0 -13
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/licenses/LICENSE +0 -0
- {aws_cost_calculator_cli-1.6.0.dist-info → aws_cost_calculator_cli-1.9.1.dist-info}/top_level.txt +0 -0
cost_calculator/cur.py
ADDED
|
@@ -0,0 +1,244 @@
|
|
|
1
|
+
"""
|
|
2
|
+
CUR (Cost and Usage Report) queries via Athena for resource-level analysis.
|
|
3
|
+
"""
|
|
4
|
+
import time
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
# Service name to CUR product code mapping
|
|
9
|
+
SERVICE_TO_PRODUCT_CODE = {
|
|
10
|
+
'EC2 - Other': 'AmazonEC2',
|
|
11
|
+
'Amazon Elastic Compute Cloud - Compute': 'AmazonEC2',
|
|
12
|
+
'Amazon Relational Database Service': 'AmazonRDS',
|
|
13
|
+
'Amazon Simple Storage Service': 'AmazonS3',
|
|
14
|
+
'Load Balancing': 'AWSELB',
|
|
15
|
+
'Elastic Load Balancing': 'AWSELB',
|
|
16
|
+
'Amazon DynamoDB': 'AmazonDynamoDB',
|
|
17
|
+
'AWS Lambda': 'AWSLambda',
|
|
18
|
+
'Amazon CloudFront': 'AmazonCloudFront',
|
|
19
|
+
'Amazon ElastiCache': 'AmazonElastiCache',
|
|
20
|
+
'Amazon Elastic MapReduce': 'ElasticMapReduce',
|
|
21
|
+
'Amazon Kinesis': 'AmazonKinesis',
|
|
22
|
+
'Amazon Redshift': 'AmazonRedshift',
|
|
23
|
+
'Amazon Simple Notification Service': 'AmazonSNS',
|
|
24
|
+
'Amazon Simple Queue Service': 'AmazonSQS',
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
def map_service_to_product_code(service_name):
|
|
29
|
+
"""Map service name to CUR product code"""
|
|
30
|
+
# Direct mapping
|
|
31
|
+
if service_name in SERVICE_TO_PRODUCT_CODE:
|
|
32
|
+
return SERVICE_TO_PRODUCT_CODE[service_name]
|
|
33
|
+
|
|
34
|
+
# Fuzzy matching
|
|
35
|
+
service_lower = service_name.lower()
|
|
36
|
+
for key, code in SERVICE_TO_PRODUCT_CODE.items():
|
|
37
|
+
if key.lower() in service_lower or service_lower in key.lower():
|
|
38
|
+
return code
|
|
39
|
+
|
|
40
|
+
# Fallback: try to extract from service name
|
|
41
|
+
# "Amazon Relational Database Service" -> "AmazonRDS"
|
|
42
|
+
return service_name.replace(' ', '').replace('-', '')
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
def get_cur_config():
|
|
46
|
+
"""Load CUR configuration from config file or environment variables"""
|
|
47
|
+
import os
|
|
48
|
+
from pathlib import Path
|
|
49
|
+
import json
|
|
50
|
+
|
|
51
|
+
# Try config file first
|
|
52
|
+
config_file = Path.home() / '.config' / 'cost-calculator' / 'cur_config.json'
|
|
53
|
+
if config_file.exists():
|
|
54
|
+
with open(config_file) as f:
|
|
55
|
+
return json.load(f)
|
|
56
|
+
|
|
57
|
+
# Fall back to environment variables
|
|
58
|
+
return {
|
|
59
|
+
'database': os.environ.get('CUR_DATABASE', 'cur_database'),
|
|
60
|
+
'table': os.environ.get('CUR_TABLE', 'cur_table'),
|
|
61
|
+
's3_output': os.environ.get('CUR_S3_OUTPUT', 's3://your-athena-results-bucket/')
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def query_cur_resources(athena_client, accounts, service, account_filter, weeks,
|
|
66
|
+
cur_database=None,
|
|
67
|
+
cur_table=None,
|
|
68
|
+
s3_output=None):
|
|
69
|
+
"""
|
|
70
|
+
Query CUR via Athena for resource-level cost details.
|
|
71
|
+
|
|
72
|
+
Args:
|
|
73
|
+
athena_client: boto3 Athena client
|
|
74
|
+
accounts: list of account IDs
|
|
75
|
+
service: service name to filter by
|
|
76
|
+
account_filter: specific account ID or None for all accounts
|
|
77
|
+
weeks: number of weeks to analyze
|
|
78
|
+
cur_database: Athena database name
|
|
79
|
+
cur_table: CUR table name
|
|
80
|
+
s3_output: S3 location for query results
|
|
81
|
+
|
|
82
|
+
Returns:
|
|
83
|
+
list of dicts with resource details
|
|
84
|
+
"""
|
|
85
|
+
# Load CUR configuration
|
|
86
|
+
cur_config = get_cur_config()
|
|
87
|
+
if cur_database is None:
|
|
88
|
+
cur_database = cur_config['database']
|
|
89
|
+
if cur_table is None:
|
|
90
|
+
cur_table = cur_config['table']
|
|
91
|
+
if s3_output is None:
|
|
92
|
+
s3_output = cur_config['s3_output']
|
|
93
|
+
|
|
94
|
+
# Calculate date range
|
|
95
|
+
end_date = datetime.now() - timedelta(days=2)
|
|
96
|
+
start_date = end_date - timedelta(weeks=weeks)
|
|
97
|
+
|
|
98
|
+
# Map service to product code
|
|
99
|
+
product_code = map_service_to_product_code(service)
|
|
100
|
+
|
|
101
|
+
# Build account filter
|
|
102
|
+
if account_filter:
|
|
103
|
+
account_clause = f"AND line_item_usage_account_id = '{account_filter}'"
|
|
104
|
+
else:
|
|
105
|
+
account_list = "','".join(accounts)
|
|
106
|
+
account_clause = f"AND line_item_usage_account_id IN ('{account_list}')"
|
|
107
|
+
|
|
108
|
+
# Build query - use generic columns that work for all services
|
|
109
|
+
query = f"""
|
|
110
|
+
SELECT
|
|
111
|
+
line_item_usage_account_id as account_id,
|
|
112
|
+
line_item_resource_id as resource_id,
|
|
113
|
+
line_item_usage_type as usage_type,
|
|
114
|
+
product_region as region,
|
|
115
|
+
SUM(line_item_unblended_cost) as total_cost,
|
|
116
|
+
SUM(line_item_usage_amount) as total_usage
|
|
117
|
+
FROM {cur_database}.{cur_table}
|
|
118
|
+
WHERE line_item_product_code = '{product_code}'
|
|
119
|
+
{account_clause}
|
|
120
|
+
AND line_item_resource_id != ''
|
|
121
|
+
AND line_item_line_item_type IN ('Usage', 'Fee')
|
|
122
|
+
AND line_item_usage_start_date >= DATE '{start_date.strftime('%Y-%m-%d')}'
|
|
123
|
+
AND line_item_usage_start_date < DATE '{end_date.strftime('%Y-%m-%d')}'
|
|
124
|
+
GROUP BY 1, 2, 3, 4
|
|
125
|
+
ORDER BY total_cost DESC
|
|
126
|
+
LIMIT 50
|
|
127
|
+
"""
|
|
128
|
+
|
|
129
|
+
# Execute Athena query
|
|
130
|
+
try:
|
|
131
|
+
response = athena_client.start_query_execution(
|
|
132
|
+
QueryString=query,
|
|
133
|
+
QueryExecutionContext={'Database': cur_database},
|
|
134
|
+
ResultConfiguration={'OutputLocation': s3_output}
|
|
135
|
+
)
|
|
136
|
+
|
|
137
|
+
query_execution_id = response['QueryExecutionId']
|
|
138
|
+
|
|
139
|
+
# Wait for query to complete (max 60 seconds)
|
|
140
|
+
max_wait = 60
|
|
141
|
+
wait_interval = 2
|
|
142
|
+
elapsed = 0
|
|
143
|
+
|
|
144
|
+
while elapsed < max_wait:
|
|
145
|
+
status_response = athena_client.get_query_execution(
|
|
146
|
+
QueryExecutionId=query_execution_id
|
|
147
|
+
)
|
|
148
|
+
state = status_response['QueryExecution']['Status']['State']
|
|
149
|
+
|
|
150
|
+
if state == 'SUCCEEDED':
|
|
151
|
+
break
|
|
152
|
+
elif state in ['FAILED', 'CANCELLED']:
|
|
153
|
+
reason = status_response['QueryExecution']['Status'].get(
|
|
154
|
+
'StateChangeReason', 'Unknown error'
|
|
155
|
+
)
|
|
156
|
+
raise Exception(f"Athena query {state}: {reason}")
|
|
157
|
+
|
|
158
|
+
time.sleep(wait_interval)
|
|
159
|
+
elapsed += wait_interval
|
|
160
|
+
|
|
161
|
+
if elapsed >= max_wait:
|
|
162
|
+
raise Exception("Athena query timeout after 60 seconds")
|
|
163
|
+
|
|
164
|
+
# Get results
|
|
165
|
+
results_response = athena_client.get_query_results(
|
|
166
|
+
QueryExecutionId=query_execution_id,
|
|
167
|
+
MaxResults=100
|
|
168
|
+
)
|
|
169
|
+
|
|
170
|
+
# Parse results
|
|
171
|
+
resources = []
|
|
172
|
+
rows = results_response['ResultSet']['Rows']
|
|
173
|
+
|
|
174
|
+
# Skip header row
|
|
175
|
+
for row in rows[1:]:
|
|
176
|
+
data = row['Data']
|
|
177
|
+
resources.append({
|
|
178
|
+
'account_id': data[0].get('VarCharValue', ''),
|
|
179
|
+
'resource_id': data[1].get('VarCharValue', ''),
|
|
180
|
+
'usage_type': data[2].get('VarCharValue', ''),
|
|
181
|
+
'region': data[3].get('VarCharValue', ''),
|
|
182
|
+
'total_cost': float(data[4].get('VarCharValue', 0)),
|
|
183
|
+
'total_usage': float(data[5].get('VarCharValue', 0))
|
|
184
|
+
})
|
|
185
|
+
|
|
186
|
+
return {
|
|
187
|
+
'resources': resources,
|
|
188
|
+
'service': service,
|
|
189
|
+
'product_code': product_code,
|
|
190
|
+
'account_filter': account_filter,
|
|
191
|
+
'period': {
|
|
192
|
+
'start': start_date.strftime('%Y-%m-%d'),
|
|
193
|
+
'end': end_date.strftime('%Y-%m-%d'),
|
|
194
|
+
'weeks': weeks
|
|
195
|
+
}
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
except Exception as e:
|
|
199
|
+
raise Exception(f"CUR query failed: {str(e)}")
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
def format_resource_output(result):
|
|
203
|
+
"""Format resource query results for display"""
|
|
204
|
+
resources = result['resources']
|
|
205
|
+
service = result['service']
|
|
206
|
+
account_filter = result.get('account_filter')
|
|
207
|
+
period = result['period']
|
|
208
|
+
|
|
209
|
+
output = []
|
|
210
|
+
output.append(f"\n📊 Resource Breakdown: {service}")
|
|
211
|
+
|
|
212
|
+
if account_filter:
|
|
213
|
+
output.append(f"Account: {account_filter} | Period: {period['start']} to {period['end']} ({period['weeks']} weeks)")
|
|
214
|
+
else:
|
|
215
|
+
output.append(f"All Accounts | Period: {period['start']} to {period['end']} ({period['weeks']} weeks)")
|
|
216
|
+
|
|
217
|
+
output.append("")
|
|
218
|
+
|
|
219
|
+
if not resources:
|
|
220
|
+
output.append("No resources found with costs in this period.")
|
|
221
|
+
return '\n'.join(output)
|
|
222
|
+
|
|
223
|
+
# Format as table
|
|
224
|
+
output.append(f"Top {len(resources)} Resources by Cost:")
|
|
225
|
+
output.append("┌─────────────────────────────┬──────────────────────────────┬────────────┬─────────┐")
|
|
226
|
+
output.append("│ Resource ID │ Usage Type │ Region │ Cost │")
|
|
227
|
+
output.append("├─────────────────────────────┼──────────────────────────────┼────────────┼─────────┤")
|
|
228
|
+
|
|
229
|
+
for resource in resources:
|
|
230
|
+
resource_id = resource['resource_id'][:27] + '...' if len(resource['resource_id']) > 27 else resource['resource_id']
|
|
231
|
+
usage_type = resource['usage_type'][:28] + '..' if len(resource['usage_type']) > 28 else resource['usage_type']
|
|
232
|
+
region = resource['region'][:10] if resource['region'] else 'N/A'
|
|
233
|
+
cost = resource['total_cost']
|
|
234
|
+
|
|
235
|
+
output.append(
|
|
236
|
+
f"│ {resource_id:<27} │ {usage_type:<28} │ {region:<10} │ ${cost:>6.2f} │"
|
|
237
|
+
)
|
|
238
|
+
|
|
239
|
+
output.append("└─────────────────────────────┴──────────────────────────────┴────────────┴─────────┘")
|
|
240
|
+
output.append("")
|
|
241
|
+
output.append("💡 Tip: Use AWS CLI to investigate specific resources:")
|
|
242
|
+
output.append(f" aws ec2 describe-instances --instance-ids <resource-id>")
|
|
243
|
+
|
|
244
|
+
return '\n'.join(output)
|
cost_calculator/executor.py
CHANGED
|
@@ -56,32 +56,18 @@ def execute_trends(config, weeks):
|
|
|
56
56
|
"""
|
|
57
57
|
accounts = config['accounts']
|
|
58
58
|
|
|
59
|
-
if is_api_configured():
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
session = boto3.Session(profile_name=config['aws_profile'])
|
|
72
|
-
else:
|
|
73
|
-
creds = config['credentials']
|
|
74
|
-
session_kwargs = {
|
|
75
|
-
'aws_access_key_id': creds['aws_access_key_id'],
|
|
76
|
-
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
77
|
-
'region_name': creds.get('region', 'us-east-1')
|
|
78
|
-
}
|
|
79
|
-
if 'aws_session_token' in creds:
|
|
80
|
-
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
81
|
-
session = boto3.Session(**session_kwargs)
|
|
82
|
-
|
|
83
|
-
ce_client = session.client('ce', region_name='us-east-1')
|
|
84
|
-
return analyze_trends(ce_client, accounts, weeks)
|
|
59
|
+
if not is_api_configured():
|
|
60
|
+
raise Exception(
|
|
61
|
+
"API not configured. Set COST_API_SECRET environment variable.\n"
|
|
62
|
+
"Local execution is disabled. Use the Lambda API."
|
|
63
|
+
)
|
|
64
|
+
|
|
65
|
+
# Use API only
|
|
66
|
+
click.echo("Using Lambda API...")
|
|
67
|
+
credentials = get_credentials_dict(config)
|
|
68
|
+
if not credentials:
|
|
69
|
+
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
70
|
+
return call_lambda_api('trends', credentials, accounts, weeks=weeks)
|
|
85
71
|
|
|
86
72
|
|
|
87
73
|
def execute_monthly(config, months):
|
|
@@ -93,81 +79,62 @@ def execute_monthly(config, months):
|
|
|
93
79
|
"""
|
|
94
80
|
accounts = config['accounts']
|
|
95
81
|
|
|
96
|
-
if is_api_configured():
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
session = boto3.Session(profile_name=config['aws_profile'])
|
|
109
|
-
else:
|
|
110
|
-
creds = config['credentials']
|
|
111
|
-
session_kwargs = {
|
|
112
|
-
'aws_access_key_id': creds['aws_access_key_id'],
|
|
113
|
-
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
114
|
-
'region_name': creds.get('region', 'us-east-1')
|
|
115
|
-
}
|
|
116
|
-
if 'aws_session_token' in creds:
|
|
117
|
-
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
118
|
-
session = boto3.Session(**session_kwargs)
|
|
119
|
-
|
|
120
|
-
ce_client = session.client('ce', region_name='us-east-1')
|
|
121
|
-
return analyze_monthly_trends(ce_client, accounts, months)
|
|
82
|
+
if not is_api_configured():
|
|
83
|
+
raise Exception(
|
|
84
|
+
"API not configured. Set COST_API_SECRET environment variable.\n"
|
|
85
|
+
"Local execution is disabled. Use the Lambda API."
|
|
86
|
+
)
|
|
87
|
+
|
|
88
|
+
# Use API only
|
|
89
|
+
click.echo("Using Lambda API...")
|
|
90
|
+
credentials = get_credentials_dict(config)
|
|
91
|
+
if not credentials:
|
|
92
|
+
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
93
|
+
return call_lambda_api('monthly', credentials, accounts, months=months)
|
|
122
94
|
|
|
123
95
|
|
|
124
|
-
def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None):
|
|
96
|
+
def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None, resources=False):
|
|
125
97
|
"""
|
|
126
|
-
Execute drill-down analysis via API
|
|
98
|
+
Execute drill-down analysis via API.
|
|
99
|
+
|
|
100
|
+
Args:
|
|
101
|
+
config: Profile configuration
|
|
102
|
+
weeks: Number of weeks to analyze
|
|
103
|
+
service_filter: Optional service name filter
|
|
104
|
+
account_filter: Optional account ID filter
|
|
105
|
+
usage_type_filter: Optional usage type filter
|
|
106
|
+
resources: If True, query CUR for resource-level details
|
|
127
107
|
|
|
128
108
|
Returns:
|
|
129
|
-
dict: drill data
|
|
109
|
+
dict: drill data or resource data
|
|
130
110
|
"""
|
|
131
111
|
accounts = config['accounts']
|
|
132
112
|
|
|
133
|
-
if is_api_configured():
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
kwargs = {'weeks': weeks}
|
|
138
|
-
if service_filter:
|
|
139
|
-
kwargs['service'] = service_filter
|
|
140
|
-
if account_filter:
|
|
141
|
-
kwargs['account'] = account_filter
|
|
142
|
-
if usage_type_filter:
|
|
143
|
-
kwargs['usage_type'] = usage_type_filter
|
|
144
|
-
return call_lambda_api('drill', credentials, accounts, **kwargs)
|
|
145
|
-
else:
|
|
146
|
-
# Use local execution
|
|
147
|
-
click.echo("Using local execution...")
|
|
148
|
-
from cost_calculator.drill import analyze_drill_down
|
|
149
|
-
|
|
150
|
-
# Initialize boto3 client
|
|
151
|
-
if 'aws_profile' in config:
|
|
152
|
-
session = boto3.Session(profile_name=config['aws_profile'])
|
|
153
|
-
else:
|
|
154
|
-
creds = config['credentials']
|
|
155
|
-
session_kwargs = {
|
|
156
|
-
'aws_access_key_id': creds['aws_access_key_id'],
|
|
157
|
-
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
158
|
-
'region_name': creds.get('region', 'us-east-1')
|
|
159
|
-
}
|
|
160
|
-
if 'aws_session_token' in creds:
|
|
161
|
-
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
162
|
-
session = boto3.Session(**session_kwargs)
|
|
163
|
-
|
|
164
|
-
ce_client = session.client('ce', region_name='us-east-1')
|
|
165
|
-
return analyze_drill_down(
|
|
166
|
-
ce_client, accounts, weeks,
|
|
167
|
-
service_filter=service_filter,
|
|
168
|
-
account_filter=account_filter,
|
|
169
|
-
usage_type_filter=usage_type_filter
|
|
113
|
+
if not is_api_configured():
|
|
114
|
+
raise Exception(
|
|
115
|
+
"API not configured. Set COST_API_SECRET environment variable.\n"
|
|
116
|
+
"Local execution is disabled. Use the Lambda API."
|
|
170
117
|
)
|
|
118
|
+
|
|
119
|
+
# Use API only
|
|
120
|
+
click.echo("Using Lambda API...")
|
|
121
|
+
credentials = get_credentials_dict(config)
|
|
122
|
+
if not credentials:
|
|
123
|
+
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
124
|
+
|
|
125
|
+
kwargs = {'weeks': weeks}
|
|
126
|
+
if service_filter:
|
|
127
|
+
kwargs['service'] = service_filter
|
|
128
|
+
if account_filter:
|
|
129
|
+
kwargs['account'] = account_filter
|
|
130
|
+
if usage_type_filter:
|
|
131
|
+
kwargs['usage_type'] = usage_type_filter
|
|
132
|
+
if resources:
|
|
133
|
+
if not service_filter:
|
|
134
|
+
raise click.ClickException("--service is required when using --resources flag")
|
|
135
|
+
kwargs['resources'] = True
|
|
136
|
+
|
|
137
|
+
return call_lambda_api('drill', credentials, accounts, **kwargs)
|
|
171
138
|
|
|
172
139
|
|
|
173
140
|
def execute_analyze(config, weeks, analysis_type, pattern=None, min_cost=None):
|