aws-cost-calculator-cli 1.5.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.
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)
@@ -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', {})
@@ -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 or locally.
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
- accounts = config['accounts']
104
+ profile_name = config.get('profile_name', config.get('name'))
58
105
 
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)
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
- accounts = config['accounts']
127
+ profile_name = config.get('profile_name', config.get('name'))
95
128
 
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)
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 or locally.
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
- accounts = config['accounts']
160
+ profile_name = config.get('profile_name', config.get('name'))
132
161
 
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
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 (hardcoded URL)
221
- url = 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
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: