aws-cost-calculator-cli 1.9.1__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.

@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: aws-cost-calculator-cli
3
- Version: 1.9.1
3
+ Version: 2.0.0
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
@@ -1,15 +1,15 @@
1
- aws_cost_calculator_cli-1.9.1.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
1
+ aws_cost_calculator_cli-2.0.0.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
2
2
  cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
3
3
  cost_calculator/api_client.py,sha256=4ZI2XcGIN3FBeQqb7xOxQ91kCoeM43-rExiOELXoKBQ,2485
4
- cost_calculator/cli.py,sha256=IRlRxefn9rfrt6EWwSEcMi9HCISHuOfnUs2_Bf5IZkA,54085
4
+ cost_calculator/cli.py,sha256=OxN0ZCVk1rZB1y1qGySSc0bSJYV7sdLlpz4ZhKrmt8o,76250
5
5
  cost_calculator/cur.py,sha256=QaZ_nyDSw5_cti-h5Ho6eYLbqzY5TWoub24DpyzIiSs,9502
6
6
  cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
7
- cost_calculator/executor.py,sha256=vZX3BCgTRHwBfxC0WqQsHgv1ww5rpmKqLCTrW2sflSY,6509
7
+ cost_calculator/executor.py,sha256=yZTCUgJc1OpB892O3mq9ZA0Yekc7N-HvaW8xLFyrXjo,8681
8
8
  cost_calculator/forensics.py,sha256=uhRo3I_zOeMEaBENHfgq65URga31W0Z4vzS2UN6VmTY,12819
9
9
  cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
10
10
  cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
11
- aws_cost_calculator_cli-1.9.1.dist-info/METADATA,sha256=jMNKiSSMAbMNajhVxBOyzSuJaWg3lrGfuCBSz0THo38,11978
12
- aws_cost_calculator_cli-1.9.1.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
13
- aws_cost_calculator_cli-1.9.1.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
14
- aws_cost_calculator_cli-1.9.1.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
15
- aws_cost_calculator_cli-1.9.1.dist-info/RECORD,,
11
+ aws_cost_calculator_cli-2.0.0.dist-info/METADATA,sha256=AH5JBVctCo41MMq0FIhejehxFj-A5LsN62hciwiqBHw,11978
12
+ aws_cost_calculator_cli-2.0.0.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
13
+ aws_cost_calculator_cli-2.0.0.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
14
+ aws_cost_calculator_cli-2.0.0.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
15
+ aws_cost_calculator_cli-2.0.0.dist-info/RECORD,,
cost_calculator/cli.py CHANGED
@@ -67,59 +67,39 @@ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=N
67
67
  return config
68
68
 
69
69
 
70
- def load_profile(profile_name):
71
- """Load profile configuration from local file or DynamoDB API"""
70
+ def get_api_secret():
71
+ """Get API secret from config file or environment variable"""
72
72
  import os
73
- import requests
74
73
 
74
+ # Check environment variable first
75
+ api_secret = os.environ.get('COST_API_SECRET')
76
+ if api_secret:
77
+ return api_secret
78
+
79
+ # Check config file
75
80
  config_dir = Path.home() / '.config' / 'cost-calculator'
76
- config_file = config_dir / 'profiles.json'
77
- creds_file = config_dir / 'credentials.json'
81
+ config_file = config_dir / 'config.json'
78
82
 
79
- # Try local file first
80
83
  if config_file.exists():
81
84
  with open(config_file) as f:
82
- profiles = json.load(f)
83
-
84
- if profile_name in profiles:
85
- profile = profiles[profile_name]
86
-
87
- # Load credentials if using static credentials (not SSO)
88
- if 'aws_profile' not in profile:
89
- if not creds_file.exists():
90
- # Try environment variables
91
- if os.environ.get('AWS_ACCESS_KEY_ID'):
92
- profile['credentials'] = {
93
- 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
94
- 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
95
- 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
96
- }
97
- return profile
98
-
99
- raise click.ClickException(
100
- f"No credentials found for profile '{profile_name}'.\n"
101
- f"Run: cc configure --profile {profile_name}"
102
- )
103
-
104
- with open(creds_file) as f:
105
- creds = json.load(f)
106
-
107
- if profile_name not in creds:
108
- raise click.ClickException(
109
- f"No credentials found for profile '{profile_name}'.\n"
110
- f"Run: cc configure --profile {profile_name}"
111
- )
112
-
113
- profile['credentials'] = creds[profile_name]
114
-
115
- return profile
85
+ config = json.load(f)
86
+ return config.get('api_secret')
87
+
88
+ return None
89
+
90
+
91
+ def load_profile(profile_name):
92
+ """Load profile configuration from DynamoDB API (API-only, no local files)"""
93
+ import requests
94
+
95
+ # Get API secret
96
+ api_secret = get_api_secret()
116
97
 
117
- # Profile not found locally - try DynamoDB API
118
- api_secret = os.environ.get('COST_API_SECRET')
119
98
  if not api_secret:
120
99
  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}"
100
+ "No API secret configured.\n"
101
+ "Run: cc configure --api-secret YOUR_SECRET\n"
102
+ "Or set environment variable: export COST_API_SECRET=YOUR_SECRET"
123
103
  )
124
104
 
125
105
  try:
@@ -186,7 +166,7 @@ def load_profile(profile_name):
186
166
  except requests.exceptions.RequestException as e:
187
167
  raise click.ClickException(
188
168
  f"Failed to fetch profile from API: {e}\n"
189
- f"Run: cc init --profile {profile_name}"
169
+ "Check your API secret and network connection."
190
170
  )
191
171
 
192
172
 
@@ -737,77 +717,79 @@ def setup():
737
717
 
738
718
 
739
719
  @cli.command()
740
- @click.option('--profile', required=True, help='Profile name to configure')
741
- @click.option('--access-key-id', prompt=True, hide_input=False, help='AWS Access Key ID')
742
- @click.option('--secret-access-key', prompt=True, hide_input=True, help='AWS Secret Access Key')
743
- @click.option('--session-token', default='', help='AWS Session Token (optional, for temporary credentials)')
744
- @click.option('--region', default='us-east-1', help='AWS Region (default: us-east-1)')
745
- def configure(profile, access_key_id, secret_access_key, session_token, region):
746
- """Configure AWS credentials for a profile (alternative to SSO)"""
720
+ @click.option('--api-secret', help='API secret for DynamoDB profile access')
721
+ @click.option('--show', is_flag=True, help='Show current configuration')
722
+ def configure(api_secret, show):
723
+ """
724
+ Configure Cost Calculator CLI settings.
747
725
 
748
- config_dir = Path.home() / '.config' / 'cost-calculator'
749
- config_file = config_dir / 'profiles.json'
750
- creds_file = config_dir / 'credentials.json'
726
+ This tool requires an API secret to access profiles stored in DynamoDB.
727
+ The secret can be configured here or set via COST_API_SECRET environment variable.
751
728
 
752
- # Create config directory if it doesn't exist
729
+ Examples:
730
+ # Configure API secret
731
+ cc configure --api-secret YOUR_SECRET_KEY
732
+
733
+ # Show current configuration
734
+ cc configure --show
735
+
736
+ # Use environment variable instead (no configuration needed)
737
+ export COST_API_SECRET=YOUR_SECRET_KEY
738
+ """
739
+ import os
740
+
741
+ config_dir = Path.home() / '.config' / 'cost-calculator'
753
742
  config_dir.mkdir(parents=True, exist_ok=True)
743
+ config_file = config_dir / 'config.json'
754
744
 
755
- # Load existing profiles
756
- if config_file.exists() and config_file.stat().st_size > 0:
757
- try:
745
+ if show:
746
+ # Show current configuration
747
+ if config_file.exists():
758
748
  with open(config_file) as f:
759
- profiles = json.load(f)
760
- except json.JSONDecodeError:
761
- profiles = {}
762
- else:
763
- profiles = {}
764
-
765
- # Check if profile exists
766
- if profile not in profiles:
767
- click.echo(f"Error: Profile '{profile}' not found. Create it first with: cc init --profile {profile}")
749
+ config = json.load(f)
750
+ if 'api_secret' in config:
751
+ masked_secret = config['api_secret'][:8] + '...' + config['api_secret'][-4:]
752
+ click.echo(f"API Secret: {masked_secret} (configured)")
753
+ else:
754
+ click.echo("API Secret: Not configured")
755
+ else:
756
+ click.echo("No configuration file found")
757
+
758
+ # Check environment variable
759
+ import os
760
+ if os.environ.get('COST_API_SECRET'):
761
+ click.echo("Environment: COST_API_SECRET is set")
762
+ else:
763
+ click.echo("Environment: COST_API_SECRET is not set")
764
+
768
765
  return
769
766
 
770
- # Remove aws_profile if it exists (switching from SSO to static creds)
771
- if 'aws_profile' in profiles[profile]:
772
- del profiles[profile]['aws_profile']
773
-
774
- # Save updated profile
775
- with open(config_file, 'w') as f:
776
- json.dump(profiles, f, indent=2)
777
-
778
- # Load or create credentials file
779
- if creds_file.exists() and creds_file.stat().st_size > 0:
780
- try:
781
- with open(creds_file) as f:
782
- creds = json.load(f)
783
- except json.JSONDecodeError:
784
- creds = {}
785
- else:
786
- creds = {}
767
+ if not api_secret:
768
+ raise click.ClickException(
769
+ "Please provide --api-secret or use --show to view current configuration\n"
770
+ "Example: cc configure --api-secret YOUR_SECRET_KEY"
771
+ )
787
772
 
788
- # Store credentials (encrypted would be better, but for now just file permissions)
789
- creds[profile] = {
790
- 'aws_access_key_id': access_key_id,
791
- 'aws_secret_access_key': secret_access_key,
792
- 'region': region
793
- }
773
+ # Load existing config
774
+ config = {}
775
+ if config_file.exists():
776
+ with open(config_file) as f:
777
+ config = json.load(f)
794
778
 
795
- if session_token:
796
- creds[profile]['aws_session_token'] = session_token
779
+ # Update API secret
780
+ config['api_secret'] = api_secret
797
781
 
798
- # Save credentials with restricted permissions
799
- with open(creds_file, 'w') as f:
800
- json.dump(creds, f, indent=2)
782
+ # Save config
783
+ with open(config_file, 'w') as f:
784
+ json.dump(config, f, indent=2)
801
785
 
802
- # Set file permissions to 600 (owner read/write only)
803
- creds_file.chmod(0o600)
786
+ # Set restrictive permissions
787
+ os.chmod(config_file, 0o600)
804
788
 
805
- click.echo(f"✓ AWS credentials configured for profile '{profile}'")
806
- click.echo(f"✓ Credentials saved to {creds_file} (permissions: 600)")
807
- click.echo(f"\nUsage: cc calculate --profile {profile}")
808
- click.echo("\nNote: Credentials are stored locally. For temporary credentials,")
809
- click.echo(" you'll need to reconfigure when they expire.")
810
-
789
+ masked_secret = api_secret[:8] + '...' + api_secret[-4:]
790
+ click.echo(f"✓ API secret configured: {masked_secret}")
791
+ click.echo(f"\nYou can now run: cc calculate --profile PROFILE_NAME")
792
+ click.echo(f"\nNote: Profiles are stored in DynamoDB and accessed via the API.")
811
793
 
812
794
  @cli.command()
813
795
  @click.option('--profile', required=True, help='Profile name')
@@ -1380,5 +1362,638 @@ def find_account_profile(account_id):
1380
1362
  return None
1381
1363
 
1382
1364
 
1365
+ @cli.command()
1366
+ @click.option('--profile', required=True, help='Profile name')
1367
+ @click.option('--start-date', help='Start date (YYYY-MM-DD)')
1368
+ @click.option('--end-date', help='End date (YYYY-MM-DD)')
1369
+ @click.option('--days', type=int, default=10, help='Number of days to analyze (default: 10)')
1370
+ @click.option('--service', help='Filter by service name')
1371
+ @click.option('--account', help='Filter by account ID')
1372
+ @click.option('--sso', help='AWS SSO profile name')
1373
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1374
+ def daily(profile, start_date, end_date, days, service, account, sso, output_json):
1375
+ """
1376
+ Get daily cost breakdown with granular detail.
1377
+
1378
+ Shows day-by-day costs for specific services and accounts, useful for:
1379
+ - Identifying cost spikes on specific dates
1380
+ - Validating daily cost patterns
1381
+ - Calculating precise daily averages
1382
+
1383
+ Examples:
1384
+ # Last 10 days of CloudWatch costs for specific account
1385
+ cc daily --profile khoros --days 10 --service AmazonCloudWatch --account 820054669588
1386
+
1387
+ # Custom date range with JSON output for automation
1388
+ cc daily --profile khoros --start-date 2025-10-28 --end-date 2025-11-06 --json
1389
+
1390
+ # Find high-cost days using jq
1391
+ cc daily --profile khoros --days 30 --json | jq '.daily_costs | map(select(.cost > 1000))'
1392
+ """
1393
+ # Load profile
1394
+ config = load_profile(profile)
1395
+
1396
+ # Apply SSO if provided
1397
+ if sso:
1398
+ config['aws_profile'] = sso
1399
+
1400
+ # Calculate date range
1401
+ if end_date:
1402
+ end = datetime.strptime(end_date, '%Y-%m-%d')
1403
+ else:
1404
+ end = datetime.now()
1405
+
1406
+ if start_date:
1407
+ start = datetime.strptime(start_date, '%Y-%m-%d')
1408
+ else:
1409
+ start = end - timedelta(days=days)
1410
+
1411
+ click.echo(f"Daily breakdown: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
1412
+ if service:
1413
+ click.echo(f"Service filter: {service}")
1414
+ if account:
1415
+ click.echo(f"Account filter: {account}")
1416
+ click.echo("")
1417
+
1418
+ # Get credentials
1419
+ try:
1420
+ if 'aws_profile' in config:
1421
+ session = boto3.Session(profile_name=config['aws_profile'])
1422
+ else:
1423
+ creds = config['credentials']
1424
+ session = boto3.Session(
1425
+ aws_access_key_id=creds['aws_access_key_id'],
1426
+ aws_secret_access_key=creds['aws_secret_access_key'],
1427
+ aws_session_token=creds.get('aws_session_token')
1428
+ )
1429
+
1430
+ ce_client = session.client('ce', region_name='us-east-1')
1431
+
1432
+ # Build filter
1433
+ filter_parts = []
1434
+
1435
+ # Account filter
1436
+ if account:
1437
+ filter_parts.append({
1438
+ "Dimensions": {
1439
+ "Key": "LINKED_ACCOUNT",
1440
+ "Values": [account]
1441
+ }
1442
+ })
1443
+ else:
1444
+ filter_parts.append({
1445
+ "Dimensions": {
1446
+ "Key": "LINKED_ACCOUNT",
1447
+ "Values": config['accounts']
1448
+ }
1449
+ })
1450
+
1451
+ # Service filter
1452
+ if service:
1453
+ filter_parts.append({
1454
+ "Dimensions": {
1455
+ "Key": "SERVICE",
1456
+ "Values": [service]
1457
+ }
1458
+ })
1459
+
1460
+ # Exclude support and tax
1461
+ filter_parts.append({
1462
+ "Not": {
1463
+ "Dimensions": {
1464
+ "Key": "RECORD_TYPE",
1465
+ "Values": ["Tax", "Support"]
1466
+ }
1467
+ }
1468
+ })
1469
+
1470
+ cost_filter = {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
1471
+
1472
+ # Get daily costs
1473
+ response = ce_client.get_cost_and_usage(
1474
+ TimePeriod={
1475
+ 'Start': start.strftime('%Y-%m-%d'),
1476
+ 'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
1477
+ },
1478
+ Granularity='DAILY',
1479
+ Metrics=['UnblendedCost'],
1480
+ Filter=cost_filter
1481
+ )
1482
+
1483
+ # Collect results
1484
+ daily_costs = []
1485
+ total = 0
1486
+ for day in response['ResultsByTime']:
1487
+ date = day['TimePeriod']['Start']
1488
+ cost = float(day['Total']['UnblendedCost']['Amount'])
1489
+ total += cost
1490
+ daily_costs.append({'date': date, 'cost': cost})
1491
+
1492
+ num_days = len(response['ResultsByTime'])
1493
+ daily_avg = total / num_days if num_days > 0 else 0
1494
+ annual = daily_avg * 365
1495
+
1496
+ # Output results
1497
+ if output_json:
1498
+ import json
1499
+ result = {
1500
+ 'period': {
1501
+ 'start': start.strftime('%Y-%m-%d'),
1502
+ 'end': end.strftime('%Y-%m-%d'),
1503
+ 'days': num_days
1504
+ },
1505
+ 'filters': {
1506
+ 'service': service,
1507
+ 'account': account
1508
+ },
1509
+ 'daily_costs': daily_costs,
1510
+ 'summary': {
1511
+ 'total': total,
1512
+ 'daily_avg': daily_avg,
1513
+ 'annual_projection': annual
1514
+ }
1515
+ }
1516
+ click.echo(json.dumps(result, indent=2))
1517
+ else:
1518
+ click.echo("Date | Cost")
1519
+ click.echo("-----------|-----------")
1520
+ for item in daily_costs:
1521
+ click.echo(f"{item['date']} | ${item['cost']:,.2f}")
1522
+ click.echo("-----------|-----------")
1523
+ click.echo(f"Total | ${total:,.2f}")
1524
+ click.echo(f"Daily Avg | ${daily_avg:,.2f}")
1525
+ click.echo(f"Annual | ${annual:,.0f}")
1526
+
1527
+ except Exception as e:
1528
+ raise click.ClickException(f"Failed to get daily costs: {e}")
1529
+
1530
+
1531
+ @cli.command()
1532
+ @click.option('--profile', required=True, help='Profile name')
1533
+ @click.option('--account', help='Account ID to compare')
1534
+ @click.option('--service', help='Service to compare')
1535
+ @click.option('--before', required=True, help='Before period (YYYY-MM-DD:YYYY-MM-DD)')
1536
+ @click.option('--after', required=True, help='After period (YYYY-MM-DD:YYYY-MM-DD)')
1537
+ @click.option('--expected-reduction', type=float, help='Expected reduction percentage')
1538
+ @click.option('--sso', help='AWS SSO profile name')
1539
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1540
+ def compare(profile, account, service, before, after, expected_reduction, sso, output_json):
1541
+ """
1542
+ Compare costs between two periods for validation and analysis.
1543
+
1544
+ Perfect for:
1545
+ - Validating cost optimization savings
1546
+ - Before/after migration analysis
1547
+ - Measuring impact of infrastructure changes
1548
+ - Automated savings validation in CI/CD
1549
+
1550
+ Examples:
1551
+ # Validate Datadog migration savings (expect 50% reduction)
1552
+ cc compare --profile khoros --account 180770971501 --service AmazonCloudWatch \
1553
+ --before "2025-10-28:2025-11-06" --after "2025-11-17:2025-11-26" --expected-reduction 50
1554
+
1555
+ # Compare total costs across all accounts
1556
+ cc compare --profile khoros --before "2025-10-01:2025-10-31" --after "2025-11-01:2025-11-30"
1557
+
1558
+ # JSON output for automated validation
1559
+ cc compare --profile khoros --service EC2 --before "2025-10-01:2025-10-07" \
1560
+ --after "2025-11-08:2025-11-14" --json | jq '.comparison.met_expectation'
1561
+ """
1562
+ # Load profile
1563
+ config = load_profile(profile)
1564
+
1565
+ # Apply SSO if provided
1566
+ if sso:
1567
+ config['aws_profile'] = sso
1568
+
1569
+ # Parse periods
1570
+ try:
1571
+ before_start, before_end = before.split(':')
1572
+ after_start, after_end = after.split(':')
1573
+ except ValueError:
1574
+ raise click.ClickException("Period format must be 'YYYY-MM-DD:YYYY-MM-DD'")
1575
+
1576
+ if not output_json:
1577
+ click.echo(f"Comparing periods:")
1578
+ click.echo(f" Before: {before_start} to {before_end}")
1579
+ click.echo(f" After: {after_start} to {after_end}")
1580
+ if service:
1581
+ click.echo(f" Service: {service}")
1582
+ if account:
1583
+ click.echo(f" Account: {account}")
1584
+ click.echo("")
1585
+
1586
+ # Get credentials
1587
+ try:
1588
+ if 'aws_profile' in config:
1589
+ session = boto3.Session(profile_name=config['aws_profile'])
1590
+ else:
1591
+ creds = config['credentials']
1592
+ session = boto3.Session(
1593
+ aws_access_key_id=creds['aws_access_key_id'],
1594
+ aws_secret_access_key=creds['aws_secret_access_key'],
1595
+ aws_session_token=creds.get('aws_session_token')
1596
+ )
1597
+
1598
+ ce_client = session.client('ce', region_name='us-east-1')
1599
+
1600
+ # Build filter
1601
+ def build_filter():
1602
+ filter_parts = []
1603
+
1604
+ if account:
1605
+ filter_parts.append({
1606
+ "Dimensions": {
1607
+ "Key": "LINKED_ACCOUNT",
1608
+ "Values": [account]
1609
+ }
1610
+ })
1611
+ else:
1612
+ filter_parts.append({
1613
+ "Dimensions": {
1614
+ "Key": "LINKED_ACCOUNT",
1615
+ "Values": config['accounts']
1616
+ }
1617
+ })
1618
+
1619
+ if service:
1620
+ filter_parts.append({
1621
+ "Dimensions": {
1622
+ "Key": "SERVICE",
1623
+ "Values": [service]
1624
+ }
1625
+ })
1626
+
1627
+ filter_parts.append({
1628
+ "Not": {
1629
+ "Dimensions": {
1630
+ "Key": "RECORD_TYPE",
1631
+ "Values": ["Tax", "Support"]
1632
+ }
1633
+ }
1634
+ })
1635
+
1636
+ return {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
1637
+
1638
+ cost_filter = build_filter()
1639
+
1640
+ # Get before period costs
1641
+ before_response = ce_client.get_cost_and_usage(
1642
+ TimePeriod={
1643
+ 'Start': before_start,
1644
+ 'End': (datetime.strptime(before_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
1645
+ },
1646
+ Granularity='DAILY',
1647
+ Metrics=['UnblendedCost'],
1648
+ Filter=cost_filter
1649
+ )
1650
+
1651
+ # Get after period costs
1652
+ after_response = ce_client.get_cost_and_usage(
1653
+ TimePeriod={
1654
+ 'Start': after_start,
1655
+ 'End': (datetime.strptime(after_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
1656
+ },
1657
+ Granularity='DAILY',
1658
+ Metrics=['UnblendedCost'],
1659
+ Filter=cost_filter
1660
+ )
1661
+
1662
+ # Calculate totals
1663
+ before_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in before_response['ResultsByTime'])
1664
+ after_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in after_response['ResultsByTime'])
1665
+
1666
+ before_days = len(before_response['ResultsByTime'])
1667
+ after_days = len(after_response['ResultsByTime'])
1668
+
1669
+ before_daily = before_total / before_days if before_days > 0 else 0
1670
+ after_daily = after_total / after_days if after_days > 0 else 0
1671
+
1672
+ reduction = before_daily - after_daily
1673
+ reduction_pct = (reduction / before_daily * 100) if before_daily > 0 else 0
1674
+ annual_savings = reduction * 365
1675
+
1676
+ # Output results
1677
+ if output_json:
1678
+ import json
1679
+ result = {
1680
+ 'before': {
1681
+ 'period': {'start': before_start, 'end': before_end},
1682
+ 'total': before_total,
1683
+ 'daily_avg': before_daily,
1684
+ 'days': before_days
1685
+ },
1686
+ 'after': {
1687
+ 'period': {'start': after_start, 'end': after_end},
1688
+ 'total': after_total,
1689
+ 'daily_avg': after_daily,
1690
+ 'days': after_days
1691
+ },
1692
+ 'comparison': {
1693
+ 'daily_reduction': reduction,
1694
+ 'reduction_pct': reduction_pct,
1695
+ 'annual_savings': annual_savings
1696
+ }
1697
+ }
1698
+
1699
+ if expected_reduction is not None:
1700
+ result['comparison']['expected_reduction_pct'] = expected_reduction
1701
+ result['comparison']['met_expectation'] = reduction_pct >= expected_reduction
1702
+
1703
+ click.echo(json.dumps(result, indent=2))
1704
+ else:
1705
+ click.echo("Before Period:")
1706
+ click.echo(f" Total: ${before_total:,.2f}")
1707
+ click.echo(f" Daily Avg: ${before_daily:,.2f}")
1708
+ click.echo(f" Days: {before_days}")
1709
+ click.echo("")
1710
+ click.echo("After Period:")
1711
+ click.echo(f" Total: ${after_total:,.2f}")
1712
+ click.echo(f" Daily Avg: ${after_daily:,.2f}")
1713
+ click.echo(f" Days: {after_days}")
1714
+ click.echo("")
1715
+ click.echo("Comparison:")
1716
+ click.echo(f" Daily Reduction: ${reduction:,.2f}")
1717
+ click.echo(f" Reduction %: {reduction_pct:.1f}%")
1718
+ click.echo(f" Annual Savings: ${annual_savings:,.0f}")
1719
+
1720
+ if expected_reduction is not None:
1721
+ click.echo("")
1722
+ if reduction_pct >= expected_reduction:
1723
+ click.echo(f"✅ Savings achieved: {reduction_pct:.1f}% (expected {expected_reduction}%)")
1724
+ else:
1725
+ click.echo(f"⚠️ Below target: {reduction_pct:.1f}% (expected {expected_reduction}%)")
1726
+
1727
+ except Exception as e:
1728
+ raise click.ClickException(f"Comparison failed: {e}")
1729
+
1730
+
1731
+ @cli.command()
1732
+ @click.option('--profile', required=True, help='Profile name')
1733
+ @click.option('--tag-key', required=True, help='Tag key to filter by')
1734
+ @click.option('--tag-value', help='Tag value to filter by (optional)')
1735
+ @click.option('--start-date', help='Start date (YYYY-MM-DD)')
1736
+ @click.option('--end-date', help='End date (YYYY-MM-DD)')
1737
+ @click.option('--days', type=int, default=30, help='Number of days to analyze (default: 30)')
1738
+ @click.option('--sso', help='AWS SSO profile name')
1739
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1740
+ def tags(profile, tag_key, tag_value, start_date, end_date, days, sso, output_json):
1741
+ """
1742
+ Analyze costs grouped by resource tags for cost attribution.
1743
+
1744
+ Useful for:
1745
+ - Cost allocation by team, project, or environment
1746
+ - Identifying untagged resources (cost attribution gaps)
1747
+ - Tracking costs by cost center or department
1748
+ - Validating tagging compliance
1749
+
1750
+ Examples:
1751
+ # See all costs by Environment tag
1752
+ cc tags --profile khoros --tag-key "Environment" --days 30
1753
+
1754
+ # Filter to specific tag value
1755
+ cc tags --profile khoros --tag-key "Team" --tag-value "Platform" --days 30
1756
+
1757
+ # Find top cost centers with JSON output
1758
+ cc tags --profile khoros --tag-key "CostCenter" --days 30 --json | \
1759
+ jq '.tag_costs | sort_by(-.cost) | .[:5]'
1760
+
1761
+ # Identify untagged resources (look for empty tag values)
1762
+ cc tags --profile khoros --tag-key "Owner" --days 7
1763
+ """
1764
+ # Load profile
1765
+ config = load_profile(profile)
1766
+
1767
+ # Apply SSO if provided
1768
+ if sso:
1769
+ config['aws_profile'] = sso
1770
+
1771
+ # Calculate date range
1772
+ if end_date:
1773
+ end = datetime.strptime(end_date, '%Y-%m-%d')
1774
+ else:
1775
+ end = datetime.now()
1776
+
1777
+ if start_date:
1778
+ start = datetime.strptime(start_date, '%Y-%m-%d')
1779
+ else:
1780
+ start = end - timedelta(days=days)
1781
+
1782
+ if not output_json:
1783
+ click.echo(f"Tag-based cost analysis: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
1784
+ click.echo(f"Tag key: {tag_key}")
1785
+ if tag_value:
1786
+ click.echo(f"Tag value: {tag_value}")
1787
+ click.echo("")
1788
+
1789
+ # Get credentials
1790
+ try:
1791
+ if 'aws_profile' in config:
1792
+ session = boto3.Session(profile_name=config['aws_profile'])
1793
+ else:
1794
+ creds = config['credentials']
1795
+ session = boto3.Session(
1796
+ aws_access_key_id=creds['aws_access_key_id'],
1797
+ aws_secret_access_key=creds['aws_secret_access_key'],
1798
+ aws_session_token=creds.get('aws_session_token')
1799
+ )
1800
+
1801
+ ce_client = session.client('ce', region_name='us-east-1')
1802
+
1803
+ # Build filter
1804
+ filter_parts = [
1805
+ {
1806
+ "Dimensions": {
1807
+ "Key": "LINKED_ACCOUNT",
1808
+ "Values": config['accounts']
1809
+ }
1810
+ },
1811
+ {
1812
+ "Not": {
1813
+ "Dimensions": {
1814
+ "Key": "RECORD_TYPE",
1815
+ "Values": ["Tax", "Support"]
1816
+ }
1817
+ }
1818
+ }
1819
+ ]
1820
+
1821
+ # Add tag filter if value specified
1822
+ if tag_value:
1823
+ filter_parts.append({
1824
+ "Tags": {
1825
+ "Key": tag_key,
1826
+ "Values": [tag_value]
1827
+ }
1828
+ })
1829
+
1830
+ cost_filter = {"And": filter_parts}
1831
+
1832
+ # Get costs grouped by tag values
1833
+ response = ce_client.get_cost_and_usage(
1834
+ TimePeriod={
1835
+ 'Start': start.strftime('%Y-%m-%d'),
1836
+ 'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
1837
+ },
1838
+ Granularity='MONTHLY',
1839
+ Metrics=['UnblendedCost'],
1840
+ GroupBy=[{
1841
+ 'Type': 'TAG',
1842
+ 'Key': tag_key
1843
+ }],
1844
+ Filter=cost_filter
1845
+ )
1846
+
1847
+ # Collect results
1848
+ tag_costs = {}
1849
+ for period in response['ResultsByTime']:
1850
+ for group in period['Groups']:
1851
+ tag_val = group['Keys'][0].split('$')[1] if '$' in group['Keys'][0] else group['Keys'][0]
1852
+ cost = float(group['Metrics']['UnblendedCost']['Amount'])
1853
+ tag_costs[tag_val] = tag_costs.get(tag_val, 0) + cost
1854
+
1855
+ # Sort by cost
1856
+ sorted_tags = sorted(tag_costs.items(), key=lambda x: x[1], reverse=True)
1857
+
1858
+ total = sum(tag_costs.values())
1859
+ num_days = (end - start).days
1860
+ daily_avg = total / num_days if num_days > 0 else 0
1861
+
1862
+ # Output results
1863
+ if output_json:
1864
+ import json
1865
+ result = {
1866
+ 'period': {
1867
+ 'start': start.strftime('%Y-%m-%d'),
1868
+ 'end': end.strftime('%Y-%m-%d'),
1869
+ 'days': num_days
1870
+ },
1871
+ 'tag_key': tag_key,
1872
+ 'tag_value_filter': tag_value,
1873
+ 'tag_costs': [{'tag_value': k, 'cost': v, 'percentage': (v/total*100) if total > 0 else 0} for k, v in sorted_tags],
1874
+ 'summary': {
1875
+ 'total': total,
1876
+ 'daily_avg': daily_avg,
1877
+ 'annual_projection': daily_avg * 365
1878
+ }
1879
+ }
1880
+ click.echo(json.dumps(result, indent=2))
1881
+ else:
1882
+ click.echo(f"Tag Value{' '*(30-len('Tag Value'))} | Cost | %")
1883
+ click.echo("-" * 60)
1884
+ for tag_val, cost in sorted_tags:
1885
+ pct = (cost / total * 100) if total > 0 else 0
1886
+ tag_display = tag_val[:30].ljust(30)
1887
+ click.echo(f"{tag_display} | ${cost:>9,.2f} | {pct:>5.1f}%")
1888
+ click.echo("-" * 60)
1889
+ click.echo(f"{'Total'.ljust(30)} | ${total:>9,.2f} | 100.0%")
1890
+ click.echo("")
1891
+ click.echo(f"Daily Avg: ${daily_avg:,.2f}")
1892
+ click.echo(f"Annual Projection: ${daily_avg * 365:,.0f}")
1893
+
1894
+ except Exception as e:
1895
+ raise click.ClickException(f"Tag analysis failed: {e}")
1896
+
1897
+
1898
+ @cli.command()
1899
+ @click.option('--profile', required=True, help='Profile name')
1900
+ @click.option('--query', required=True, help='SQL query to execute')
1901
+ @click.option('--database', default='athenacurcfn_cloud_intelligence_dashboard', help='Athena database name')
1902
+ @click.option('--output-bucket', help='S3 bucket for query results (default: from profile)')
1903
+ @click.option('--sso', help='AWS SSO profile name')
1904
+ def query(profile, query, database, output_bucket, sso):
1905
+ """
1906
+ Execute custom Athena SQL query on CUR data
1907
+
1908
+ Example:
1909
+ 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"
1910
+ """
1911
+ # Load profile
1912
+ config = load_profile(profile)
1913
+
1914
+ # Apply SSO if provided
1915
+ if sso:
1916
+ config['aws_profile'] = sso
1917
+
1918
+ # Get credentials
1919
+ try:
1920
+ if 'aws_profile' in config:
1921
+ session = boto3.Session(profile_name=config['aws_profile'])
1922
+ else:
1923
+ creds = config['credentials']
1924
+ session = boto3.Session(
1925
+ aws_access_key_id=creds['aws_access_key_id'],
1926
+ aws_secret_access_key=creds['aws_secret_access_key'],
1927
+ aws_session_token=creds.get('aws_session_token')
1928
+ )
1929
+
1930
+ athena_client = session.client('athena', region_name='us-east-1')
1931
+
1932
+ # Default output location
1933
+ if not output_bucket:
1934
+ output_bucket = 's3://khoros-finops-athena/athena/'
1935
+
1936
+ click.echo(f"Executing query on database: {database}")
1937
+ click.echo(f"Output location: {output_bucket}")
1938
+ click.echo("")
1939
+
1940
+ # Execute query
1941
+ response = athena_client.start_query_execution(
1942
+ QueryString=query,
1943
+ QueryExecutionContext={'Database': database},
1944
+ ResultConfiguration={'OutputLocation': output_bucket}
1945
+ )
1946
+
1947
+ query_id = response['QueryExecutionId']
1948
+ click.echo(f"Query ID: {query_id}")
1949
+ click.echo("Waiting for query to complete...")
1950
+
1951
+ # Wait for completion
1952
+ import time
1953
+ max_wait = 60
1954
+ waited = 0
1955
+ while waited < max_wait:
1956
+ status_response = athena_client.get_query_execution(QueryExecutionId=query_id)
1957
+ status = status_response['QueryExecution']['Status']['State']
1958
+
1959
+ if status == 'SUCCEEDED':
1960
+ click.echo("✓ Query completed successfully")
1961
+ break
1962
+ elif status in ['FAILED', 'CANCELLED']:
1963
+ reason = status_response['QueryExecution']['Status'].get('StateChangeReason', 'Unknown')
1964
+ raise click.ClickException(f"Query {status}: {reason}")
1965
+
1966
+ time.sleep(2)
1967
+ waited += 2
1968
+
1969
+ if waited >= max_wait:
1970
+ raise click.ClickException(f"Query timeout after {max_wait}s. Check query ID: {query_id}")
1971
+
1972
+ # Get results
1973
+ results = athena_client.get_query_results(QueryExecutionId=query_id)
1974
+
1975
+ # Display results
1976
+ rows = results['ResultSet']['Rows']
1977
+ if not rows:
1978
+ click.echo("No results returned")
1979
+ return
1980
+
1981
+ # Header
1982
+ headers = [col['VarCharValue'] for col in rows[0]['Data']]
1983
+ click.echo(" | ".join(headers))
1984
+ click.echo("-" * (len(" | ".join(headers))))
1985
+
1986
+ # Data rows
1987
+ for row in rows[1:]:
1988
+ values = [col.get('VarCharValue', '') for col in row['Data']]
1989
+ click.echo(" | ".join(values))
1990
+
1991
+ click.echo("")
1992
+ click.echo(f"Returned {len(rows)-1} rows")
1993
+
1994
+ except Exception as e:
1995
+ raise click.ClickException(f"Query failed: {e}")
1996
+
1997
+
1383
1998
  if __name__ == '__main__':
1384
1999
  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', {})