aws-cost-calculator-cli 1.8.2__py3-none-any.whl → 1.11.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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-cost-calculator-cli
3
- Version: 1.8.2
3
+ Version: 1.11.1
4
4
  Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
5
5
  Home-page: https://github.com/trilogy-group/aws-cost-calculator
6
6
  Author: Cost Optimization Team
@@ -0,0 +1,15 @@
1
+ aws_cost_calculator_cli-1.11.1.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
2
+ cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
3
+ cost_calculator/api_client.py,sha256=4ZI2XcGIN3FBeQqb7xOxQ91kCoeM43-rExiOELXoKBQ,2485
4
+ cost_calculator/cli.py,sha256=OudMcAitmJfWgZKxjlHtfXnx2SlKrYh6FzPTkHabJlI,77975
5
+ cost_calculator/cur.py,sha256=QaZ_nyDSw5_cti-h5Ho6eYLbqzY5TWoub24DpyzIiSs,9502
6
+ cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
7
+ cost_calculator/executor.py,sha256=yZTCUgJc1OpB892O3mq9ZA0Yekc7N-HvaW8xLFyrXjo,8681
8
+ cost_calculator/forensics.py,sha256=uhRo3I_zOeMEaBENHfgq65URga31W0Z4vzS2UN6VmTY,12819
9
+ cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
10
+ cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
11
+ aws_cost_calculator_cli-1.11.1.dist-info/METADATA,sha256=d-AHFprwq0-lD-D3ilWa7IGBxOszOdMqP7nJl5J0gyA,11979
12
+ aws_cost_calculator_cli-1.11.1.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
13
+ aws_cost_calculator_cli-1.11.1.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
14
+ aws_cost_calculator_cli-1.11.1.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
15
+ aws_cost_calculator_cli-1.11.1.dist-info/RECORD,,
cost_calculator/cli.py CHANGED
@@ -68,7 +68,7 @@ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=N
68
68
 
69
69
 
70
70
  def load_profile(profile_name):
71
- """Load profile configuration from local file or DynamoDB API"""
71
+ """Load profile configuration from DynamoDB API or local file as fallback"""
72
72
  import os
73
73
  import requests
74
74
 
@@ -76,7 +76,76 @@ def load_profile(profile_name):
76
76
  config_file = config_dir / 'profiles.json'
77
77
  creds_file = config_dir / 'credentials.json'
78
78
 
79
- # Try local file first
79
+ # Try DynamoDB API first if COST_API_SECRET is set
80
+ api_secret = os.environ.get('COST_API_SECRET')
81
+ if api_secret:
82
+ try:
83
+ response = requests.post(
84
+ 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
85
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
86
+ json={'operation': 'get', 'profile_name': profile_name},
87
+ timeout=10
88
+ )
89
+
90
+ if response.status_code == 200:
91
+ response_data = response.json()
92
+ # API returns {"profile": {...}} wrapper
93
+ profile_data = response_data.get('profile', response_data)
94
+ profile = {'accounts': profile_data['accounts']}
95
+
96
+ # If profile has aws_profile field, use it
97
+ if 'aws_profile' in profile_data:
98
+ profile['aws_profile'] = profile_data['aws_profile']
99
+ # Check for AWS_PROFILE environment variable (SSO support)
100
+ elif os.environ.get('AWS_PROFILE'):
101
+ profile['aws_profile'] = os.environ['AWS_PROFILE']
102
+ # Use environment credentials
103
+ elif os.environ.get('AWS_ACCESS_KEY_ID'):
104
+ profile['credentials'] = {
105
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
106
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
107
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
108
+ }
109
+ else:
110
+ # Try to find a matching AWS profile by name
111
+ # This allows "khoros" profile to work with "khoros_umbrella" AWS profile
112
+ import subprocess
113
+ try:
114
+ result = subprocess.run(
115
+ ['aws', 'configure', 'list-profiles'],
116
+ capture_output=True,
117
+ text=True,
118
+ timeout=5
119
+ )
120
+ if result.returncode == 0:
121
+ available_profiles = result.stdout.strip().split('\n')
122
+ # Try exact match first
123
+ if profile_name in available_profiles:
124
+ profile['aws_profile'] = profile_name
125
+ # Try with common suffixes
126
+ elif f"{profile_name}_umbrella" in available_profiles:
127
+ profile['aws_profile'] = f"{profile_name}_umbrella"
128
+ elif f"{profile_name}-umbrella" in available_profiles:
129
+ profile['aws_profile'] = f"{profile_name}-umbrella"
130
+ elif f"{profile_name}_prod" in available_profiles:
131
+ profile['aws_profile'] = f"{profile_name}_prod"
132
+ # If no match found, leave it unset - user must provide --sso
133
+ except:
134
+ # If we can't list profiles, leave it unset - user must provide --sso
135
+ pass
136
+
137
+ return profile
138
+ else:
139
+ raise click.ClickException(
140
+ f"Profile '{profile_name}' not found in DynamoDB.\n"
141
+ f"Run: cc profile create --name {profile_name} --accounts \"...\""
142
+ )
143
+ except requests.exceptions.RequestException as e:
144
+ raise click.ClickException(
145
+ f"Failed to fetch profile from API: {e}\n"
146
+ )
147
+
148
+ # Fallback to local file if no API secret
80
149
  if config_file.exists():
81
150
  with open(config_file) as f:
82
151
  profiles = json.load(f)
@@ -114,50 +183,11 @@ def load_profile(profile_name):
114
183
 
115
184
  return profile
116
185
 
117
- # Profile not found locally - try DynamoDB API
118
- api_secret = os.environ.get('COST_API_SECRET')
119
- if not api_secret:
120
- raise click.ClickException(
121
- f"Profile '{profile_name}' not found locally and COST_API_SECRET not set.\n"
122
- f"Run: cc init --profile {profile_name}"
123
- )
124
-
125
- try:
126
- response = requests.post(
127
- 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
128
- headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
129
- json={'operation': 'get', 'profile_name': profile_name},
130
- timeout=10
131
- )
132
-
133
- if response.status_code == 200:
134
- response_data = response.json()
135
- # API returns {"profile": {...}} wrapper
136
- profile_data = response_data.get('profile', response_data)
137
- profile = {'accounts': profile_data['accounts']}
138
-
139
- # Check for AWS_PROFILE environment variable (SSO support)
140
- if os.environ.get('AWS_PROFILE'):
141
- profile['aws_profile'] = os.environ['AWS_PROFILE']
142
- # Use environment credentials
143
- elif os.environ.get('AWS_ACCESS_KEY_ID'):
144
- profile['credentials'] = {
145
- 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
146
- 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
147
- 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
148
- }
149
-
150
- return profile
151
- else:
152
- raise click.ClickException(
153
- f"Profile '{profile_name}' not found in DynamoDB.\n"
154
- f"Run: cc profile create --name {profile_name} --accounts \"...\""
155
- )
156
- except requests.exceptions.RequestException as e:
157
- raise click.ClickException(
158
- f"Failed to fetch profile from API: {e}\n"
159
- f"Run: cc init --profile {profile_name}"
160
- )
186
+ # Profile not found anywhere
187
+ raise click.ClickException(
188
+ f"Profile '{profile_name}' not found.\n"
189
+ f"Run: cc profile create --name {profile_name} --accounts \"...\""
190
+ )
161
191
 
162
192
 
163
193
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -1175,6 +1205,27 @@ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
1175
1205
  if sso:
1176
1206
  config['aws_profile'] = sso
1177
1207
 
1208
+ # Validate that we have a way to get credentials
1209
+ if 'aws_profile' not in config and 'credentials' not in config:
1210
+ import subprocess
1211
+ try:
1212
+ result = subprocess.run(
1213
+ ['aws', 'configure', 'list-profiles'],
1214
+ capture_output=True,
1215
+ text=True,
1216
+ timeout=5
1217
+ )
1218
+ available = result.stdout.strip().split('\n') if result.returncode == 0 else []
1219
+ suggestion = f"\nAvailable AWS profiles: {', '.join(available[:5])}" if available else ""
1220
+ except:
1221
+ suggestion = ""
1222
+
1223
+ raise click.ClickException(
1224
+ f"Profile '{profile}' has no AWS authentication configured.\n"
1225
+ f"Use --sso flag to specify your AWS SSO profile:\n"
1226
+ f" cc investigate --profile {profile} --sso YOUR_AWS_PROFILE{suggestion}"
1227
+ )
1228
+
1178
1229
  # Step 1: Cost Analysis
1179
1230
  click.echo("Step 1/3: Analyzing cost trends...")
1180
1231
  try:
@@ -1218,27 +1269,23 @@ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
1218
1269
  click.echo(f" ⚠️ No credentials available for account")
1219
1270
  continue
1220
1271
 
1221
- # Inventory resources via backend API
1272
+ # Inventory resources via backend API only
1273
+ if not is_api_configured():
1274
+ click.echo(f" ✗ API not configured. Set COST_API_SECRET environment variable.")
1275
+ continue
1276
+
1222
1277
  try:
1223
1278
  regions = ['us-west-2', 'us-east-1', 'eu-west-1']
1224
1279
  for region in regions:
1225
1280
  try:
1226
- if is_api_configured():
1227
- inv = call_lambda_api(
1228
- 'forensics',
1229
- account_creds,
1230
- [], # accounts not needed for forensics
1231
- operation='inventory',
1232
- account_id=acc_id,
1233
- region=region
1234
- )
1235
- else:
1236
- # Fallback to local execution
1237
- from cost_calculator.forensics import inventory_resources
1238
- acc_profile = find_account_profile(acc_id)
1239
- if not acc_profile:
1240
- raise Exception("No SSO profile found and API not configured")
1241
- inv = inventory_resources(acc_id, acc_profile, region)
1281
+ inv = call_lambda_api(
1282
+ 'forensics',
1283
+ account_creds,
1284
+ [], # accounts not needed for forensics
1285
+ operation='inventory',
1286
+ account_id=acc_id,
1287
+ region=region
1288
+ )
1242
1289
 
1243
1290
  if not inv.get('error'):
1244
1291
  inventories.append(inv)
@@ -1252,13 +1299,15 @@ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
1252
1299
  except Exception as e:
1253
1300
  click.echo(f" ✗ Inventory error: {str(e)}")
1254
1301
 
1255
- # CloudTrail analysis via backend API
1302
+ # CloudTrail analysis via backend API only
1256
1303
  if not no_cloudtrail:
1257
- try:
1258
- start_date = (datetime.now() - timedelta(days=weeks * 7)).isoformat() + 'Z'
1259
- end_date = datetime.now().isoformat() + 'Z'
1260
-
1261
- if is_api_configured():
1304
+ if not is_api_configured():
1305
+ click.echo(f" ✗ CloudTrail skipped: API not configured")
1306
+ else:
1307
+ try:
1308
+ start_date = (datetime.now() - timedelta(days=weeks * 7)).isoformat() + 'Z'
1309
+ end_date = datetime.now().isoformat() + 'Z'
1310
+
1262
1311
  ct_analysis = call_lambda_api(
1263
1312
  'forensics',
1264
1313
  account_creds,
@@ -1269,26 +1318,17 @@ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
1269
1318
  end_date=end_date,
1270
1319
  region='us-west-2'
1271
1320
  )
1272
- else:
1273
- # Fallback to local execution
1274
- from cost_calculator.forensics import analyze_cloudtrail
1275
- acc_profile = find_account_profile(acc_id)
1276
- if not acc_profile:
1277
- raise Exception("No SSO profile found and API not configured")
1278
- start_dt = datetime.now() - timedelta(days=weeks * 7)
1279
- end_dt = datetime.now()
1280
- ct_analysis = analyze_cloudtrail(acc_id, acc_profile, start_dt, end_dt)
1281
-
1282
- cloudtrail_analyses.append(ct_analysis)
1283
-
1284
- if ct_analysis.get('error'):
1285
- click.echo(f" ⚠️ CloudTrail: {ct_analysis['error']}")
1286
- else:
1287
- click.echo(f" ✓ CloudTrail analysis complete")
1288
- click.echo(f" - {len(ct_analysis['event_summary'])} event types")
1289
- click.echo(f" - {len(ct_analysis['write_events'])} resource changes")
1290
- except Exception as e:
1291
- click.echo(f" ✗ CloudTrail error: {str(e)}")
1321
+
1322
+ cloudtrail_analyses.append(ct_analysis)
1323
+
1324
+ if ct_analysis.get('error'):
1325
+ click.echo(f" ⚠️ CloudTrail: {ct_analysis['error']}")
1326
+ else:
1327
+ click.echo(f" ✓ CloudTrail analysis complete")
1328
+ click.echo(f" - {len(ct_analysis['event_summary'])} event types")
1329
+ click.echo(f" - {len(ct_analysis['write_events'])} resource changes")
1330
+ except Exception as e:
1331
+ click.echo(f" ✗ CloudTrail error: {str(e)}")
1292
1332
 
1293
1333
  # Generate report
1294
1334
  click.echo(f"\nGenerating report...")
@@ -1340,5 +1380,638 @@ def find_account_profile(account_id):
1340
1380
  return None
1341
1381
 
1342
1382
 
1383
+ @cli.command()
1384
+ @click.option('--profile', required=True, help='Profile name')
1385
+ @click.option('--start-date', help='Start date (YYYY-MM-DD)')
1386
+ @click.option('--end-date', help='End date (YYYY-MM-DD)')
1387
+ @click.option('--days', type=int, default=10, help='Number of days to analyze (default: 10)')
1388
+ @click.option('--service', help='Filter by service name')
1389
+ @click.option('--account', help='Filter by account ID')
1390
+ @click.option('--sso', help='AWS SSO profile name')
1391
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1392
+ def daily(profile, start_date, end_date, days, service, account, sso, output_json):
1393
+ """
1394
+ Get daily cost breakdown with granular detail.
1395
+
1396
+ Shows day-by-day costs for specific services and accounts, useful for:
1397
+ - Identifying cost spikes on specific dates
1398
+ - Validating daily cost patterns
1399
+ - Calculating precise daily averages
1400
+
1401
+ Examples:
1402
+ # Last 10 days of CloudWatch costs for specific account
1403
+ cc daily --profile khoros --days 10 --service AmazonCloudWatch --account 820054669588
1404
+
1405
+ # Custom date range with JSON output for automation
1406
+ cc daily --profile khoros --start-date 2025-10-28 --end-date 2025-11-06 --json
1407
+
1408
+ # Find high-cost days using jq
1409
+ cc daily --profile khoros --days 30 --json | jq '.daily_costs | map(select(.cost > 1000))'
1410
+ """
1411
+ # Load profile
1412
+ config = load_profile(profile)
1413
+
1414
+ # Apply SSO if provided
1415
+ if sso:
1416
+ config['aws_profile'] = sso
1417
+
1418
+ # Calculate date range
1419
+ if end_date:
1420
+ end = datetime.strptime(end_date, '%Y-%m-%d')
1421
+ else:
1422
+ end = datetime.now()
1423
+
1424
+ if start_date:
1425
+ start = datetime.strptime(start_date, '%Y-%m-%d')
1426
+ else:
1427
+ start = end - timedelta(days=days)
1428
+
1429
+ click.echo(f"Daily breakdown: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
1430
+ if service:
1431
+ click.echo(f"Service filter: {service}")
1432
+ if account:
1433
+ click.echo(f"Account filter: {account}")
1434
+ click.echo("")
1435
+
1436
+ # Get credentials
1437
+ try:
1438
+ if 'aws_profile' in config:
1439
+ session = boto3.Session(profile_name=config['aws_profile'])
1440
+ else:
1441
+ creds = config['credentials']
1442
+ session = boto3.Session(
1443
+ aws_access_key_id=creds['aws_access_key_id'],
1444
+ aws_secret_access_key=creds['aws_secret_access_key'],
1445
+ aws_session_token=creds.get('aws_session_token')
1446
+ )
1447
+
1448
+ ce_client = session.client('ce', region_name='us-east-1')
1449
+
1450
+ # Build filter
1451
+ filter_parts = []
1452
+
1453
+ # Account filter
1454
+ if account:
1455
+ filter_parts.append({
1456
+ "Dimensions": {
1457
+ "Key": "LINKED_ACCOUNT",
1458
+ "Values": [account]
1459
+ }
1460
+ })
1461
+ else:
1462
+ filter_parts.append({
1463
+ "Dimensions": {
1464
+ "Key": "LINKED_ACCOUNT",
1465
+ "Values": config['accounts']
1466
+ }
1467
+ })
1468
+
1469
+ # Service filter
1470
+ if service:
1471
+ filter_parts.append({
1472
+ "Dimensions": {
1473
+ "Key": "SERVICE",
1474
+ "Values": [service]
1475
+ }
1476
+ })
1477
+
1478
+ # Exclude support and tax
1479
+ filter_parts.append({
1480
+ "Not": {
1481
+ "Dimensions": {
1482
+ "Key": "RECORD_TYPE",
1483
+ "Values": ["Tax", "Support"]
1484
+ }
1485
+ }
1486
+ })
1487
+
1488
+ cost_filter = {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
1489
+
1490
+ # Get daily costs
1491
+ response = ce_client.get_cost_and_usage(
1492
+ TimePeriod={
1493
+ 'Start': start.strftime('%Y-%m-%d'),
1494
+ 'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
1495
+ },
1496
+ Granularity='DAILY',
1497
+ Metrics=['UnblendedCost'],
1498
+ Filter=cost_filter
1499
+ )
1500
+
1501
+ # Collect results
1502
+ daily_costs = []
1503
+ total = 0
1504
+ for day in response['ResultsByTime']:
1505
+ date = day['TimePeriod']['Start']
1506
+ cost = float(day['Total']['UnblendedCost']['Amount'])
1507
+ total += cost
1508
+ daily_costs.append({'date': date, 'cost': cost})
1509
+
1510
+ num_days = len(response['ResultsByTime'])
1511
+ daily_avg = total / num_days if num_days > 0 else 0
1512
+ annual = daily_avg * 365
1513
+
1514
+ # Output results
1515
+ if output_json:
1516
+ import json
1517
+ result = {
1518
+ 'period': {
1519
+ 'start': start.strftime('%Y-%m-%d'),
1520
+ 'end': end.strftime('%Y-%m-%d'),
1521
+ 'days': num_days
1522
+ },
1523
+ 'filters': {
1524
+ 'service': service,
1525
+ 'account': account
1526
+ },
1527
+ 'daily_costs': daily_costs,
1528
+ 'summary': {
1529
+ 'total': total,
1530
+ 'daily_avg': daily_avg,
1531
+ 'annual_projection': annual
1532
+ }
1533
+ }
1534
+ click.echo(json.dumps(result, indent=2))
1535
+ else:
1536
+ click.echo("Date | Cost")
1537
+ click.echo("-----------|-----------")
1538
+ for item in daily_costs:
1539
+ click.echo(f"{item['date']} | ${item['cost']:,.2f}")
1540
+ click.echo("-----------|-----------")
1541
+ click.echo(f"Total | ${total:,.2f}")
1542
+ click.echo(f"Daily Avg | ${daily_avg:,.2f}")
1543
+ click.echo(f"Annual | ${annual:,.0f}")
1544
+
1545
+ except Exception as e:
1546
+ raise click.ClickException(f"Failed to get daily costs: {e}")
1547
+
1548
+
1549
+ @cli.command()
1550
+ @click.option('--profile', required=True, help='Profile name')
1551
+ @click.option('--account', help='Account ID to compare')
1552
+ @click.option('--service', help='Service to compare')
1553
+ @click.option('--before', required=True, help='Before period (YYYY-MM-DD:YYYY-MM-DD)')
1554
+ @click.option('--after', required=True, help='After period (YYYY-MM-DD:YYYY-MM-DD)')
1555
+ @click.option('--expected-reduction', type=float, help='Expected reduction percentage')
1556
+ @click.option('--sso', help='AWS SSO profile name')
1557
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1558
+ def compare(profile, account, service, before, after, expected_reduction, sso, output_json):
1559
+ """
1560
+ Compare costs between two periods for validation and analysis.
1561
+
1562
+ Perfect for:
1563
+ - Validating cost optimization savings
1564
+ - Before/after migration analysis
1565
+ - Measuring impact of infrastructure changes
1566
+ - Automated savings validation in CI/CD
1567
+
1568
+ Examples:
1569
+ # Validate Datadog migration savings (expect 50% reduction)
1570
+ cc compare --profile khoros --account 180770971501 --service AmazonCloudWatch \
1571
+ --before "2025-10-28:2025-11-06" --after "2025-11-17:2025-11-26" --expected-reduction 50
1572
+
1573
+ # Compare total costs across all accounts
1574
+ cc compare --profile khoros --before "2025-10-01:2025-10-31" --after "2025-11-01:2025-11-30"
1575
+
1576
+ # JSON output for automated validation
1577
+ cc compare --profile khoros --service EC2 --before "2025-10-01:2025-10-07" \
1578
+ --after "2025-11-08:2025-11-14" --json | jq '.comparison.met_expectation'
1579
+ """
1580
+ # Load profile
1581
+ config = load_profile(profile)
1582
+
1583
+ # Apply SSO if provided
1584
+ if sso:
1585
+ config['aws_profile'] = sso
1586
+
1587
+ # Parse periods
1588
+ try:
1589
+ before_start, before_end = before.split(':')
1590
+ after_start, after_end = after.split(':')
1591
+ except ValueError:
1592
+ raise click.ClickException("Period format must be 'YYYY-MM-DD:YYYY-MM-DD'")
1593
+
1594
+ if not output_json:
1595
+ click.echo(f"Comparing periods:")
1596
+ click.echo(f" Before: {before_start} to {before_end}")
1597
+ click.echo(f" After: {after_start} to {after_end}")
1598
+ if service:
1599
+ click.echo(f" Service: {service}")
1600
+ if account:
1601
+ click.echo(f" Account: {account}")
1602
+ click.echo("")
1603
+
1604
+ # Get credentials
1605
+ try:
1606
+ if 'aws_profile' in config:
1607
+ session = boto3.Session(profile_name=config['aws_profile'])
1608
+ else:
1609
+ creds = config['credentials']
1610
+ session = boto3.Session(
1611
+ aws_access_key_id=creds['aws_access_key_id'],
1612
+ aws_secret_access_key=creds['aws_secret_access_key'],
1613
+ aws_session_token=creds.get('aws_session_token')
1614
+ )
1615
+
1616
+ ce_client = session.client('ce', region_name='us-east-1')
1617
+
1618
+ # Build filter
1619
+ def build_filter():
1620
+ filter_parts = []
1621
+
1622
+ if account:
1623
+ filter_parts.append({
1624
+ "Dimensions": {
1625
+ "Key": "LINKED_ACCOUNT",
1626
+ "Values": [account]
1627
+ }
1628
+ })
1629
+ else:
1630
+ filter_parts.append({
1631
+ "Dimensions": {
1632
+ "Key": "LINKED_ACCOUNT",
1633
+ "Values": config['accounts']
1634
+ }
1635
+ })
1636
+
1637
+ if service:
1638
+ filter_parts.append({
1639
+ "Dimensions": {
1640
+ "Key": "SERVICE",
1641
+ "Values": [service]
1642
+ }
1643
+ })
1644
+
1645
+ filter_parts.append({
1646
+ "Not": {
1647
+ "Dimensions": {
1648
+ "Key": "RECORD_TYPE",
1649
+ "Values": ["Tax", "Support"]
1650
+ }
1651
+ }
1652
+ })
1653
+
1654
+ return {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
1655
+
1656
+ cost_filter = build_filter()
1657
+
1658
+ # Get before period costs
1659
+ before_response = ce_client.get_cost_and_usage(
1660
+ TimePeriod={
1661
+ 'Start': before_start,
1662
+ 'End': (datetime.strptime(before_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
1663
+ },
1664
+ Granularity='DAILY',
1665
+ Metrics=['UnblendedCost'],
1666
+ Filter=cost_filter
1667
+ )
1668
+
1669
+ # Get after period costs
1670
+ after_response = ce_client.get_cost_and_usage(
1671
+ TimePeriod={
1672
+ 'Start': after_start,
1673
+ 'End': (datetime.strptime(after_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
1674
+ },
1675
+ Granularity='DAILY',
1676
+ Metrics=['UnblendedCost'],
1677
+ Filter=cost_filter
1678
+ )
1679
+
1680
+ # Calculate totals
1681
+ before_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in before_response['ResultsByTime'])
1682
+ after_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in after_response['ResultsByTime'])
1683
+
1684
+ before_days = len(before_response['ResultsByTime'])
1685
+ after_days = len(after_response['ResultsByTime'])
1686
+
1687
+ before_daily = before_total / before_days if before_days > 0 else 0
1688
+ after_daily = after_total / after_days if after_days > 0 else 0
1689
+
1690
+ reduction = before_daily - after_daily
1691
+ reduction_pct = (reduction / before_daily * 100) if before_daily > 0 else 0
1692
+ annual_savings = reduction * 365
1693
+
1694
+ # Output results
1695
+ if output_json:
1696
+ import json
1697
+ result = {
1698
+ 'before': {
1699
+ 'period': {'start': before_start, 'end': before_end},
1700
+ 'total': before_total,
1701
+ 'daily_avg': before_daily,
1702
+ 'days': before_days
1703
+ },
1704
+ 'after': {
1705
+ 'period': {'start': after_start, 'end': after_end},
1706
+ 'total': after_total,
1707
+ 'daily_avg': after_daily,
1708
+ 'days': after_days
1709
+ },
1710
+ 'comparison': {
1711
+ 'daily_reduction': reduction,
1712
+ 'reduction_pct': reduction_pct,
1713
+ 'annual_savings': annual_savings
1714
+ }
1715
+ }
1716
+
1717
+ if expected_reduction is not None:
1718
+ result['comparison']['expected_reduction_pct'] = expected_reduction
1719
+ result['comparison']['met_expectation'] = reduction_pct >= expected_reduction
1720
+
1721
+ click.echo(json.dumps(result, indent=2))
1722
+ else:
1723
+ click.echo("Before Period:")
1724
+ click.echo(f" Total: ${before_total:,.2f}")
1725
+ click.echo(f" Daily Avg: ${before_daily:,.2f}")
1726
+ click.echo(f" Days: {before_days}")
1727
+ click.echo("")
1728
+ click.echo("After Period:")
1729
+ click.echo(f" Total: ${after_total:,.2f}")
1730
+ click.echo(f" Daily Avg: ${after_daily:,.2f}")
1731
+ click.echo(f" Days: {after_days}")
1732
+ click.echo("")
1733
+ click.echo("Comparison:")
1734
+ click.echo(f" Daily Reduction: ${reduction:,.2f}")
1735
+ click.echo(f" Reduction %: {reduction_pct:.1f}%")
1736
+ click.echo(f" Annual Savings: ${annual_savings:,.0f}")
1737
+
1738
+ if expected_reduction is not None:
1739
+ click.echo("")
1740
+ if reduction_pct >= expected_reduction:
1741
+ click.echo(f"✅ Savings achieved: {reduction_pct:.1f}% (expected {expected_reduction}%)")
1742
+ else:
1743
+ click.echo(f"⚠️ Below target: {reduction_pct:.1f}% (expected {expected_reduction}%)")
1744
+
1745
+ except Exception as e:
1746
+ raise click.ClickException(f"Comparison failed: {e}")
1747
+
1748
+
1749
+ @cli.command()
1750
+ @click.option('--profile', required=True, help='Profile name')
1751
+ @click.option('--tag-key', required=True, help='Tag key to filter by')
1752
+ @click.option('--tag-value', help='Tag value to filter by (optional)')
1753
+ @click.option('--start-date', help='Start date (YYYY-MM-DD)')
1754
+ @click.option('--end-date', help='End date (YYYY-MM-DD)')
1755
+ @click.option('--days', type=int, default=30, help='Number of days to analyze (default: 30)')
1756
+ @click.option('--sso', help='AWS SSO profile name')
1757
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1758
+ def tags(profile, tag_key, tag_value, start_date, end_date, days, sso, output_json):
1759
+ """
1760
+ Analyze costs grouped by resource tags for cost attribution.
1761
+
1762
+ Useful for:
1763
+ - Cost allocation by team, project, or environment
1764
+ - Identifying untagged resources (cost attribution gaps)
1765
+ - Tracking costs by cost center or department
1766
+ - Validating tagging compliance
1767
+
1768
+ Examples:
1769
+ # See all costs by Environment tag
1770
+ cc tags --profile khoros --tag-key "Environment" --days 30
1771
+
1772
+ # Filter to specific tag value
1773
+ cc tags --profile khoros --tag-key "Team" --tag-value "Platform" --days 30
1774
+
1775
+ # Find top cost centers with JSON output
1776
+ cc tags --profile khoros --tag-key "CostCenter" --days 30 --json | \
1777
+ jq '.tag_costs | sort_by(-.cost) | .[:5]'
1778
+
1779
+ # Identify untagged resources (look for empty tag values)
1780
+ cc tags --profile khoros --tag-key "Owner" --days 7
1781
+ """
1782
+ # Load profile
1783
+ config = load_profile(profile)
1784
+
1785
+ # Apply SSO if provided
1786
+ if sso:
1787
+ config['aws_profile'] = sso
1788
+
1789
+ # Calculate date range
1790
+ if end_date:
1791
+ end = datetime.strptime(end_date, '%Y-%m-%d')
1792
+ else:
1793
+ end = datetime.now()
1794
+
1795
+ if start_date:
1796
+ start = datetime.strptime(start_date, '%Y-%m-%d')
1797
+ else:
1798
+ start = end - timedelta(days=days)
1799
+
1800
+ if not output_json:
1801
+ click.echo(f"Tag-based cost analysis: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
1802
+ click.echo(f"Tag key: {tag_key}")
1803
+ if tag_value:
1804
+ click.echo(f"Tag value: {tag_value}")
1805
+ click.echo("")
1806
+
1807
+ # Get credentials
1808
+ try:
1809
+ if 'aws_profile' in config:
1810
+ session = boto3.Session(profile_name=config['aws_profile'])
1811
+ else:
1812
+ creds = config['credentials']
1813
+ session = boto3.Session(
1814
+ aws_access_key_id=creds['aws_access_key_id'],
1815
+ aws_secret_access_key=creds['aws_secret_access_key'],
1816
+ aws_session_token=creds.get('aws_session_token')
1817
+ )
1818
+
1819
+ ce_client = session.client('ce', region_name='us-east-1')
1820
+
1821
+ # Build filter
1822
+ filter_parts = [
1823
+ {
1824
+ "Dimensions": {
1825
+ "Key": "LINKED_ACCOUNT",
1826
+ "Values": config['accounts']
1827
+ }
1828
+ },
1829
+ {
1830
+ "Not": {
1831
+ "Dimensions": {
1832
+ "Key": "RECORD_TYPE",
1833
+ "Values": ["Tax", "Support"]
1834
+ }
1835
+ }
1836
+ }
1837
+ ]
1838
+
1839
+ # Add tag filter if value specified
1840
+ if tag_value:
1841
+ filter_parts.append({
1842
+ "Tags": {
1843
+ "Key": tag_key,
1844
+ "Values": [tag_value]
1845
+ }
1846
+ })
1847
+
1848
+ cost_filter = {"And": filter_parts}
1849
+
1850
+ # Get costs grouped by tag values
1851
+ response = ce_client.get_cost_and_usage(
1852
+ TimePeriod={
1853
+ 'Start': start.strftime('%Y-%m-%d'),
1854
+ 'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
1855
+ },
1856
+ Granularity='MONTHLY',
1857
+ Metrics=['UnblendedCost'],
1858
+ GroupBy=[{
1859
+ 'Type': 'TAG',
1860
+ 'Key': tag_key
1861
+ }],
1862
+ Filter=cost_filter
1863
+ )
1864
+
1865
+ # Collect results
1866
+ tag_costs = {}
1867
+ for period in response['ResultsByTime']:
1868
+ for group in period['Groups']:
1869
+ tag_val = group['Keys'][0].split('$')[1] if '$' in group['Keys'][0] else group['Keys'][0]
1870
+ cost = float(group['Metrics']['UnblendedCost']['Amount'])
1871
+ tag_costs[tag_val] = tag_costs.get(tag_val, 0) + cost
1872
+
1873
+ # Sort by cost
1874
+ sorted_tags = sorted(tag_costs.items(), key=lambda x: x[1], reverse=True)
1875
+
1876
+ total = sum(tag_costs.values())
1877
+ num_days = (end - start).days
1878
+ daily_avg = total / num_days if num_days > 0 else 0
1879
+
1880
+ # Output results
1881
+ if output_json:
1882
+ import json
1883
+ result = {
1884
+ 'period': {
1885
+ 'start': start.strftime('%Y-%m-%d'),
1886
+ 'end': end.strftime('%Y-%m-%d'),
1887
+ 'days': num_days
1888
+ },
1889
+ 'tag_key': tag_key,
1890
+ 'tag_value_filter': tag_value,
1891
+ 'tag_costs': [{'tag_value': k, 'cost': v, 'percentage': (v/total*100) if total > 0 else 0} for k, v in sorted_tags],
1892
+ 'summary': {
1893
+ 'total': total,
1894
+ 'daily_avg': daily_avg,
1895
+ 'annual_projection': daily_avg * 365
1896
+ }
1897
+ }
1898
+ click.echo(json.dumps(result, indent=2))
1899
+ else:
1900
+ click.echo(f"Tag Value{' '*(30-len('Tag Value'))} | Cost | %")
1901
+ click.echo("-" * 60)
1902
+ for tag_val, cost in sorted_tags:
1903
+ pct = (cost / total * 100) if total > 0 else 0
1904
+ tag_display = tag_val[:30].ljust(30)
1905
+ click.echo(f"{tag_display} | ${cost:>9,.2f} | {pct:>5.1f}%")
1906
+ click.echo("-" * 60)
1907
+ click.echo(f"{'Total'.ljust(30)} | ${total:>9,.2f} | 100.0%")
1908
+ click.echo("")
1909
+ click.echo(f"Daily Avg: ${daily_avg:,.2f}")
1910
+ click.echo(f"Annual Projection: ${daily_avg * 365:,.0f}")
1911
+
1912
+ except Exception as e:
1913
+ raise click.ClickException(f"Tag analysis failed: {e}")
1914
+
1915
+
1916
+ @cli.command()
1917
+ @click.option('--profile', required=True, help='Profile name')
1918
+ @click.option('--query', required=True, help='SQL query to execute')
1919
+ @click.option('--database', default='athenacurcfn_cloud_intelligence_dashboard', help='Athena database name')
1920
+ @click.option('--output-bucket', help='S3 bucket for query results (default: from profile)')
1921
+ @click.option('--sso', help='AWS SSO profile name')
1922
+ def query(profile, query, database, output_bucket, sso):
1923
+ """
1924
+ Execute custom Athena SQL query on CUR data
1925
+
1926
+ Example:
1927
+ cc query --profile khoros --query "SELECT line_item_usage_account_id, SUM(line_item_unblended_cost) as cost FROM cloud_intelligence_dashboard WHERE line_item_usage_start_date >= DATE '2025-11-01' GROUP BY 1 ORDER BY 2 DESC LIMIT 10"
1928
+ """
1929
+ # Load profile
1930
+ config = load_profile(profile)
1931
+
1932
+ # Apply SSO if provided
1933
+ if sso:
1934
+ config['aws_profile'] = sso
1935
+
1936
+ # Get credentials
1937
+ try:
1938
+ if 'aws_profile' in config:
1939
+ session = boto3.Session(profile_name=config['aws_profile'])
1940
+ else:
1941
+ creds = config['credentials']
1942
+ session = boto3.Session(
1943
+ aws_access_key_id=creds['aws_access_key_id'],
1944
+ aws_secret_access_key=creds['aws_secret_access_key'],
1945
+ aws_session_token=creds.get('aws_session_token')
1946
+ )
1947
+
1948
+ athena_client = session.client('athena', region_name='us-east-1')
1949
+
1950
+ # Default output location
1951
+ if not output_bucket:
1952
+ output_bucket = 's3://khoros-finops-athena/athena/'
1953
+
1954
+ click.echo(f"Executing query on database: {database}")
1955
+ click.echo(f"Output location: {output_bucket}")
1956
+ click.echo("")
1957
+
1958
+ # Execute query
1959
+ response = athena_client.start_query_execution(
1960
+ QueryString=query,
1961
+ QueryExecutionContext={'Database': database},
1962
+ ResultConfiguration={'OutputLocation': output_bucket}
1963
+ )
1964
+
1965
+ query_id = response['QueryExecutionId']
1966
+ click.echo(f"Query ID: {query_id}")
1967
+ click.echo("Waiting for query to complete...")
1968
+
1969
+ # Wait for completion
1970
+ import time
1971
+ max_wait = 60
1972
+ waited = 0
1973
+ while waited < max_wait:
1974
+ status_response = athena_client.get_query_execution(QueryExecutionId=query_id)
1975
+ status = status_response['QueryExecution']['Status']['State']
1976
+
1977
+ if status == 'SUCCEEDED':
1978
+ click.echo("✓ Query completed successfully")
1979
+ break
1980
+ elif status in ['FAILED', 'CANCELLED']:
1981
+ reason = status_response['QueryExecution']['Status'].get('StateChangeReason', 'Unknown')
1982
+ raise click.ClickException(f"Query {status}: {reason}")
1983
+
1984
+ time.sleep(2)
1985
+ waited += 2
1986
+
1987
+ if waited >= max_wait:
1988
+ raise click.ClickException(f"Query timeout after {max_wait}s. Check query ID: {query_id}")
1989
+
1990
+ # Get results
1991
+ results = athena_client.get_query_results(QueryExecutionId=query_id)
1992
+
1993
+ # Display results
1994
+ rows = results['ResultSet']['Rows']
1995
+ if not rows:
1996
+ click.echo("No results returned")
1997
+ return
1998
+
1999
+ # Header
2000
+ headers = [col['VarCharValue'] for col in rows[0]['Data']]
2001
+ click.echo(" | ".join(headers))
2002
+ click.echo("-" * (len(" | ".join(headers))))
2003
+
2004
+ # Data rows
2005
+ for row in rows[1:]:
2006
+ values = [col.get('VarCharValue', '') for col in row['Data']]
2007
+ click.echo(" | ".join(values))
2008
+
2009
+ click.echo("")
2010
+ click.echo(f"Returned {len(rows)-1} rows")
2011
+
2012
+ except Exception as e:
2013
+ raise click.ClickException(f"Query failed: {e}")
2014
+
2015
+
1343
2016
  if __name__ == '__main__':
1344
2017
  cli()
@@ -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,37 +122,23 @@ 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
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.
127
142
 
128
143
  Args:
129
144
  config: Profile configuration
@@ -138,86 +153,31 @@ def execute_drill(config, weeks, service_filter=None, account_filter=None, usage
138
153
  """
139
154
  accounts = config['accounts']
140
155
 
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."
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
141
175
  if resources:
142
- # Resource-level drill requires service filter
143
176
  if not service_filter:
144
177
  raise click.ClickException("--service is required when using --resources flag")
145
-
146
- if is_api_configured():
147
- # Use API
148
- click.echo("Using Lambda API for CUR resource query...")
149
- credentials = get_credentials_dict(config)
150
- kwargs = {
151
- 'weeks': weeks,
152
- 'service': service_filter,
153
- 'resources': True
154
- }
155
- if account_filter:
156
- kwargs['account'] = account_filter
157
- return call_lambda_api('drill', credentials, accounts, **kwargs)
158
- else:
159
- # Use local Athena client
160
- click.echo("Using local Athena client for CUR resource query...")
161
- from cost_calculator.cur import query_cur_resources
162
-
163
- # Initialize boto3 session
164
- if 'aws_profile' in config:
165
- session = boto3.Session(profile_name=config['aws_profile'])
166
- else:
167
- creds = config['credentials']
168
- session_kwargs = {
169
- 'aws_access_key_id': creds['aws_access_key_id'],
170
- 'aws_secret_access_key': creds['aws_secret_access_key'],
171
- 'region_name': creds.get('region', 'us-east-1')
172
- }
173
- if 'aws_session_token' in creds:
174
- session_kwargs['aws_session_token'] = creds['aws_session_token']
175
- session = boto3.Session(**session_kwargs)
176
-
177
- athena_client = session.client('athena', region_name='us-east-1')
178
- return query_cur_resources(
179
- athena_client, accounts, service_filter, account_filter, weeks
180
- )
181
- else:
182
- # Standard drill-down via Cost Explorer
183
- if is_api_configured():
184
- # Use API
185
- click.echo("Using Lambda API...")
186
- credentials = get_credentials_dict(config)
187
- kwargs = {'weeks': weeks}
188
- if service_filter:
189
- kwargs['service'] = service_filter
190
- if account_filter:
191
- kwargs['account'] = account_filter
192
- if usage_type_filter:
193
- kwargs['usage_type'] = usage_type_filter
194
- return call_lambda_api('drill', credentials, accounts, **kwargs)
195
- else:
196
- # Use local execution
197
- click.echo("Using local execution...")
198
- from cost_calculator.drill import analyze_drill_down
199
-
200
- # Initialize boto3 client
201
- if 'aws_profile' in config:
202
- session = boto3.Session(profile_name=config['aws_profile'])
203
- else:
204
- creds = config['credentials']
205
- session_kwargs = {
206
- 'aws_access_key_id': creds['aws_access_key_id'],
207
- 'aws_secret_access_key': creds['aws_secret_access_key'],
208
- 'region_name': creds.get('region', 'us-east-1')
209
- }
210
- if 'aws_session_token' in creds:
211
- session_kwargs['aws_session_token'] = creds['aws_session_token']
212
- session = boto3.Session(**session_kwargs)
213
-
214
- ce_client = session.client('ce', region_name='us-east-1')
215
- return analyze_drill_down(
216
- ce_client, accounts, weeks,
217
- service_filter=service_filter,
218
- account_filter=account_filter,
219
- usage_type_filter=usage_type_filter
220
- )
178
+ kwargs['resources'] = True
179
+
180
+ return call_lambda_api('drill', credentials, accounts, **kwargs)
221
181
 
222
182
 
223
183
  def execute_analyze(config, weeks, analysis_type, pattern=None, min_cost=None):
@@ -236,7 +236,8 @@ def format_investigation_report(cost_data, inventories, cloudtrail_data=None):
236
236
  report.append("")
237
237
 
238
238
  for inv in inventories:
239
- report.append(f"### Account {inv['account_id']} ({inv['profile']})")
239
+ profile_name = inv.get('profile', inv['account_id'])
240
+ report.append(f"### Account {inv['account_id']} ({profile_name})")
240
241
  report.append(f"**Region:** {inv['region']}")
241
242
  report.append("")
242
243
 
@@ -288,7 +289,8 @@ def format_investigation_report(cost_data, inventories, cloudtrail_data=None):
288
289
  report.append("")
289
290
 
290
291
  for ct in cloudtrail_data:
291
- report.append(f"### Account {ct['account_id']} ({ct['profile']})")
292
+ profile_name = ct.get('profile', ct['account_id'])
293
+ report.append(f"### Account {ct['account_id']} ({profile_name})")
292
294
  report.append(f"**Period:** {ct['start_date'][:10]} to {ct['end_date'][:10]}")
293
295
  report.append("")
294
296
 
@@ -1,15 +0,0 @@
1
- aws_cost_calculator_cli-1.8.2.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
2
- cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
3
- cost_calculator/api_client.py,sha256=4ZI2XcGIN3FBeQqb7xOxQ91kCoeM43-rExiOELXoKBQ,2485
4
- cost_calculator/cli.py,sha256=jkyPlHAdVgpk5LrP1T9YvBK4-K6hM5fd538VqIHw-7A,52255
5
- cost_calculator/cur.py,sha256=QaZ_nyDSw5_cti-h5Ho6eYLbqzY5TWoub24DpyzIiSs,9502
6
- cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
7
- cost_calculator/executor.py,sha256=aWLELeisDu6b-5U5LDPDPnK5uJKilYcoUPerxPUqtYg,10435
8
- cost_calculator/forensics.py,sha256=Ny5IbMhJF6kawQDiLDExQGb5C-zRDbbmziS5JoinmdA,12694
9
- cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
10
- cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
11
- aws_cost_calculator_cli-1.8.2.dist-info/METADATA,sha256=wCkJT5XL6m3ESmbK0mdfzSpkWiJn9PJQ6nuUBelILXU,11978
12
- aws_cost_calculator_cli-1.8.2.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
13
- aws_cost_calculator_cli-1.8.2.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
14
- aws_cost_calculator_cli-1.8.2.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
15
- aws_cost_calculator_cli-1.8.2.dist-info/RECORD,,