aws-cost-calculator-cli 1.6.2__py3-none-any.whl → 2.4.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.
- {aws_cost_calculator_cli-1.6.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/METADATA +31 -12
- aws_cost_calculator_cli-2.4.0.dist-info/RECORD +16 -0
- {aws_cost_calculator_cli-1.6.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/top_level.txt +0 -1
- cost_calculator/api_client.py +18 -20
- cost_calculator/cli.py +1750 -249
- cost_calculator/cur.py +244 -0
- cost_calculator/dimensions.py +141 -0
- cost_calculator/executor.py +124 -101
- cost_calculator/forensics.py +323 -0
- aws_cost_calculator_cli-1.6.2.dist-info/RECORD +0 -25
- backend/__init__.py +0 -1
- backend/algorithms/__init__.py +0 -1
- backend/algorithms/analyze.py +0 -272
- backend/algorithms/drill.py +0 -323
- backend/algorithms/monthly.py +0 -242
- backend/algorithms/trends.py +0 -353
- backend/handlers/__init__.py +0 -1
- backend/handlers/analyze.py +0 -112
- backend/handlers/drill.py +0 -117
- backend/handlers/monthly.py +0 -106
- backend/handlers/profiles.py +0 -148
- backend/handlers/trends.py +0 -106
- {aws_cost_calculator_cli-1.6.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/WHEEL +0 -0
- {aws_cost_calculator_cli-1.6.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.6.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/licenses/LICENSE +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)
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Dimension mapping between CLI, Cost Explorer, and Athena CUR.
|
|
3
|
+
|
|
4
|
+
This module provides utilities for translating dimension names across different backends.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
# Dimension mapping: CLI dimension -> (CE dimension, Athena column)
|
|
8
|
+
DIMENSION_MAP = {
|
|
9
|
+
'service': ('SERVICE', 'line_item_product_code'),
|
|
10
|
+
'account': ('LINKED_ACCOUNT', 'line_item_usage_account_id'),
|
|
11
|
+
'region': ('REGION', 'product_region'),
|
|
12
|
+
'usage_type': ('USAGE_TYPE', 'line_item_usage_type'),
|
|
13
|
+
'resource': (None, 'line_item_resource_id'), # Athena only
|
|
14
|
+
'instance_type': ('INSTANCE_TYPE', 'product_instance_type'),
|
|
15
|
+
'operation': ('OPERATION', 'line_item_operation'),
|
|
16
|
+
'availability_zone': ('AVAILABILITY_ZONE', 'product_availability_zone'),
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def get_ce_dimension(cli_dimension):
|
|
21
|
+
"""
|
|
22
|
+
Get Cost Explorer dimension key for a CLI dimension.
|
|
23
|
+
|
|
24
|
+
Args:
|
|
25
|
+
cli_dimension: CLI dimension name (e.g., 'service', 'account')
|
|
26
|
+
|
|
27
|
+
Returns:
|
|
28
|
+
str: Cost Explorer dimension key (e.g., 'SERVICE', 'LINKED_ACCOUNT')
|
|
29
|
+
None: If dimension is not available in Cost Explorer
|
|
30
|
+
|
|
31
|
+
Raises:
|
|
32
|
+
ValueError: If dimension is unknown
|
|
33
|
+
"""
|
|
34
|
+
if cli_dimension not in DIMENSION_MAP:
|
|
35
|
+
raise ValueError(f"Unknown dimension: {cli_dimension}")
|
|
36
|
+
|
|
37
|
+
ce_dim, _ = DIMENSION_MAP[cli_dimension]
|
|
38
|
+
return ce_dim
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
def get_athena_column(cli_dimension):
|
|
42
|
+
"""
|
|
43
|
+
Get Athena CUR column name for a CLI dimension.
|
|
44
|
+
|
|
45
|
+
Args:
|
|
46
|
+
cli_dimension: CLI dimension name (e.g., 'service', 'account')
|
|
47
|
+
|
|
48
|
+
Returns:
|
|
49
|
+
str: Athena column name (e.g., 'line_item_product_code', 'line_item_usage_account_id')
|
|
50
|
+
|
|
51
|
+
Raises:
|
|
52
|
+
ValueError: If dimension is unknown
|
|
53
|
+
"""
|
|
54
|
+
if cli_dimension not in DIMENSION_MAP:
|
|
55
|
+
raise ValueError(f"Unknown dimension: {cli_dimension}")
|
|
56
|
+
|
|
57
|
+
_, athena_col = DIMENSION_MAP[cli_dimension]
|
|
58
|
+
return athena_col
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def is_athena_only(cli_dimension):
|
|
62
|
+
"""
|
|
63
|
+
Check if a dimension is only available in Athena (not in Cost Explorer).
|
|
64
|
+
|
|
65
|
+
Args:
|
|
66
|
+
cli_dimension: CLI dimension name
|
|
67
|
+
|
|
68
|
+
Returns:
|
|
69
|
+
bool: True if dimension requires Athena, False otherwise
|
|
70
|
+
"""
|
|
71
|
+
if cli_dimension not in DIMENSION_MAP:
|
|
72
|
+
raise ValueError(f"Unknown dimension: {cli_dimension}")
|
|
73
|
+
|
|
74
|
+
ce_dim, _ = DIMENSION_MAP[cli_dimension]
|
|
75
|
+
return ce_dim is None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
def get_available_dimensions(backend='auto'):
|
|
79
|
+
"""
|
|
80
|
+
Get list of available dimensions for a given backend.
|
|
81
|
+
|
|
82
|
+
Args:
|
|
83
|
+
backend: 'auto', 'ce', or 'athena'
|
|
84
|
+
|
|
85
|
+
Returns:
|
|
86
|
+
list: List of available dimension names
|
|
87
|
+
"""
|
|
88
|
+
if backend == 'athena':
|
|
89
|
+
# All dimensions available in Athena
|
|
90
|
+
return list(DIMENSION_MAP.keys())
|
|
91
|
+
elif backend == 'ce':
|
|
92
|
+
# Only dimensions with CE mapping
|
|
93
|
+
return [dim for dim, (ce_dim, _) in DIMENSION_MAP.items() if ce_dim is not None]
|
|
94
|
+
else: # auto
|
|
95
|
+
# All dimensions (backend will be auto-selected)
|
|
96
|
+
return list(DIMENSION_MAP.keys())
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def validate_dimension_backend(dimension, backend):
|
|
100
|
+
"""
|
|
101
|
+
Validate that a dimension is available for the specified backend.
|
|
102
|
+
|
|
103
|
+
Args:
|
|
104
|
+
dimension: CLI dimension name
|
|
105
|
+
backend: 'auto', 'ce', or 'athena'
|
|
106
|
+
|
|
107
|
+
Returns:
|
|
108
|
+
tuple: (is_valid, error_message)
|
|
109
|
+
|
|
110
|
+
Examples:
|
|
111
|
+
>>> validate_dimension_backend('resource', 'ce')
|
|
112
|
+
(False, "Dimension 'resource' requires Athena backend (not available in Cost Explorer)")
|
|
113
|
+
|
|
114
|
+
>>> validate_dimension_backend('service', 'ce')
|
|
115
|
+
(True, None)
|
|
116
|
+
"""
|
|
117
|
+
if dimension not in DIMENSION_MAP:
|
|
118
|
+
return False, f"Unknown dimension: {dimension}"
|
|
119
|
+
|
|
120
|
+
if backend == 'ce' and is_athena_only(dimension):
|
|
121
|
+
return False, f"Dimension '{dimension}' requires Athena backend (not available in Cost Explorer)"
|
|
122
|
+
|
|
123
|
+
return True, None
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
# Human-readable dimension descriptions
|
|
127
|
+
DIMENSION_DESCRIPTIONS = {
|
|
128
|
+
'service': 'AWS Service (e.g., EC2, S3, RDS)',
|
|
129
|
+
'account': 'AWS Account ID',
|
|
130
|
+
'region': 'AWS Region (e.g., us-east-1, eu-west-1)',
|
|
131
|
+
'usage_type': 'Usage Type (e.g., BoxUsage:t3.micro)',
|
|
132
|
+
'resource': 'Resource ID/ARN (Athena only)',
|
|
133
|
+
'instance_type': 'Instance Type (e.g., t3.micro, m5.large)',
|
|
134
|
+
'operation': 'Operation (e.g., RunInstances, CreateBucket)',
|
|
135
|
+
'availability_zone': 'Availability Zone (e.g., us-east-1a)',
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def get_dimension_description(dimension):
|
|
140
|
+
"""Get human-readable description for a dimension."""
|
|
141
|
+
return DIMENSION_DESCRIPTIONS.get(dimension, dimension)
|
cost_calculator/executor.py
CHANGED
|
@@ -3,6 +3,7 @@ Executor that routes to either API or local execution.
|
|
|
3
3
|
"""
|
|
4
4
|
import boto3
|
|
5
5
|
import click
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from cost_calculator.api_client import is_api_configured, call_lambda_api
|
|
7
8
|
|
|
8
9
|
|
|
@@ -22,6 +23,13 @@ def get_credentials_dict(config):
|
|
|
22
23
|
try:
|
|
23
24
|
session = boto3.Session(profile_name=config['aws_profile'])
|
|
24
25
|
credentials = session.get_credentials()
|
|
26
|
+
|
|
27
|
+
if credentials is None:
|
|
28
|
+
raise Exception(
|
|
29
|
+
f"Could not get credentials for profile '{config['aws_profile']}'.\n"
|
|
30
|
+
f"Run: aws sso login --profile {config['aws_profile']}"
|
|
31
|
+
)
|
|
32
|
+
|
|
25
33
|
frozen_creds = credentials.get_frozen_credentials()
|
|
26
34
|
|
|
27
35
|
return {
|
|
@@ -29,9 +37,44 @@ def get_credentials_dict(config):
|
|
|
29
37
|
'secret_key': frozen_creds.secret_key,
|
|
30
38
|
'session_token': frozen_creds.token
|
|
31
39
|
}
|
|
32
|
-
except Exception:
|
|
33
|
-
#
|
|
34
|
-
|
|
40
|
+
except Exception as e:
|
|
41
|
+
# Show the actual error instead of silently returning None
|
|
42
|
+
error_msg = str(e)
|
|
43
|
+
|
|
44
|
+
# If it's an SSO token error, provide better guidance
|
|
45
|
+
if 'SSO Token' in error_msg or 'sso' in error_msg.lower():
|
|
46
|
+
# Try to detect if using sso_session format
|
|
47
|
+
import subprocess
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
['grep', '-A', '3', f'profile {config["aws_profile"]}',
|
|
51
|
+
str(Path.home() / '.aws' / 'config')],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=2
|
|
55
|
+
)
|
|
56
|
+
if 'sso_session' in result.stdout:
|
|
57
|
+
# Extract session name
|
|
58
|
+
for line in result.stdout.split('\n'):
|
|
59
|
+
if 'sso_session' in line:
|
|
60
|
+
session_name = line.split('=')[1].strip()
|
|
61
|
+
raise Exception(
|
|
62
|
+
f"Failed to get AWS credentials for profile '{config['aws_profile']}'.\n"
|
|
63
|
+
f"Error: {error_msg}\n\n"
|
|
64
|
+
f"Your profile uses SSO session '{session_name}'.\n"
|
|
65
|
+
f"Try: aws sso login --sso-session {session_name}\n"
|
|
66
|
+
f"(Requires AWS CLI v2.9.0+)\n\n"
|
|
67
|
+
f"Or: aws sso login --profile {config['aws_profile']}\n"
|
|
68
|
+
f"(If using older AWS CLI)"
|
|
69
|
+
)
|
|
70
|
+
except:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
raise Exception(
|
|
74
|
+
f"Failed to get AWS credentials for profile '{config['aws_profile']}'.\n"
|
|
75
|
+
f"Error: {error_msg}\n"
|
|
76
|
+
f"Try: aws sso login --profile {config['aws_profile']}"
|
|
77
|
+
)
|
|
35
78
|
else:
|
|
36
79
|
# Use static credentials
|
|
37
80
|
creds = config.get('credentials', {})
|
|
@@ -49,39 +92,29 @@ def get_credentials_dict(config):
|
|
|
49
92
|
|
|
50
93
|
def execute_trends(config, weeks):
|
|
51
94
|
"""
|
|
52
|
-
Execute trends analysis via API
|
|
95
|
+
Execute trends analysis via API.
|
|
96
|
+
|
|
97
|
+
Args:
|
|
98
|
+
config: Profile configuration
|
|
99
|
+
weeks: Number of weeks to analyze
|
|
53
100
|
|
|
54
101
|
Returns:
|
|
55
102
|
dict: trends data
|
|
56
103
|
"""
|
|
57
|
-
|
|
104
|
+
profile_name = config.get('profile_name', config.get('name'))
|
|
58
105
|
|
|
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)
|
|
106
|
+
if not is_api_configured():
|
|
107
|
+
raise Exception(
|
|
108
|
+
"API not configured. Set COST_API_SECRET environment variable.\n"
|
|
109
|
+
"Local execution is disabled. Use the Lambda API."
|
|
110
|
+
)
|
|
111
|
+
|
|
112
|
+
# Use API only
|
|
113
|
+
click.echo("Using Lambda API...")
|
|
114
|
+
credentials = get_credentials_dict(config)
|
|
115
|
+
if not credentials:
|
|
116
|
+
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
117
|
+
return call_lambda_api('trends', credentials, profile=profile_name, weeks=weeks)
|
|
85
118
|
|
|
86
119
|
|
|
87
120
|
def execute_monthly(config, months):
|
|
@@ -91,83 +124,70 @@ def execute_monthly(config, months):
|
|
|
91
124
|
Returns:
|
|
92
125
|
dict: monthly data
|
|
93
126
|
"""
|
|
94
|
-
|
|
127
|
+
profile_name = config.get('profile_name', config.get('name'))
|
|
95
128
|
|
|
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)
|
|
129
|
+
if not is_api_configured():
|
|
130
|
+
raise Exception(
|
|
131
|
+
"API not configured. Set COST_API_SECRET environment variable.\n"
|
|
132
|
+
"Local execution is disabled. Use the Lambda API."
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
# Use API only
|
|
136
|
+
click.echo("Using Lambda API...")
|
|
137
|
+
credentials = get_credentials_dict(config)
|
|
138
|
+
if not credentials:
|
|
139
|
+
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
140
|
+
return call_lambda_api('monthly', credentials, profile=profile_name, months=months)
|
|
122
141
|
|
|
123
142
|
|
|
124
|
-
def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None):
|
|
143
|
+
def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None, resources=False, dimension=None, backend='auto'):
|
|
125
144
|
"""
|
|
126
|
-
Execute drill-down analysis via API
|
|
145
|
+
Execute drill-down analysis via API.
|
|
146
|
+
|
|
147
|
+
Args:
|
|
148
|
+
config: Profile configuration
|
|
149
|
+
weeks: Number of weeks to analyze
|
|
150
|
+
service_filter: Optional service name filter
|
|
151
|
+
account_filter: Optional account ID filter
|
|
152
|
+
usage_type_filter: Optional usage type filter
|
|
153
|
+
resources: If True, query CUR for resource-level details
|
|
154
|
+
dimension: Dimension to analyze by (service, account, region, usage_type, resource, etc.)
|
|
155
|
+
backend: Backend to use ('auto', 'ce', 'athena')
|
|
127
156
|
|
|
128
157
|
Returns:
|
|
129
|
-
dict: drill data
|
|
158
|
+
dict: drill data or resource data
|
|
130
159
|
"""
|
|
131
|
-
|
|
160
|
+
profile_name = config.get('profile_name', config.get('name'))
|
|
132
161
|
|
|
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
|
|
162
|
+
if not is_api_configured():
|
|
163
|
+
raise Exception(
|
|
164
|
+
"API not configured. Set COST_API_SECRET environment variable.\n"
|
|
165
|
+
"Local execution is disabled. Use the Lambda API."
|
|
170
166
|
)
|
|
167
|
+
|
|
168
|
+
# Use API only
|
|
169
|
+
click.echo("Using Lambda API...")
|
|
170
|
+
credentials = get_credentials_dict(config)
|
|
171
|
+
if not credentials:
|
|
172
|
+
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
173
|
+
|
|
174
|
+
kwargs = {'weeks': weeks}
|
|
175
|
+
if service_filter:
|
|
176
|
+
kwargs['service'] = service_filter
|
|
177
|
+
if account_filter:
|
|
178
|
+
kwargs['account'] = account_filter
|
|
179
|
+
if usage_type_filter:
|
|
180
|
+
kwargs['usage_type'] = usage_type_filter
|
|
181
|
+
if dimension:
|
|
182
|
+
kwargs['dimension'] = dimension
|
|
183
|
+
if backend and backend != 'auto':
|
|
184
|
+
kwargs['backend'] = backend
|
|
185
|
+
if resources:
|
|
186
|
+
if not service_filter and not dimension:
|
|
187
|
+
raise click.ClickException("--service or --dimension is required when using --resources flag")
|
|
188
|
+
kwargs['resources'] = True
|
|
189
|
+
|
|
190
|
+
return call_lambda_api('drill', credentials, profile=profile_name, **kwargs)
|
|
171
191
|
|
|
172
192
|
|
|
173
193
|
def execute_analyze(config, weeks, analysis_type, pattern=None, min_cost=None):
|
|
@@ -217,8 +237,11 @@ def execute_profile_operation(operation, profile_name=None, accounts=None, descr
|
|
|
217
237
|
|
|
218
238
|
api_secret = os.environ.get('COST_API_SECRET', '')
|
|
219
239
|
|
|
220
|
-
# Use profiles endpoint (
|
|
221
|
-
url =
|
|
240
|
+
# Use profiles endpoint (can be overridden via environment variable)
|
|
241
|
+
url = os.environ.get(
|
|
242
|
+
'COST_CALCULATOR_PROFILES_URL',
|
|
243
|
+
'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
|
|
244
|
+
)
|
|
222
245
|
|
|
223
246
|
payload = {'operation': operation}
|
|
224
247
|
if profile_name:
|