aws-cost-calculator-cli 1.2.0__py3-none-any.whl → 1.11.0__py3-none-any.whl

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

Potentially problematic release.


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

cost_calculator/cli.py CHANGED
@@ -11,55 +11,183 @@ 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
- from cost_calculator.trends import analyze_trends, format_trends_markdown
17
- from cost_calculator.monthly import analyze_monthly_trends, format_monthly_markdown
18
+ from cost_calculator.trends import format_trends_markdown
19
+ from cost_calculator.monthly import format_monthly_markdown
20
+ from cost_calculator.drill import format_drill_down_markdown
21
+ from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
18
22
 
19
23
 
20
- def load_profile(profile_name):
21
- """Load profile configuration from ~/.config/cost-calculator/profiles.json"""
22
- config_dir = Path.home() / '.config' / 'cost-calculator'
23
- config_file = config_dir / 'profiles.json'
24
- creds_file = config_dir / 'credentials.json'
24
+ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=None, session_token=None):
25
+ """Apply authentication options to profile config
25
26
 
26
- if not config_file.exists():
27
- raise click.ClickException(
28
- f"Profile configuration not found at {config_file}\n"
29
- f"Run: cc init --profile {profile_name}"
30
- )
27
+ Args:
28
+ config: Profile configuration dict
29
+ sso: AWS SSO profile name
30
+ access_key_id: AWS Access Key ID
31
+ secret_access_key: AWS Secret Access Key
32
+ session_token: AWS Session Token
31
33
 
32
- with open(config_file) as f:
33
- profiles = json.load(f)
34
+ Returns:
35
+ Updated config dict
36
+ """
37
+ import subprocess
34
38
 
35
- if profile_name not in profiles:
36
- raise click.ClickException(
37
- f"Profile '{profile_name}' not found in {config_file}\n"
38
- f"Available profiles: {', '.join(profiles.keys())}"
39
- )
39
+ if sso:
40
+ # SSO authentication - trigger login if needed
41
+ try:
42
+ # Test if SSO session is valid
43
+ result = subprocess.run(
44
+ ['aws', 'sts', 'get-caller-identity', '--profile', sso],
45
+ capture_output=True,
46
+ text=True,
47
+ timeout=5
48
+ )
49
+ if result.returncode != 0:
50
+ if 'expired' in result.stderr.lower() or 'token' in result.stderr.lower():
51
+ click.echo(f"SSO session expired or not initialized. Logging in...")
52
+ subprocess.run(['aws', 'sso', 'login', '--profile', sso], check=True)
53
+ except Exception as e:
54
+ click.echo(f"Warning: Could not verify SSO session: {e}")
55
+
56
+ config['aws_profile'] = sso
57
+ elif access_key_id and secret_access_key:
58
+ # Static credentials provided via CLI
59
+ config['credentials'] = {
60
+ 'aws_access_key_id': access_key_id,
61
+ 'aws_secret_access_key': secret_access_key,
62
+ 'region': 'us-east-1'
63
+ }
64
+ if session_token:
65
+ config['credentials']['aws_session_token'] = session_token
40
66
 
41
- profile = profiles[profile_name]
67
+ return config
68
+
69
+
70
+ def load_profile(profile_name):
71
+ """Load profile configuration from DynamoDB API or local file as fallback"""
72
+ import os
73
+ import requests
42
74
 
43
- # Load credentials if using static credentials (not SSO)
44
- if 'aws_profile' not in profile:
45
- if not creds_file.exists():
46
- raise click.ClickException(
47
- f"No credentials found for profile '{profile_name}'.\n"
48
- f"Run: cc configure --profile {profile_name}"
75
+ config_dir = Path.home() / '.config' / 'cost-calculator'
76
+ config_file = config_dir / 'profiles.json'
77
+ creds_file = config_dir / 'credentials.json'
78
+
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
49
88
  )
50
-
51
- with open(creds_file) as f:
52
- creds = json.load(f)
53
-
54
- if profile_name not in creds:
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:
55
144
  raise click.ClickException(
56
- f"No credentials found for profile '{profile_name}'.\n"
57
- f"Run: cc configure --profile {profile_name}"
145
+ f"Failed to fetch profile from API: {e}\n"
58
146
  )
147
+
148
+ # Fallback to local file if no API secret
149
+ if config_file.exists():
150
+ with open(config_file) as f:
151
+ profiles = json.load(f)
59
152
 
60
- profile['credentials'] = creds[profile_name]
153
+ if profile_name in profiles:
154
+ profile = profiles[profile_name]
155
+
156
+ # Load credentials if using static credentials (not SSO)
157
+ if 'aws_profile' not in profile:
158
+ if not creds_file.exists():
159
+ # Try environment variables
160
+ if os.environ.get('AWS_ACCESS_KEY_ID'):
161
+ profile['credentials'] = {
162
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
163
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
164
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
165
+ }
166
+ return profile
167
+
168
+ raise click.ClickException(
169
+ f"No credentials found for profile '{profile_name}'.\n"
170
+ f"Run: cc configure --profile {profile_name}"
171
+ )
172
+
173
+ with open(creds_file) as f:
174
+ creds = json.load(f)
175
+
176
+ if profile_name not in creds:
177
+ raise click.ClickException(
178
+ f"No credentials found for profile '{profile_name}'.\n"
179
+ f"Run: cc configure --profile {profile_name}"
180
+ )
181
+
182
+ profile['credentials'] = creds[profile_name]
183
+
184
+ return profile
61
185
 
62
- return profile
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
+ )
63
191
 
64
192
 
65
193
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -227,9 +355,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
227
355
  # Calculate days in the month that the support covers
228
356
  # Support on Nov 1 covers October (31 days)
229
357
  support_month = support_month_date - timedelta(days=1) # Go back to previous month
230
- 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]
231
360
 
232
- # 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
233
362
  support_per_day = (support_cost / 2) / days_in_support_month
234
363
 
235
364
  # Calculate daily rate
@@ -288,18 +417,157 @@ def cli():
288
417
  pass
289
418
 
290
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
+
291
540
  @cli.command()
292
541
  @click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
293
542
  @click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
294
543
  @click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
295
544
  @click.option('--window', default=30, help='Number of days to analyze (default: 30)')
296
545
  @click.option('--json-output', is_flag=True, help='Output as JSON')
297
- def calculate(profile, start_date, offset, window, json_output):
298
- """Calculate AWS costs for the specified period"""
546
+ @click.option('--sso', help='AWS SSO profile name (e.g., my_sso_profile)')
547
+ @click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
548
+ @click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
549
+ @click.option('--session-token', help='AWS Session Token (for static credentials)')
550
+ def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
551
+ """
552
+ Calculate AWS costs for the specified period
553
+
554
+ \b
555
+ Authentication Options:
556
+ 1. SSO: --sso <profile_name>
557
+ Example: cc calculate --profile myprofile --sso my_sso_profile
558
+
559
+ 2. Static Credentials: --access-key-id, --secret-access-key, --session-token
560
+ Example: cc calculate --profile myprofile --access-key-id ASIA... --secret-access-key ... --session-token ...
561
+
562
+ 3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
563
+ """
299
564
 
300
565
  # Load profile configuration
301
566
  config = load_profile(profile)
302
567
 
568
+ # Apply authentication options
569
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
570
+
303
571
  # Calculate costs
304
572
  result = calculate_costs(
305
573
  profile_config=config,
@@ -545,55 +813,23 @@ def configure(profile, access_key_id, secret_access_key, session_token, region):
545
813
  @click.option('--profile', required=True, help='Profile name')
546
814
  @click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
547
815
  @click.option('--output', default='cost_trends.md', help='Output markdown file (default: cost_trends.md)')
548
- @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
549
- def trends(profile, weeks, output, json_output):
816
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
817
+ @click.option('--sso', help='AWS SSO profile name')
818
+ @click.option('--access-key-id', help='AWS Access Key ID')
819
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
820
+ @click.option('--session-token', help='AWS Session Token')
821
+ def trends(profile, weeks, output, json_output, sso, access_key_id, secret_access_key, session_token):
550
822
  """Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
551
823
 
552
824
  # Load profile configuration
553
825
  config = load_profile(profile)
554
-
555
- # Initialize boto3 client
556
- try:
557
- if 'aws_profile' in config:
558
- aws_profile = config['aws_profile']
559
- click.echo(f"AWS Profile: {aws_profile} (SSO)")
560
- session = boto3.Session(profile_name=aws_profile)
561
- ce_client = session.client('ce', region_name='us-east-1')
562
- else:
563
- creds = config['credentials']
564
- click.echo(f"AWS Credentials: Static")
565
-
566
- session_kwargs = {
567
- 'aws_access_key_id': creds['aws_access_key_id'],
568
- 'aws_secret_access_key': creds['aws_secret_access_key'],
569
- 'region_name': creds.get('region', 'us-east-1')
570
- }
571
-
572
- if 'aws_session_token' in creds:
573
- session_kwargs['aws_session_token'] = creds['aws_session_token']
574
-
575
- session = boto3.Session(**session_kwargs)
576
- ce_client = session.client('ce')
577
-
578
- except Exception as e:
579
- if 'Token has expired' in str(e) or 'sso' in str(e).lower():
580
- if 'aws_profile' in config:
581
- raise click.ClickException(
582
- f"AWS SSO session expired or not initialized.\n"
583
- f"Run: aws sso login --profile {config['aws_profile']}"
584
- )
585
- else:
586
- raise click.ClickException(
587
- f"AWS credentials expired.\n"
588
- f"Run: cc configure --profile {profile}"
589
- )
590
- raise
826
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
591
827
 
592
828
  click.echo(f"Analyzing last {weeks} weeks...")
593
829
  click.echo("")
594
830
 
595
- # Analyze trends
596
- trends_data = analyze_trends(ce_client, config['accounts'], num_weeks=weeks)
831
+ # Execute via API or locally
832
+ trends_data = execute_trends(config, weeks)
597
833
 
598
834
  if json_output:
599
835
  # Output as JSON
@@ -648,41 +884,23 @@ def trends(profile, weeks, output, json_output):
648
884
  @click.option('--profile', required=True, help='Profile name')
649
885
  @click.option('--months', default=6, help='Number of months to analyze (default: 6)')
650
886
  @click.option('--output', default='monthly_trends.md', help='Output markdown file (default: monthly_trends.md)')
651
- @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
652
- def monthly(profile, months, output, json_output):
887
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
888
+ @click.option('--sso', help='AWS SSO profile name')
889
+ @click.option('--access-key-id', help='AWS Access Key ID')
890
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
891
+ @click.option('--session-token', help='AWS Session Token')
892
+ def monthly(profile, months, output, json_output, sso, access_key_id, secret_access_key, session_token):
653
893
  """Analyze month-over-month cost trends at service level"""
654
894
 
655
- # Load profile configuration
895
+ # Load profile
656
896
  config = load_profile(profile)
657
-
658
- # Initialize boto3 client
659
- try:
660
- if 'aws_profile' in config:
661
- aws_profile = config['aws_profile']
662
- click.echo(f"AWS Profile: {aws_profile} (SSO)")
663
- session = boto3.Session(profile_name=aws_profile)
664
- else:
665
- # Use static credentials
666
- creds = config['credentials']
667
- click.echo("AWS Credentials: Static")
668
- session = boto3.Session(
669
- aws_access_key_id=creds['aws_access_key_id'],
670
- aws_secret_access_key=creds['aws_secret_access_key'],
671
- aws_session_token=creds.get('aws_session_token')
672
- )
673
-
674
- ce_client = session.client('ce', region_name='us-east-1')
675
- except Exception as e:
676
- raise click.ClickException(f"Failed to initialize AWS session: {str(e)}")
677
-
678
- # Get account list
679
- accounts = config['accounts']
897
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
680
898
 
681
899
  click.echo(f"Analyzing last {months} months...")
682
900
  click.echo("")
683
901
 
684
- # Analyze monthly trends
685
- monthly_data = analyze_monthly_trends(ce_client, accounts, months)
902
+ # Execute via API or locally
903
+ monthly_data = execute_monthly(config, months)
686
904
 
687
905
  if json_output:
688
906
  # Output as JSON
@@ -731,5 +949,1027 @@ def monthly(profile, months, output, json_output):
731
949
  click.echo("")
732
950
 
733
951
 
952
+ @cli.command()
953
+ @click.option('--profile', required=True, help='Profile name')
954
+ @click.option('--weeks', default=4, help='Number of weeks to analyze (default: 4)')
955
+ @click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
956
+ @click.option('--account', help='Filter by account ID')
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)')
959
+ @click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
960
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
961
+ @click.option('--sso', help='AWS SSO profile name')
962
+ @click.option('--access-key-id', help='AWS Access Key ID')
963
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
964
+ @click.option('--session-token', help='AWS Session Token')
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
+ """
971
+
972
+ # Load profile
973
+ config = load_profile(profile)
974
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
975
+
976
+ # Show filters
977
+ click.echo(f"Analyzing last {weeks} weeks...")
978
+ if service:
979
+ click.echo(f" Service filter: {service}")
980
+ if account:
981
+ click.echo(f" Account filter: {account}")
982
+ if usage_type:
983
+ click.echo(f" Usage type filter: {usage_type}")
984
+ if resources:
985
+ click.echo(f" Mode: Resource-level (CUR via Athena)")
986
+ click.echo("")
987
+
988
+ # Execute via API or locally
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
997
+
998
+ if json_output:
999
+ # Output as JSON
1000
+ output_data = {
1001
+ 'generated': datetime.now().isoformat(),
1002
+ 'weeks': weeks,
1003
+ 'filters': drill_data['filters'],
1004
+ 'group_by': drill_data['group_by'],
1005
+ 'comparisons': []
1006
+ }
1007
+
1008
+ for comparison in drill_data['comparisons']:
1009
+ output_data['comparisons'].append({
1010
+ 'prev_week': comparison['prev_week']['label'],
1011
+ 'curr_week': comparison['curr_week']['label'],
1012
+ 'increases': comparison['increases'],
1013
+ 'decreases': comparison['decreases'],
1014
+ 'total_increase': comparison['total_increase'],
1015
+ 'total_decrease': comparison['total_decrease']
1016
+ })
1017
+
1018
+ click.echo(json.dumps(output_data, indent=2))
1019
+ else:
1020
+ # Generate markdown report
1021
+ markdown = format_drill_down_markdown(drill_data)
1022
+
1023
+ # Save to file
1024
+ with open(output, 'w') as f:
1025
+ f.write(markdown)
1026
+
1027
+ click.echo(f"✓ Drill-down report saved to {output}")
1028
+ click.echo("")
1029
+
1030
+ # Show summary
1031
+ group_by_label = {
1032
+ 'SERVICE': 'services',
1033
+ 'LINKED_ACCOUNT': 'accounts',
1034
+ 'USAGE_TYPE': 'usage types',
1035
+ 'REGION': 'regions'
1036
+ }.get(drill_data['group_by'], 'items')
1037
+
1038
+ click.echo(f"Showing top {group_by_label}:")
1039
+ for comparison in drill_data['comparisons']:
1040
+ prev_week = comparison['prev_week']['label']
1041
+ curr_week = comparison['curr_week']['label']
1042
+ num_increases = len(comparison['increases'])
1043
+ num_decreases = len(comparison['decreases'])
1044
+
1045
+ click.echo(f"{prev_week} → {curr_week}")
1046
+ click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
1047
+
1048
+ if comparison['increases']:
1049
+ top = comparison['increases'][0]
1050
+ click.echo(f" Top: {top['dimension'][:50]} (+${top['change']:,.2f})")
1051
+
1052
+ click.echo("")
1053
+
1054
+
1055
+ @cli.command()
1056
+ @click.option('--profile', required=True, help='Profile name')
1057
+ @click.option('--type', 'analysis_type', default='summary',
1058
+ type=click.Choice(['summary', 'volatility', 'trends', 'search']),
1059
+ help='Analysis type')
1060
+ @click.option('--weeks', default=12, help='Number of weeks (default: 12)')
1061
+ @click.option('--pattern', help='Service search pattern (for search type)')
1062
+ @click.option('--min-cost', type=float, help='Minimum cost filter (for search type)')
1063
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
1064
+ def analyze(profile, analysis_type, weeks, pattern, min_cost, json_output):
1065
+ """Perform pandas-based analysis (aggregations, volatility, trends, search)"""
1066
+
1067
+ config = load_profile(profile)
1068
+
1069
+ if not json_output:
1070
+ click.echo(f"Running {analysis_type} analysis for {weeks} weeks...")
1071
+
1072
+ from cost_calculator.executor import execute_analyze
1073
+ result = execute_analyze(config, weeks, analysis_type, pattern, min_cost)
1074
+
1075
+ if json_output:
1076
+ import json
1077
+ click.echo(json.dumps(result, indent=2, default=str))
1078
+ else:
1079
+ # Format output based on type
1080
+ if analysis_type == 'summary':
1081
+ click.echo(f"\n📊 Summary ({result.get('total_services', 0)} services)")
1082
+ click.echo(f"Weeks analyzed: {result.get('weeks_analyzed', 0)}")
1083
+ click.echo(f"\nTop 10 Services (by total change):")
1084
+ for svc in result.get('services', [])[:10]:
1085
+ click.echo(f" {svc['service']}")
1086
+ click.echo(f" Total: ${svc['change_sum']:,.2f}")
1087
+ click.echo(f" Average: ${svc['change_mean']:,.2f}")
1088
+ click.echo(f" Volatility: {svc['volatility']:.3f}")
1089
+
1090
+ elif analysis_type == 'volatility':
1091
+ click.echo(f"\n📈 High Volatility Services:")
1092
+ for svc in result.get('high_volatility_services', [])[:10]:
1093
+ click.echo(f" {svc['service']}: CV={svc['coefficient_of_variation']:.3f}")
1094
+
1095
+ outliers = result.get('outliers', [])
1096
+ if outliers:
1097
+ click.echo(f"\n⚠️ Outliers ({len(outliers)}):")
1098
+ for o in outliers[:5]:
1099
+ click.echo(f" {o['service']} ({o['week']}): ${o['change']:,.2f} (z={o['z_score']:.2f})")
1100
+
1101
+ elif analysis_type == 'trends':
1102
+ inc = result.get('increasing_trends', [])
1103
+ dec = result.get('decreasing_trends', [])
1104
+
1105
+ click.echo(f"\n📈 Increasing Trends ({len(inc)}):")
1106
+ for t in inc[:5]:
1107
+ click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
1108
+
1109
+ click.echo(f"\n📉 Decreasing Trends ({len(dec)}):")
1110
+ for t in dec[:5]:
1111
+ click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
1112
+
1113
+ elif analysis_type == 'search':
1114
+ matches = result.get('matches', [])
1115
+ click.echo(f"\n🔍 Search Results ({len(matches)} matches)")
1116
+ if pattern:
1117
+ click.echo(f"Pattern: {pattern}")
1118
+ if min_cost:
1119
+ click.echo(f"Min cost: ${min_cost:,.2f}")
1120
+
1121
+ for m in matches[:20]:
1122
+ click.echo(f" {m['service']}: ${m['curr_cost']:,.2f}")
1123
+
1124
+
1125
+ @cli.command()
1126
+ @click.argument('operation', type=click.Choice(['list', 'get', 'create', 'update', 'delete']))
1127
+ @click.option('--name', help='Profile name')
1128
+ @click.option('--accounts', help='Comma-separated account IDs')
1129
+ @click.option('--description', help='Profile description')
1130
+ def profile(operation, name, accounts, description):
1131
+ """Manage profiles (CRUD operations)"""
1132
+
1133
+ from cost_calculator.executor import execute_profile_operation
1134
+
1135
+ # Parse accounts if provided
1136
+ account_list = None
1137
+ if accounts:
1138
+ account_list = [a.strip() for a in accounts.split(',')]
1139
+
1140
+ result = execute_profile_operation(
1141
+ operation=operation,
1142
+ profile_name=name,
1143
+ accounts=account_list,
1144
+ description=description
1145
+ )
1146
+
1147
+ if operation == 'list':
1148
+ profiles = result.get('profiles', [])
1149
+ click.echo(f"\n📋 Profiles ({len(profiles)}):")
1150
+ for p in profiles:
1151
+ click.echo(f" {p['profile_name']}: {len(p.get('accounts', []))} accounts")
1152
+ if p.get('description'):
1153
+ click.echo(f" {p['description']}")
1154
+
1155
+ elif operation == 'get':
1156
+ profile_data = result.get('profile', {})
1157
+ click.echo(f"\n📋 Profile: {profile_data.get('profile_name')}")
1158
+ click.echo(f"Accounts: {len(profile_data.get('accounts', []))}")
1159
+ if profile_data.get('description'):
1160
+ click.echo(f"Description: {profile_data['description']}")
1161
+ click.echo(f"\nAccounts:")
1162
+ for acc in profile_data.get('accounts', []):
1163
+ click.echo(f" {acc}")
1164
+
1165
+ else:
1166
+ click.echo(result.get('message', 'Operation completed'))
1167
+
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
+
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
+ Example:
1397
+ cc daily --profile khoros --days 10 --service AmazonCloudWatch --account 820054669588
1398
+ """
1399
+ # Load profile
1400
+ config = load_profile(profile)
1401
+
1402
+ # Apply SSO if provided
1403
+ if sso:
1404
+ config['aws_profile'] = sso
1405
+
1406
+ # Calculate date range
1407
+ if end_date:
1408
+ end = datetime.strptime(end_date, '%Y-%m-%d')
1409
+ else:
1410
+ end = datetime.now()
1411
+
1412
+ if start_date:
1413
+ start = datetime.strptime(start_date, '%Y-%m-%d')
1414
+ else:
1415
+ start = end - timedelta(days=days)
1416
+
1417
+ click.echo(f"Daily breakdown: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
1418
+ if service:
1419
+ click.echo(f"Service filter: {service}")
1420
+ if account:
1421
+ click.echo(f"Account filter: {account}")
1422
+ click.echo("")
1423
+
1424
+ # Get credentials
1425
+ try:
1426
+ if 'aws_profile' in config:
1427
+ session = boto3.Session(profile_name=config['aws_profile'])
1428
+ else:
1429
+ creds = config['credentials']
1430
+ session = boto3.Session(
1431
+ aws_access_key_id=creds['aws_access_key_id'],
1432
+ aws_secret_access_key=creds['aws_secret_access_key'],
1433
+ aws_session_token=creds.get('aws_session_token')
1434
+ )
1435
+
1436
+ ce_client = session.client('ce', region_name='us-east-1')
1437
+
1438
+ # Build filter
1439
+ filter_parts = []
1440
+
1441
+ # Account filter
1442
+ if account:
1443
+ filter_parts.append({
1444
+ "Dimensions": {
1445
+ "Key": "LINKED_ACCOUNT",
1446
+ "Values": [account]
1447
+ }
1448
+ })
1449
+ else:
1450
+ filter_parts.append({
1451
+ "Dimensions": {
1452
+ "Key": "LINKED_ACCOUNT",
1453
+ "Values": config['accounts']
1454
+ }
1455
+ })
1456
+
1457
+ # Service filter
1458
+ if service:
1459
+ filter_parts.append({
1460
+ "Dimensions": {
1461
+ "Key": "SERVICE",
1462
+ "Values": [service]
1463
+ }
1464
+ })
1465
+
1466
+ # Exclude support and tax
1467
+ filter_parts.append({
1468
+ "Not": {
1469
+ "Dimensions": {
1470
+ "Key": "RECORD_TYPE",
1471
+ "Values": ["Tax", "Support"]
1472
+ }
1473
+ }
1474
+ })
1475
+
1476
+ cost_filter = {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
1477
+
1478
+ # Get daily costs
1479
+ response = ce_client.get_cost_and_usage(
1480
+ TimePeriod={
1481
+ 'Start': start.strftime('%Y-%m-%d'),
1482
+ 'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
1483
+ },
1484
+ Granularity='DAILY',
1485
+ Metrics=['UnblendedCost'],
1486
+ Filter=cost_filter
1487
+ )
1488
+
1489
+ # Collect results
1490
+ daily_costs = []
1491
+ total = 0
1492
+ for day in response['ResultsByTime']:
1493
+ date = day['TimePeriod']['Start']
1494
+ cost = float(day['Total']['UnblendedCost']['Amount'])
1495
+ total += cost
1496
+ daily_costs.append({'date': date, 'cost': cost})
1497
+
1498
+ num_days = len(response['ResultsByTime'])
1499
+ daily_avg = total / num_days if num_days > 0 else 0
1500
+ annual = daily_avg * 365
1501
+
1502
+ # Output results
1503
+ if output_json:
1504
+ import json
1505
+ result = {
1506
+ 'period': {
1507
+ 'start': start.strftime('%Y-%m-%d'),
1508
+ 'end': end.strftime('%Y-%m-%d'),
1509
+ 'days': num_days
1510
+ },
1511
+ 'filters': {
1512
+ 'service': service,
1513
+ 'account': account
1514
+ },
1515
+ 'daily_costs': daily_costs,
1516
+ 'summary': {
1517
+ 'total': total,
1518
+ 'daily_avg': daily_avg,
1519
+ 'annual_projection': annual
1520
+ }
1521
+ }
1522
+ click.echo(json.dumps(result, indent=2))
1523
+ else:
1524
+ click.echo("Date | Cost")
1525
+ click.echo("-----------|-----------")
1526
+ for item in daily_costs:
1527
+ click.echo(f"{item['date']} | ${item['cost']:,.2f}")
1528
+ click.echo("-----------|-----------")
1529
+ click.echo(f"Total | ${total:,.2f}")
1530
+ click.echo(f"Daily Avg | ${daily_avg:,.2f}")
1531
+ click.echo(f"Annual | ${annual:,.0f}")
1532
+
1533
+ except Exception as e:
1534
+ raise click.ClickException(f"Failed to get daily costs: {e}")
1535
+
1536
+
1537
+ @cli.command()
1538
+ @click.option('--profile', required=True, help='Profile name')
1539
+ @click.option('--account', help='Account ID to compare')
1540
+ @click.option('--service', help='Service to compare')
1541
+ @click.option('--before', required=True, help='Before period (YYYY-MM-DD:YYYY-MM-DD)')
1542
+ @click.option('--after', required=True, help='After period (YYYY-MM-DD:YYYY-MM-DD)')
1543
+ @click.option('--expected-reduction', type=float, help='Expected reduction percentage')
1544
+ @click.option('--sso', help='AWS SSO profile name')
1545
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1546
+ def compare(profile, account, service, before, after, expected_reduction, sso, output_json):
1547
+ """
1548
+ Compare costs between two periods
1549
+
1550
+ Example:
1551
+ cc compare --profile khoros --account 180770971501 --service AmazonCloudWatch \
1552
+ --before "2025-10-28:2025-11-06" --after "2025-11-17:2025-11-26" --expected-reduction 50
1553
+ """
1554
+ # Load profile
1555
+ config = load_profile(profile)
1556
+
1557
+ # Apply SSO if provided
1558
+ if sso:
1559
+ config['aws_profile'] = sso
1560
+
1561
+ # Parse periods
1562
+ try:
1563
+ before_start, before_end = before.split(':')
1564
+ after_start, after_end = after.split(':')
1565
+ except ValueError:
1566
+ raise click.ClickException("Period format must be 'YYYY-MM-DD:YYYY-MM-DD'")
1567
+
1568
+ if not output_json:
1569
+ click.echo(f"Comparing periods:")
1570
+ click.echo(f" Before: {before_start} to {before_end}")
1571
+ click.echo(f" After: {after_start} to {after_end}")
1572
+ if service:
1573
+ click.echo(f" Service: {service}")
1574
+ if account:
1575
+ click.echo(f" Account: {account}")
1576
+ click.echo("")
1577
+
1578
+ # Get credentials
1579
+ try:
1580
+ if 'aws_profile' in config:
1581
+ session = boto3.Session(profile_name=config['aws_profile'])
1582
+ else:
1583
+ creds = config['credentials']
1584
+ session = boto3.Session(
1585
+ aws_access_key_id=creds['aws_access_key_id'],
1586
+ aws_secret_access_key=creds['aws_secret_access_key'],
1587
+ aws_session_token=creds.get('aws_session_token')
1588
+ )
1589
+
1590
+ ce_client = session.client('ce', region_name='us-east-1')
1591
+
1592
+ # Build filter
1593
+ def build_filter():
1594
+ filter_parts = []
1595
+
1596
+ if account:
1597
+ filter_parts.append({
1598
+ "Dimensions": {
1599
+ "Key": "LINKED_ACCOUNT",
1600
+ "Values": [account]
1601
+ }
1602
+ })
1603
+ else:
1604
+ filter_parts.append({
1605
+ "Dimensions": {
1606
+ "Key": "LINKED_ACCOUNT",
1607
+ "Values": config['accounts']
1608
+ }
1609
+ })
1610
+
1611
+ if service:
1612
+ filter_parts.append({
1613
+ "Dimensions": {
1614
+ "Key": "SERVICE",
1615
+ "Values": [service]
1616
+ }
1617
+ })
1618
+
1619
+ filter_parts.append({
1620
+ "Not": {
1621
+ "Dimensions": {
1622
+ "Key": "RECORD_TYPE",
1623
+ "Values": ["Tax", "Support"]
1624
+ }
1625
+ }
1626
+ })
1627
+
1628
+ return {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
1629
+
1630
+ cost_filter = build_filter()
1631
+
1632
+ # Get before period costs
1633
+ before_response = ce_client.get_cost_and_usage(
1634
+ TimePeriod={
1635
+ 'Start': before_start,
1636
+ 'End': (datetime.strptime(before_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
1637
+ },
1638
+ Granularity='DAILY',
1639
+ Metrics=['UnblendedCost'],
1640
+ Filter=cost_filter
1641
+ )
1642
+
1643
+ # Get after period costs
1644
+ after_response = ce_client.get_cost_and_usage(
1645
+ TimePeriod={
1646
+ 'Start': after_start,
1647
+ 'End': (datetime.strptime(after_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
1648
+ },
1649
+ Granularity='DAILY',
1650
+ Metrics=['UnblendedCost'],
1651
+ Filter=cost_filter
1652
+ )
1653
+
1654
+ # Calculate totals
1655
+ before_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in before_response['ResultsByTime'])
1656
+ after_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in after_response['ResultsByTime'])
1657
+
1658
+ before_days = len(before_response['ResultsByTime'])
1659
+ after_days = len(after_response['ResultsByTime'])
1660
+
1661
+ before_daily = before_total / before_days if before_days > 0 else 0
1662
+ after_daily = after_total / after_days if after_days > 0 else 0
1663
+
1664
+ reduction = before_daily - after_daily
1665
+ reduction_pct = (reduction / before_daily * 100) if before_daily > 0 else 0
1666
+ annual_savings = reduction * 365
1667
+
1668
+ # Output results
1669
+ if output_json:
1670
+ import json
1671
+ result = {
1672
+ 'before': {
1673
+ 'period': {'start': before_start, 'end': before_end},
1674
+ 'total': before_total,
1675
+ 'daily_avg': before_daily,
1676
+ 'days': before_days
1677
+ },
1678
+ 'after': {
1679
+ 'period': {'start': after_start, 'end': after_end},
1680
+ 'total': after_total,
1681
+ 'daily_avg': after_daily,
1682
+ 'days': after_days
1683
+ },
1684
+ 'comparison': {
1685
+ 'daily_reduction': reduction,
1686
+ 'reduction_pct': reduction_pct,
1687
+ 'annual_savings': annual_savings
1688
+ }
1689
+ }
1690
+
1691
+ if expected_reduction is not None:
1692
+ result['comparison']['expected_reduction_pct'] = expected_reduction
1693
+ result['comparison']['met_expectation'] = reduction_pct >= expected_reduction
1694
+
1695
+ click.echo(json.dumps(result, indent=2))
1696
+ else:
1697
+ click.echo("Before Period:")
1698
+ click.echo(f" Total: ${before_total:,.2f}")
1699
+ click.echo(f" Daily Avg: ${before_daily:,.2f}")
1700
+ click.echo(f" Days: {before_days}")
1701
+ click.echo("")
1702
+ click.echo("After Period:")
1703
+ click.echo(f" Total: ${after_total:,.2f}")
1704
+ click.echo(f" Daily Avg: ${after_daily:,.2f}")
1705
+ click.echo(f" Days: {after_days}")
1706
+ click.echo("")
1707
+ click.echo("Comparison:")
1708
+ click.echo(f" Daily Reduction: ${reduction:,.2f}")
1709
+ click.echo(f" Reduction %: {reduction_pct:.1f}%")
1710
+ click.echo(f" Annual Savings: ${annual_savings:,.0f}")
1711
+
1712
+ if expected_reduction is not None:
1713
+ click.echo("")
1714
+ if reduction_pct >= expected_reduction:
1715
+ click.echo(f"✅ Savings achieved: {reduction_pct:.1f}% (expected {expected_reduction}%)")
1716
+ else:
1717
+ click.echo(f"⚠️ Below target: {reduction_pct:.1f}% (expected {expected_reduction}%)")
1718
+
1719
+ except Exception as e:
1720
+ raise click.ClickException(f"Comparison failed: {e}")
1721
+
1722
+
1723
+ @cli.command()
1724
+ @click.option('--profile', required=True, help='Profile name')
1725
+ @click.option('--tag-key', required=True, help='Tag key to filter by')
1726
+ @click.option('--tag-value', help='Tag value to filter by (optional)')
1727
+ @click.option('--start-date', help='Start date (YYYY-MM-DD)')
1728
+ @click.option('--end-date', help='End date (YYYY-MM-DD)')
1729
+ @click.option('--days', type=int, default=30, help='Number of days to analyze (default: 30)')
1730
+ @click.option('--sso', help='AWS SSO profile name')
1731
+ @click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
1732
+ def tags(profile, tag_key, tag_value, start_date, end_date, days, sso, output_json):
1733
+ """
1734
+ Analyze costs by resource tags
1735
+
1736
+ Example:
1737
+ cc tags --profile khoros --tag-key "datadog:org" --days 30
1738
+ cc tags --profile khoros --tag-key "Environment" --tag-value "Production"
1739
+ """
1740
+ # Load profile
1741
+ config = load_profile(profile)
1742
+
1743
+ # Apply SSO if provided
1744
+ if sso:
1745
+ config['aws_profile'] = sso
1746
+
1747
+ # Calculate date range
1748
+ if end_date:
1749
+ end = datetime.strptime(end_date, '%Y-%m-%d')
1750
+ else:
1751
+ end = datetime.now()
1752
+
1753
+ if start_date:
1754
+ start = datetime.strptime(start_date, '%Y-%m-%d')
1755
+ else:
1756
+ start = end - timedelta(days=days)
1757
+
1758
+ if not output_json:
1759
+ click.echo(f"Tag-based cost analysis: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
1760
+ click.echo(f"Tag key: {tag_key}")
1761
+ if tag_value:
1762
+ click.echo(f"Tag value: {tag_value}")
1763
+ click.echo("")
1764
+
1765
+ # Get credentials
1766
+ try:
1767
+ if 'aws_profile' in config:
1768
+ session = boto3.Session(profile_name=config['aws_profile'])
1769
+ else:
1770
+ creds = config['credentials']
1771
+ session = boto3.Session(
1772
+ aws_access_key_id=creds['aws_access_key_id'],
1773
+ aws_secret_access_key=creds['aws_secret_access_key'],
1774
+ aws_session_token=creds.get('aws_session_token')
1775
+ )
1776
+
1777
+ ce_client = session.client('ce', region_name='us-east-1')
1778
+
1779
+ # Build filter
1780
+ filter_parts = [
1781
+ {
1782
+ "Dimensions": {
1783
+ "Key": "LINKED_ACCOUNT",
1784
+ "Values": config['accounts']
1785
+ }
1786
+ },
1787
+ {
1788
+ "Not": {
1789
+ "Dimensions": {
1790
+ "Key": "RECORD_TYPE",
1791
+ "Values": ["Tax", "Support"]
1792
+ }
1793
+ }
1794
+ }
1795
+ ]
1796
+
1797
+ # Add tag filter if value specified
1798
+ if tag_value:
1799
+ filter_parts.append({
1800
+ "Tags": {
1801
+ "Key": tag_key,
1802
+ "Values": [tag_value]
1803
+ }
1804
+ })
1805
+
1806
+ cost_filter = {"And": filter_parts}
1807
+
1808
+ # Get costs grouped by tag values
1809
+ response = ce_client.get_cost_and_usage(
1810
+ TimePeriod={
1811
+ 'Start': start.strftime('%Y-%m-%d'),
1812
+ 'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
1813
+ },
1814
+ Granularity='MONTHLY',
1815
+ Metrics=['UnblendedCost'],
1816
+ GroupBy=[{
1817
+ 'Type': 'TAG',
1818
+ 'Key': tag_key
1819
+ }],
1820
+ Filter=cost_filter
1821
+ )
1822
+
1823
+ # Collect results
1824
+ tag_costs = {}
1825
+ for period in response['ResultsByTime']:
1826
+ for group in period['Groups']:
1827
+ tag_val = group['Keys'][0].split('$')[1] if '$' in group['Keys'][0] else group['Keys'][0]
1828
+ cost = float(group['Metrics']['UnblendedCost']['Amount'])
1829
+ tag_costs[tag_val] = tag_costs.get(tag_val, 0) + cost
1830
+
1831
+ # Sort by cost
1832
+ sorted_tags = sorted(tag_costs.items(), key=lambda x: x[1], reverse=True)
1833
+
1834
+ total = sum(tag_costs.values())
1835
+ num_days = (end - start).days
1836
+ daily_avg = total / num_days if num_days > 0 else 0
1837
+
1838
+ # Output results
1839
+ if output_json:
1840
+ import json
1841
+ result = {
1842
+ 'period': {
1843
+ 'start': start.strftime('%Y-%m-%d'),
1844
+ 'end': end.strftime('%Y-%m-%d'),
1845
+ 'days': num_days
1846
+ },
1847
+ 'tag_key': tag_key,
1848
+ 'tag_value_filter': tag_value,
1849
+ 'tag_costs': [{'tag_value': k, 'cost': v, 'percentage': (v/total*100) if total > 0 else 0} for k, v in sorted_tags],
1850
+ 'summary': {
1851
+ 'total': total,
1852
+ 'daily_avg': daily_avg,
1853
+ 'annual_projection': daily_avg * 365
1854
+ }
1855
+ }
1856
+ click.echo(json.dumps(result, indent=2))
1857
+ else:
1858
+ click.echo(f"Tag Value{' '*(30-len('Tag Value'))} | Cost | %")
1859
+ click.echo("-" * 60)
1860
+ for tag_val, cost in sorted_tags:
1861
+ pct = (cost / total * 100) if total > 0 else 0
1862
+ tag_display = tag_val[:30].ljust(30)
1863
+ click.echo(f"{tag_display} | ${cost:>9,.2f} | {pct:>5.1f}%")
1864
+ click.echo("-" * 60)
1865
+ click.echo(f"{'Total'.ljust(30)} | ${total:>9,.2f} | 100.0%")
1866
+ click.echo("")
1867
+ click.echo(f"Daily Avg: ${daily_avg:,.2f}")
1868
+ click.echo(f"Annual Projection: ${daily_avg * 365:,.0f}")
1869
+
1870
+ except Exception as e:
1871
+ raise click.ClickException(f"Tag analysis failed: {e}")
1872
+
1873
+
1874
+ @cli.command()
1875
+ @click.option('--profile', required=True, help='Profile name')
1876
+ @click.option('--query', required=True, help='SQL query to execute')
1877
+ @click.option('--database', default='athenacurcfn_cloud_intelligence_dashboard', help='Athena database name')
1878
+ @click.option('--output-bucket', help='S3 bucket for query results (default: from profile)')
1879
+ @click.option('--sso', help='AWS SSO profile name')
1880
+ def query(profile, query, database, output_bucket, sso):
1881
+ """
1882
+ Execute custom Athena SQL query on CUR data
1883
+
1884
+ Example:
1885
+ 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"
1886
+ """
1887
+ # Load profile
1888
+ config = load_profile(profile)
1889
+
1890
+ # Apply SSO if provided
1891
+ if sso:
1892
+ config['aws_profile'] = sso
1893
+
1894
+ # Get credentials
1895
+ try:
1896
+ if 'aws_profile' in config:
1897
+ session = boto3.Session(profile_name=config['aws_profile'])
1898
+ else:
1899
+ creds = config['credentials']
1900
+ session = boto3.Session(
1901
+ aws_access_key_id=creds['aws_access_key_id'],
1902
+ aws_secret_access_key=creds['aws_secret_access_key'],
1903
+ aws_session_token=creds.get('aws_session_token')
1904
+ )
1905
+
1906
+ athena_client = session.client('athena', region_name='us-east-1')
1907
+
1908
+ # Default output location
1909
+ if not output_bucket:
1910
+ output_bucket = 's3://khoros-finops-athena/athena/'
1911
+
1912
+ click.echo(f"Executing query on database: {database}")
1913
+ click.echo(f"Output location: {output_bucket}")
1914
+ click.echo("")
1915
+
1916
+ # Execute query
1917
+ response = athena_client.start_query_execution(
1918
+ QueryString=query,
1919
+ QueryExecutionContext={'Database': database},
1920
+ ResultConfiguration={'OutputLocation': output_bucket}
1921
+ )
1922
+
1923
+ query_id = response['QueryExecutionId']
1924
+ click.echo(f"Query ID: {query_id}")
1925
+ click.echo("Waiting for query to complete...")
1926
+
1927
+ # Wait for completion
1928
+ import time
1929
+ max_wait = 60
1930
+ waited = 0
1931
+ while waited < max_wait:
1932
+ status_response = athena_client.get_query_execution(QueryExecutionId=query_id)
1933
+ status = status_response['QueryExecution']['Status']['State']
1934
+
1935
+ if status == 'SUCCEEDED':
1936
+ click.echo("✓ Query completed successfully")
1937
+ break
1938
+ elif status in ['FAILED', 'CANCELLED']:
1939
+ reason = status_response['QueryExecution']['Status'].get('StateChangeReason', 'Unknown')
1940
+ raise click.ClickException(f"Query {status}: {reason}")
1941
+
1942
+ time.sleep(2)
1943
+ waited += 2
1944
+
1945
+ if waited >= max_wait:
1946
+ raise click.ClickException(f"Query timeout after {max_wait}s. Check query ID: {query_id}")
1947
+
1948
+ # Get results
1949
+ results = athena_client.get_query_results(QueryExecutionId=query_id)
1950
+
1951
+ # Display results
1952
+ rows = results['ResultSet']['Rows']
1953
+ if not rows:
1954
+ click.echo("No results returned")
1955
+ return
1956
+
1957
+ # Header
1958
+ headers = [col['VarCharValue'] for col in rows[0]['Data']]
1959
+ click.echo(" | ".join(headers))
1960
+ click.echo("-" * (len(" | ".join(headers))))
1961
+
1962
+ # Data rows
1963
+ for row in rows[1:]:
1964
+ values = [col.get('VarCharValue', '') for col in row['Data']]
1965
+ click.echo(" | ".join(values))
1966
+
1967
+ click.echo("")
1968
+ click.echo(f"Returned {len(rows)-1} rows")
1969
+
1970
+ except Exception as e:
1971
+ raise click.ClickException(f"Query failed: {e}")
1972
+
1973
+
734
1974
  if __name__ == '__main__':
735
1975
  cli()