aws-cost-calculator-cli 1.6.0__py3-none-any.whl → 1.9.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.

Potentially problematic release.


This version of aws-cost-calculator-cli might be problematic. Click here for more details.

cost_calculator/cli.py CHANGED
@@ -11,6 +11,8 @@ Usage:
11
11
  import click
12
12
  import boto3
13
13
  import json
14
+ import os
15
+ import platform
14
16
  from datetime import datetime, timedelta
15
17
  from pathlib import Path
16
18
  from cost_calculator.trends import format_trends_markdown
@@ -134,8 +136,11 @@ def load_profile(profile_name):
134
136
  profile_data = response_data.get('profile', response_data)
135
137
  profile = {'accounts': profile_data['accounts']}
136
138
 
139
+ # If profile has aws_profile field, use it
140
+ if 'aws_profile' in profile_data:
141
+ profile['aws_profile'] = profile_data['aws_profile']
137
142
  # Check for AWS_PROFILE environment variable (SSO support)
138
- if os.environ.get('AWS_PROFILE'):
143
+ elif os.environ.get('AWS_PROFILE'):
139
144
  profile['aws_profile'] = os.environ['AWS_PROFILE']
140
145
  # Use environment credentials
141
146
  elif os.environ.get('AWS_ACCESS_KEY_ID'):
@@ -144,6 +149,33 @@ def load_profile(profile_name):
144
149
  'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
145
150
  'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
146
151
  }
152
+ else:
153
+ # Try to find a matching AWS profile by name
154
+ # This allows "khoros" profile to work with "khoros_umbrella" AWS profile
155
+ import subprocess
156
+ try:
157
+ result = subprocess.run(
158
+ ['aws', 'configure', 'list-profiles'],
159
+ capture_output=True,
160
+ text=True,
161
+ timeout=5
162
+ )
163
+ if result.returncode == 0:
164
+ available_profiles = result.stdout.strip().split('\n')
165
+ # Try exact match first
166
+ if profile_name in available_profiles:
167
+ profile['aws_profile'] = profile_name
168
+ # Try with common suffixes
169
+ elif f"{profile_name}_umbrella" in available_profiles:
170
+ profile['aws_profile'] = f"{profile_name}_umbrella"
171
+ elif f"{profile_name}-umbrella" in available_profiles:
172
+ profile['aws_profile'] = f"{profile_name}-umbrella"
173
+ elif f"{profile_name}_prod" in available_profiles:
174
+ profile['aws_profile'] = f"{profile_name}_prod"
175
+ # If no match found, leave it unset - user must provide --sso
176
+ except:
177
+ # If we can't list profiles, leave it unset - user must provide --sso
178
+ pass
147
179
 
148
180
  return profile
149
181
  else:
@@ -323,9 +355,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
323
355
  # Calculate days in the month that the support covers
324
356
  # Support on Nov 1 covers October (31 days)
325
357
  support_month = support_month_date - timedelta(days=1) # Go back to previous month
326
- days_in_support_month = support_month.day # This gives us the last day of the month
358
+ import calendar
359
+ days_in_support_month = calendar.monthrange(support_month.year, support_month.month)[1]
327
360
 
328
- # Support allocation: divide by 2 (half to Khoros), then by days in month
361
+ # Support allocation: divide by 2 (50% allocation), then by days in month
329
362
  support_per_day = (support_cost / 2) / days_in_support_month
330
363
 
331
364
  # Calculate daily rate
@@ -384,26 +417,147 @@ def cli():
384
417
  pass
385
418
 
386
419
 
420
+ @cli.command('setup-cur')
421
+ @click.option('--database', required=True, prompt='CUR Athena Database', help='Athena database name for CUR')
422
+ @click.option('--table', required=True, prompt='CUR Table Name', help='CUR table name')
423
+ @click.option('--s3-output', required=True, prompt='S3 Output Location', help='S3 bucket for Athena query results')
424
+ def setup_cur(database, table, s3_output):
425
+ """
426
+ Configure CUR (Cost and Usage Report) settings for resource-level queries
427
+
428
+ Saves CUR configuration to ~/.config/cost-calculator/cur_config.json
429
+
430
+ Example:
431
+ cc setup-cur --database my_cur_db --table cur_table --s3-output s3://my-bucket/
432
+ """
433
+ import json
434
+
435
+ config_dir = Path.home() / '.config' / 'cost-calculator'
436
+ config_dir.mkdir(parents=True, exist_ok=True)
437
+
438
+ config_file = config_dir / 'cur_config.json'
439
+
440
+ config = {
441
+ 'database': database,
442
+ 'table': table,
443
+ 's3_output': s3_output
444
+ }
445
+
446
+ with open(config_file, 'w') as f:
447
+ json.dump(config, f, indent=2)
448
+
449
+ click.echo(f"✓ CUR configuration saved to {config_file}")
450
+ click.echo(f" Database: {database}")
451
+ click.echo(f" Table: {table}")
452
+ click.echo(f" S3 Output: {s3_output}")
453
+ click.echo("")
454
+ click.echo("You can now use: cc drill --service 'EC2 - Other' --resources")
455
+
456
+
457
+ @cli.command('setup-api')
458
+ @click.option('--api-secret', required=True, prompt=True, hide_input=True, help='COST_API_SECRET value')
459
+ def setup_api(api_secret):
460
+ """
461
+ Configure COST_API_SECRET for backend API access
462
+
463
+ Saves the API secret to the appropriate location based on your OS:
464
+ - Mac/Linux: ~/.zshrc or ~/.bashrc
465
+ - Windows: User environment variables
466
+
467
+ Example:
468
+ cc setup-api --api-secret your-secret-here
469
+
470
+ Or let it prompt you (input will be hidden):
471
+ cc setup-api
472
+ """
473
+ system = platform.system()
474
+
475
+ if system == "Windows":
476
+ # Windows: Set user environment variable
477
+ try:
478
+ import winreg
479
+ key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_SET_VALUE)
480
+ winreg.SetValueEx(key, 'COST_API_SECRET', 0, winreg.REG_SZ, api_secret)
481
+ winreg.CloseKey(key)
482
+ click.echo("✓ COST_API_SECRET saved to Windows user environment variables")
483
+ click.echo(" Please restart your terminal for changes to take effect")
484
+ except Exception as e:
485
+ click.echo(f"✗ Error setting Windows environment variable: {e}", err=True)
486
+ click.echo("\nManual setup:")
487
+ click.echo("1. Open System Properties > Environment Variables")
488
+ click.echo("2. Add new User variable:")
489
+ click.echo(" Name: COST_API_SECRET")
490
+ click.echo(f" Value: {api_secret}")
491
+ return
492
+ else:
493
+ # Mac/Linux: Add to shell profile
494
+ shell = os.environ.get('SHELL', '/bin/bash')
495
+
496
+ if 'zsh' in shell:
497
+ profile_file = Path.home() / '.zshrc'
498
+ else:
499
+ profile_file = Path.home() / '.bashrc'
500
+
501
+ # Check if already exists
502
+ export_line = f'export COST_API_SECRET="{api_secret}"'
503
+
504
+ try:
505
+ if profile_file.exists():
506
+ content = profile_file.read_text()
507
+ if 'COST_API_SECRET' in content:
508
+ # Replace existing
509
+ lines = content.split('\n')
510
+ new_lines = []
511
+ for line in lines:
512
+ if 'COST_API_SECRET' in line and line.strip().startswith('export'):
513
+ new_lines.append(export_line)
514
+ else:
515
+ new_lines.append(line)
516
+ profile_file.write_text('\n'.join(new_lines))
517
+ click.echo(f"✓ Updated COST_API_SECRET in {profile_file}")
518
+ else:
519
+ # Append
520
+ with profile_file.open('a') as f:
521
+ f.write(f'\n# AWS Cost Calculator API Secret\n{export_line}\n')
522
+ click.echo(f"✓ Added COST_API_SECRET to {profile_file}")
523
+ else:
524
+ # Create new file
525
+ profile_file.write_text(f'# AWS Cost Calculator API Secret\n{export_line}\n')
526
+ click.echo(f"✓ Created {profile_file} with COST_API_SECRET")
527
+
528
+ # Also set for current session
529
+ os.environ['COST_API_SECRET'] = api_secret
530
+ click.echo(f"✓ Set COST_API_SECRET for current session")
531
+ click.echo(f"\nTo use in new terminals, run: source {profile_file}")
532
+
533
+ except Exception as e:
534
+ click.echo(f"✗ Error writing to {profile_file}: {e}", err=True)
535
+ click.echo(f"\nManual setup: Add this line to {profile_file}:")
536
+ click.echo(f" {export_line}")
537
+ return
538
+
539
+
387
540
  @cli.command()
388
541
  @click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
389
542
  @click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
390
543
  @click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
391
544
  @click.option('--window', default=30, help='Number of days to analyze (default: 30)')
392
545
  @click.option('--json-output', is_flag=True, help='Output as JSON')
393
- @click.option('--sso', help='AWS SSO profile name (e.g., khoros_umbrella)')
546
+ @click.option('--sso', help='AWS SSO profile name (e.g., my_sso_profile)')
394
547
  @click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
395
548
  @click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
396
549
  @click.option('--session-token', help='AWS Session Token (for static credentials)')
397
550
  def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
398
- """Calculate AWS costs for the specified period
551
+ """
552
+ Calculate AWS costs for the specified period
399
553
 
400
554
  \b
401
555
  Authentication Options:
402
556
  1. SSO: --sso <profile_name>
403
- Example: cc calculate --profile khoros --sso khoros_umbrella
557
+ Example: cc calculate --profile myprofile --sso my_sso_profile
404
558
 
405
559
  2. Static Credentials: --access-key-id, --secret-access-key, --session-token
406
- Example: cc calculate --profile khoros --access-key-id ASIA... --secret-access-key ... --session-token ...
560
+ Example: cc calculate --profile myprofile --access-key-id ASIA... --secret-access-key ... --session-token ...
407
561
 
408
562
  3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
409
563
  """
@@ -801,14 +955,19 @@ def monthly(profile, months, output, json_output, sso, access_key_id, secret_acc
801
955
  @click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
802
956
  @click.option('--account', help='Filter by account ID')
803
957
  @click.option('--usage-type', help='Filter by usage type')
958
+ @click.option('--resources', is_flag=True, help='Show individual resource IDs (requires CUR, uses Athena)')
804
959
  @click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
805
960
  @click.option('--json-output', is_flag=True, help='Output as JSON')
806
961
  @click.option('--sso', help='AWS SSO profile name')
807
962
  @click.option('--access-key-id', help='AWS Access Key ID')
808
963
  @click.option('--secret-access-key', help='AWS Secret Access Key')
809
964
  @click.option('--session-token', help='AWS Session Token')
810
- def drill(profile, weeks, service, account, usage_type, output, json_output, sso, access_key_id, secret_access_key, session_token):
811
- """Drill down into cost changes by service, account, or usage type"""
965
+ def drill(profile, weeks, service, account, usage_type, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
966
+ """
967
+ Drill down into cost changes by service, account, or usage type
968
+
969
+ Add --resources flag to see individual resource IDs and costs (requires CUR data via Athena)
970
+ """
812
971
 
813
972
  # Load profile
814
973
  config = load_profile(profile)
@@ -822,10 +981,19 @@ def drill(profile, weeks, service, account, usage_type, output, json_output, sso
822
981
  click.echo(f" Account filter: {account}")
823
982
  if usage_type:
824
983
  click.echo(f" Usage type filter: {usage_type}")
984
+ if resources:
985
+ click.echo(f" Mode: Resource-level (CUR via Athena)")
825
986
  click.echo("")
826
987
 
827
988
  # Execute via API or locally
828
- drill_data = execute_drill(config, weeks, service, account, usage_type)
989
+ drill_data = execute_drill(config, weeks, service, account, usage_type, resources)
990
+
991
+ # Handle resource-level output differently
992
+ if resources:
993
+ from cost_calculator.cur import format_resource_output
994
+ output_text = format_resource_output(drill_data)
995
+ click.echo(output_text)
996
+ return
829
997
 
830
998
  if json_output:
831
999
  # Output as JSON
@@ -998,5 +1166,219 @@ def profile(operation, name, accounts, description):
998
1166
  click.echo(result.get('message', 'Operation completed'))
999
1167
 
1000
1168
 
1169
+ @cli.command()
1170
+ @click.option('--profile', required=True, help='Profile name')
1171
+ @click.option('--sso', help='AWS SSO profile to use')
1172
+ @click.option('--weeks', default=8, help='Number of weeks to analyze')
1173
+ @click.option('--account', help='Focus on specific account ID')
1174
+ @click.option('--service', help='Focus on specific service')
1175
+ @click.option('--no-cloudtrail', is_flag=True, help='Skip CloudTrail analysis (faster)')
1176
+ @click.option('--output', default='investigation_report.md', help='Output file path')
1177
+ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
1178
+ """
1179
+ Multi-stage cost investigation:
1180
+ 1. Analyze cost trends and drill-downs
1181
+ 2. Inventory actual resources in problem accounts
1182
+ 3. Analyze CloudTrail events (optional)
1183
+ 4. Generate comprehensive report
1184
+ """
1185
+ from cost_calculator.executor import execute_trends, execute_drill, get_credentials_dict
1186
+ from cost_calculator.api_client import call_lambda_api, is_api_configured
1187
+ from cost_calculator.forensics import format_investigation_report
1188
+ from datetime import datetime, timedelta
1189
+
1190
+ click.echo("=" * 80)
1191
+ click.echo("COST INVESTIGATION")
1192
+ click.echo("=" * 80)
1193
+ click.echo(f"Profile: {profile}")
1194
+ click.echo(f"Weeks: {weeks}")
1195
+ if account:
1196
+ click.echo(f"Account: {account}")
1197
+ if service:
1198
+ click.echo(f"Service: {service}")
1199
+ click.echo("")
1200
+
1201
+ # Load profile
1202
+ config = load_profile(profile)
1203
+
1204
+ # Override with SSO if provided
1205
+ if sso:
1206
+ config['aws_profile'] = sso
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
+
1229
+ # Step 1: Cost Analysis
1230
+ click.echo("Step 1/3: Analyzing cost trends...")
1231
+ try:
1232
+ trends_data = execute_trends(config, weeks)
1233
+ click.echo(f"✓ Found cost data for {weeks} weeks")
1234
+ except Exception as e:
1235
+ click.echo(f"✗ Error analyzing trends: {str(e)}")
1236
+ trends_data = None
1237
+
1238
+ # Step 2: Drill-down
1239
+ click.echo("\nStep 2/3: Drilling down into costs...")
1240
+ drill_data = None
1241
+ if service or account:
1242
+ try:
1243
+ drill_data = execute_drill(config, weeks, service, account, None, False)
1244
+ click.echo(f"✓ Drill-down complete")
1245
+ except Exception as e:
1246
+ click.echo(f"✗ Error in drill-down: {str(e)}")
1247
+
1248
+ # Step 3: Resource Inventory
1249
+ click.echo("\nStep 3/3: Inventorying resources...")
1250
+ inventories = []
1251
+ cloudtrail_analyses = []
1252
+
1253
+ # Determine which accounts to investigate
1254
+ accounts_to_investigate = []
1255
+ if account:
1256
+ accounts_to_investigate = [account]
1257
+ else:
1258
+ # Extract top cost accounts from trends/drill data
1259
+ # For now, we'll need the user to specify
1260
+ click.echo("⚠️ No account specified. Use --account to inventory resources.")
1261
+
1262
+ # For each account, do inventory and CloudTrail via backend API
1263
+ for acc_id in accounts_to_investigate:
1264
+ click.echo(f"\n Investigating account {acc_id}...")
1265
+
1266
+ # Get credentials (SSO or static)
1267
+ account_creds = get_credentials_dict(config)
1268
+ if not account_creds:
1269
+ click.echo(f" ⚠️ No credentials available for account")
1270
+ continue
1271
+
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
+
1277
+ try:
1278
+ regions = ['us-west-2', 'us-east-1', 'eu-west-1']
1279
+ for region in regions:
1280
+ try:
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
+ )
1289
+
1290
+ if not inv.get('error'):
1291
+ inventories.append(inv)
1292
+ click.echo(f" ✓ Inventory complete for {region}")
1293
+ click.echo(f" - EC2: {len(inv['ec2_instances'])} instances")
1294
+ click.echo(f" - EFS: {len(inv['efs_file_systems'])} file systems ({inv.get('total_efs_size_gb', 0):,.0f} GB)")
1295
+ click.echo(f" - ELB: {len(inv['load_balancers'])} load balancers")
1296
+ break
1297
+ except Exception as e:
1298
+ continue
1299
+ except Exception as e:
1300
+ click.echo(f" ✗ Inventory error: {str(e)}")
1301
+
1302
+ # CloudTrail analysis via backend API only
1303
+ if not no_cloudtrail:
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
+
1311
+ ct_analysis = call_lambda_api(
1312
+ 'forensics',
1313
+ account_creds,
1314
+ [],
1315
+ operation='cloudtrail',
1316
+ account_id=acc_id,
1317
+ start_date=start_date,
1318
+ end_date=end_date,
1319
+ region='us-west-2'
1320
+ )
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)}")
1332
+
1333
+ # Generate report
1334
+ click.echo(f"\nGenerating report...")
1335
+ report = format_investigation_report(trends_data, inventories, cloudtrail_analyses if not no_cloudtrail else None)
1336
+
1337
+ # Write to file
1338
+ with open(output, 'w') as f:
1339
+ f.write(report)
1340
+
1341
+ click.echo(f"\n✓ Investigation complete!")
1342
+ click.echo(f"✓ Report saved to: {output}")
1343
+ click.echo("")
1344
+
1345
+
1346
+ def find_account_profile(account_id):
1347
+ """
1348
+ Find the SSO profile name for a given account ID
1349
+ Returns profile name or None
1350
+ """
1351
+ import subprocess
1352
+
1353
+ try:
1354
+ # Get list of profiles
1355
+ result = subprocess.run(
1356
+ ['aws', 'configure', 'list-profiles'],
1357
+ capture_output=True,
1358
+ text=True
1359
+ )
1360
+
1361
+ profiles = result.stdout.strip().split('\n')
1362
+
1363
+ # Check each profile
1364
+ for profile in profiles:
1365
+ try:
1366
+ result = subprocess.run(
1367
+ ['aws', 'sts', 'get-caller-identity', '--profile', profile],
1368
+ capture_output=True,
1369
+ text=True,
1370
+ timeout=5
1371
+ )
1372
+
1373
+ if account_id in result.stdout:
1374
+ return profile
1375
+ except:
1376
+ continue
1377
+
1378
+ return None
1379
+ except:
1380
+ return None
1381
+
1382
+
1001
1383
  if __name__ == '__main__':
1002
1384
  cli()