aws-cost-calculator-cli 1.6.3__py3-none-any.whl → 2.0.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-cost-calculator-cli might be problematic. Click here for more details.

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)
@@ -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
- # If profile not found, return None (API will handle)
34
- return None
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', {})
@@ -56,32 +99,18 @@ def execute_trends(config, weeks):
56
99
  """
57
100
  accounts = config['accounts']
58
101
 
59
- if is_api_configured():
60
- # Use API
61
- click.echo("Using Lambda API...")
62
- credentials = get_credentials_dict(config)
63
- return call_lambda_api('trends', credentials, accounts, weeks=weeks)
64
- else:
65
- # Use local execution
66
- click.echo("Using local execution...")
67
- from cost_calculator.trends import analyze_trends
68
-
69
- # Initialize boto3 client
70
- if 'aws_profile' in config:
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)
102
+ if not is_api_configured():
103
+ raise Exception(
104
+ "API not configured. Set COST_API_SECRET environment variable.\n"
105
+ "Local execution is disabled. Use the Lambda API."
106
+ )
107
+
108
+ # Use API only
109
+ click.echo("Using Lambda API...")
110
+ credentials = get_credentials_dict(config)
111
+ if not credentials:
112
+ raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
113
+ return call_lambda_api('trends', credentials, accounts, weeks=weeks)
85
114
 
86
115
 
87
116
  def execute_monthly(config, months):
@@ -93,81 +122,62 @@ def execute_monthly(config, months):
93
122
  """
94
123
  accounts = config['accounts']
95
124
 
96
- if is_api_configured():
97
- # Use API
98
- click.echo("Using Lambda API...")
99
- credentials = get_credentials_dict(config)
100
- return call_lambda_api('monthly', credentials, accounts, months=months)
101
- else:
102
- # Use local execution
103
- click.echo("Using local execution...")
104
- from cost_calculator.monthly import analyze_monthly_trends
105
-
106
- # Initialize boto3 client
107
- if 'aws_profile' in config:
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)
125
+ if not is_api_configured():
126
+ raise Exception(
127
+ "API not configured. Set COST_API_SECRET environment variable.\n"
128
+ "Local execution is disabled. Use the Lambda API."
129
+ )
130
+
131
+ # Use API only
132
+ click.echo("Using Lambda API...")
133
+ credentials = get_credentials_dict(config)
134
+ if not credentials:
135
+ raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
136
+ return call_lambda_api('monthly', credentials, accounts, months=months)
122
137
 
123
138
 
124
- def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None):
139
+ def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None, resources=False):
125
140
  """
126
- Execute drill-down analysis via API or locally.
141
+ Execute drill-down analysis via API.
142
+
143
+ Args:
144
+ config: Profile configuration
145
+ weeks: Number of weeks to analyze
146
+ service_filter: Optional service name filter
147
+ account_filter: Optional account ID filter
148
+ usage_type_filter: Optional usage type filter
149
+ resources: If True, query CUR for resource-level details
127
150
 
128
151
  Returns:
129
- dict: drill data
152
+ dict: drill data or resource data
130
153
  """
131
154
  accounts = config['accounts']
132
155
 
133
- if is_api_configured():
134
- # Use API
135
- click.echo("Using Lambda API...")
136
- credentials = get_credentials_dict(config)
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
156
+ if not is_api_configured():
157
+ raise Exception(
158
+ "API not configured. Set COST_API_SECRET environment variable.\n"
159
+ "Local execution is disabled. Use the Lambda API."
170
160
  )
161
+
162
+ # Use API only
163
+ click.echo("Using Lambda API...")
164
+ credentials = get_credentials_dict(config)
165
+ if not credentials:
166
+ raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
167
+
168
+ kwargs = {'weeks': weeks}
169
+ if service_filter:
170
+ kwargs['service'] = service_filter
171
+ if account_filter:
172
+ kwargs['account'] = account_filter
173
+ if usage_type_filter:
174
+ kwargs['usage_type'] = usage_type_filter
175
+ if resources:
176
+ if not service_filter:
177
+ raise click.ClickException("--service is required when using --resources flag")
178
+ kwargs['resources'] = True
179
+
180
+ return call_lambda_api('drill', credentials, accounts, **kwargs)
171
181
 
172
182
 
173
183
  def execute_analyze(config, weeks, analysis_type, pattern=None, min_cost=None):