aws-cost-calculator-cli 1.0.2__py3-none-any.whl → 1.8.2__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,53 +11,153 @@ 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
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
22
+
23
+
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
26
+
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
33
+
34
+ Returns:
35
+ Updated config dict
36
+ """
37
+ import subprocess
38
+
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
66
+
67
+ return config
16
68
 
17
69
 
18
70
  def load_profile(profile_name):
19
- """Load profile configuration from ~/.config/cost-calculator/profiles.json"""
71
+ """Load profile configuration from local file or DynamoDB API"""
72
+ import os
73
+ import requests
74
+
20
75
  config_dir = Path.home() / '.config' / 'cost-calculator'
21
76
  config_file = config_dir / 'profiles.json'
22
77
  creds_file = config_dir / 'credentials.json'
23
78
 
24
- if not config_file.exists():
79
+ # Try local file first
80
+ if config_file.exists():
81
+ 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
116
+
117
+ # Profile not found locally - try DynamoDB API
118
+ api_secret = os.environ.get('COST_API_SECRET')
119
+ if not api_secret:
25
120
  raise click.ClickException(
26
- f"Profile configuration not found at {config_file}\n"
121
+ f"Profile '{profile_name}' not found locally and COST_API_SECRET not set.\n"
27
122
  f"Run: cc init --profile {profile_name}"
28
123
  )
29
124
 
30
- with open(config_file) as f:
31
- profiles = json.load(f)
32
-
33
- if profile_name not in profiles:
34
- raise click.ClickException(
35
- f"Profile '{profile_name}' not found in {config_file}\n"
36
- f"Available profiles: {', '.join(profiles.keys())}"
125
+ try:
126
+ response = requests.post(
127
+ 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
128
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
129
+ json={'operation': 'get', 'profile_name': profile_name},
130
+ timeout=10
37
131
  )
38
-
39
- profile = profiles[profile_name]
40
-
41
- # Load credentials if using static credentials (not SSO)
42
- if 'aws_profile' not in profile:
43
- if not creds_file.exists():
44
- raise click.ClickException(
45
- f"No credentials found for profile '{profile_name}'.\n"
46
- f"Run: cc configure --profile {profile_name}"
47
- )
48
-
49
- with open(creds_file) as f:
50
- creds = json.load(f)
51
132
 
52
- if profile_name not in creds:
133
+ if response.status_code == 200:
134
+ response_data = response.json()
135
+ # API returns {"profile": {...}} wrapper
136
+ profile_data = response_data.get('profile', response_data)
137
+ profile = {'accounts': profile_data['accounts']}
138
+
139
+ # Check for AWS_PROFILE environment variable (SSO support)
140
+ if os.environ.get('AWS_PROFILE'):
141
+ profile['aws_profile'] = os.environ['AWS_PROFILE']
142
+ # Use environment credentials
143
+ elif os.environ.get('AWS_ACCESS_KEY_ID'):
144
+ profile['credentials'] = {
145
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
146
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
147
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
148
+ }
149
+
150
+ return profile
151
+ else:
53
152
  raise click.ClickException(
54
- f"No credentials found for profile '{profile_name}'.\n"
55
- f"Run: cc configure --profile {profile_name}"
153
+ f"Profile '{profile_name}' not found in DynamoDB.\n"
154
+ f"Run: cc profile create --name {profile_name} --accounts \"...\""
56
155
  )
57
-
58
- profile['credentials'] = creds[profile_name]
59
-
60
- return profile
156
+ except requests.exceptions.RequestException as e:
157
+ raise click.ClickException(
158
+ f"Failed to fetch profile from API: {e}\n"
159
+ f"Run: cc init --profile {profile_name}"
160
+ )
61
161
 
62
162
 
63
163
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -225,9 +325,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
225
325
  # Calculate days in the month that the support covers
226
326
  # Support on Nov 1 covers October (31 days)
227
327
  support_month = support_month_date - timedelta(days=1) # Go back to previous month
228
- days_in_support_month = support_month.day # This gives us the last day of the month
328
+ import calendar
329
+ days_in_support_month = calendar.monthrange(support_month.year, support_month.month)[1]
229
330
 
230
- # Support allocation: divide by 2 (half to Khoros), then by days in month
331
+ # Support allocation: divide by 2 (50% allocation), then by days in month
231
332
  support_per_day = (support_cost / 2) / days_in_support_month
232
333
 
233
334
  # Calculate daily rate
@@ -286,18 +387,157 @@ def cli():
286
387
  pass
287
388
 
288
389
 
390
+ @cli.command('setup-cur')
391
+ @click.option('--database', required=True, prompt='CUR Athena Database', help='Athena database name for CUR')
392
+ @click.option('--table', required=True, prompt='CUR Table Name', help='CUR table name')
393
+ @click.option('--s3-output', required=True, prompt='S3 Output Location', help='S3 bucket for Athena query results')
394
+ def setup_cur(database, table, s3_output):
395
+ """
396
+ Configure CUR (Cost and Usage Report) settings for resource-level queries
397
+
398
+ Saves CUR configuration to ~/.config/cost-calculator/cur_config.json
399
+
400
+ Example:
401
+ cc setup-cur --database my_cur_db --table cur_table --s3-output s3://my-bucket/
402
+ """
403
+ import json
404
+
405
+ config_dir = Path.home() / '.config' / 'cost-calculator'
406
+ config_dir.mkdir(parents=True, exist_ok=True)
407
+
408
+ config_file = config_dir / 'cur_config.json'
409
+
410
+ config = {
411
+ 'database': database,
412
+ 'table': table,
413
+ 's3_output': s3_output
414
+ }
415
+
416
+ with open(config_file, 'w') as f:
417
+ json.dump(config, f, indent=2)
418
+
419
+ click.echo(f"✓ CUR configuration saved to {config_file}")
420
+ click.echo(f" Database: {database}")
421
+ click.echo(f" Table: {table}")
422
+ click.echo(f" S3 Output: {s3_output}")
423
+ click.echo("")
424
+ click.echo("You can now use: cc drill --service 'EC2 - Other' --resources")
425
+
426
+
427
+ @cli.command('setup-api')
428
+ @click.option('--api-secret', required=True, prompt=True, hide_input=True, help='COST_API_SECRET value')
429
+ def setup_api(api_secret):
430
+ """
431
+ Configure COST_API_SECRET for backend API access
432
+
433
+ Saves the API secret to the appropriate location based on your OS:
434
+ - Mac/Linux: ~/.zshrc or ~/.bashrc
435
+ - Windows: User environment variables
436
+
437
+ Example:
438
+ cc setup-api --api-secret your-secret-here
439
+
440
+ Or let it prompt you (input will be hidden):
441
+ cc setup-api
442
+ """
443
+ system = platform.system()
444
+
445
+ if system == "Windows":
446
+ # Windows: Set user environment variable
447
+ try:
448
+ import winreg
449
+ key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_SET_VALUE)
450
+ winreg.SetValueEx(key, 'COST_API_SECRET', 0, winreg.REG_SZ, api_secret)
451
+ winreg.CloseKey(key)
452
+ click.echo("✓ COST_API_SECRET saved to Windows user environment variables")
453
+ click.echo(" Please restart your terminal for changes to take effect")
454
+ except Exception as e:
455
+ click.echo(f"✗ Error setting Windows environment variable: {e}", err=True)
456
+ click.echo("\nManual setup:")
457
+ click.echo("1. Open System Properties > Environment Variables")
458
+ click.echo("2. Add new User variable:")
459
+ click.echo(" Name: COST_API_SECRET")
460
+ click.echo(f" Value: {api_secret}")
461
+ return
462
+ else:
463
+ # Mac/Linux: Add to shell profile
464
+ shell = os.environ.get('SHELL', '/bin/bash')
465
+
466
+ if 'zsh' in shell:
467
+ profile_file = Path.home() / '.zshrc'
468
+ else:
469
+ profile_file = Path.home() / '.bashrc'
470
+
471
+ # Check if already exists
472
+ export_line = f'export COST_API_SECRET="{api_secret}"'
473
+
474
+ try:
475
+ if profile_file.exists():
476
+ content = profile_file.read_text()
477
+ if 'COST_API_SECRET' in content:
478
+ # Replace existing
479
+ lines = content.split('\n')
480
+ new_lines = []
481
+ for line in lines:
482
+ if 'COST_API_SECRET' in line and line.strip().startswith('export'):
483
+ new_lines.append(export_line)
484
+ else:
485
+ new_lines.append(line)
486
+ profile_file.write_text('\n'.join(new_lines))
487
+ click.echo(f"✓ Updated COST_API_SECRET in {profile_file}")
488
+ else:
489
+ # Append
490
+ with profile_file.open('a') as f:
491
+ f.write(f'\n# AWS Cost Calculator API Secret\n{export_line}\n')
492
+ click.echo(f"✓ Added COST_API_SECRET to {profile_file}")
493
+ else:
494
+ # Create new file
495
+ profile_file.write_text(f'# AWS Cost Calculator API Secret\n{export_line}\n')
496
+ click.echo(f"✓ Created {profile_file} with COST_API_SECRET")
497
+
498
+ # Also set for current session
499
+ os.environ['COST_API_SECRET'] = api_secret
500
+ click.echo(f"✓ Set COST_API_SECRET for current session")
501
+ click.echo(f"\nTo use in new terminals, run: source {profile_file}")
502
+
503
+ except Exception as e:
504
+ click.echo(f"✗ Error writing to {profile_file}: {e}", err=True)
505
+ click.echo(f"\nManual setup: Add this line to {profile_file}:")
506
+ click.echo(f" {export_line}")
507
+ return
508
+
509
+
289
510
  @cli.command()
290
511
  @click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
291
512
  @click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
292
513
  @click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
293
514
  @click.option('--window', default=30, help='Number of days to analyze (default: 30)')
294
515
  @click.option('--json-output', is_flag=True, help='Output as JSON')
295
- def calculate(profile, start_date, offset, window, json_output):
296
- """Calculate AWS costs for the specified period"""
516
+ @click.option('--sso', help='AWS SSO profile name (e.g., my_sso_profile)')
517
+ @click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
518
+ @click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
519
+ @click.option('--session-token', help='AWS Session Token (for static credentials)')
520
+ def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
521
+ """
522
+ Calculate AWS costs for the specified period
523
+
524
+ \b
525
+ Authentication Options:
526
+ 1. SSO: --sso <profile_name>
527
+ Example: cc calculate --profile myprofile --sso my_sso_profile
528
+
529
+ 2. Static Credentials: --access-key-id, --secret-access-key, --session-token
530
+ Example: cc calculate --profile myprofile --access-key-id ASIA... --secret-access-key ... --session-token ...
531
+
532
+ 3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
533
+ """
297
534
 
298
535
  # Load profile configuration
299
536
  config = load_profile(profile)
300
537
 
538
+ # Apply authentication options
539
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
540
+
301
541
  # Calculate costs
302
542
  result = calculate_costs(
303
543
  profile_config=config,
@@ -395,6 +635,77 @@ def list_profiles():
395
635
  click.echo("")
396
636
 
397
637
 
638
+ @cli.command()
639
+ def setup():
640
+ """Show setup instructions for manual profile configuration"""
641
+ import platform
642
+
643
+ system = platform.system()
644
+
645
+ if system == "Windows":
646
+ config_path = "%USERPROFILE%\\.config\\cost-calculator\\profiles.json"
647
+ config_path_example = "C:\\Users\\YourName\\.config\\cost-calculator\\profiles.json"
648
+ mkdir_cmd = "mkdir %USERPROFILE%\\.config\\cost-calculator"
649
+ edit_cmd = "notepad %USERPROFILE%\\.config\\cost-calculator\\profiles.json"
650
+ else: # macOS/Linux
651
+ config_path = "~/.config/cost-calculator/profiles.json"
652
+ config_path_example = "/Users/yourname/.config/cost-calculator/profiles.json"
653
+ mkdir_cmd = "mkdir -p ~/.config/cost-calculator"
654
+ edit_cmd = "nano ~/.config/cost-calculator/profiles.json"
655
+
656
+ click.echo("=" * 70)
657
+ click.echo("AWS Cost Calculator - Manual Profile Setup")
658
+ click.echo("=" * 70)
659
+ click.echo("")
660
+ click.echo(f"Platform: {system}")
661
+ click.echo(f"Config location: {config_path}")
662
+ click.echo("")
663
+ click.echo("Step 1: Create the config directory")
664
+ click.echo(f" {mkdir_cmd}")
665
+ click.echo("")
666
+ click.echo("Step 2: Create the profiles.json file")
667
+ click.echo(f" {edit_cmd}")
668
+ click.echo("")
669
+ click.echo("Step 3: Add your profile configuration (JSON format):")
670
+ click.echo("")
671
+ click.echo(' {')
672
+ click.echo(' "myprofile": {')
673
+ click.echo(' "aws_profile": "my_aws_profile",')
674
+ click.echo(' "accounts": [')
675
+ click.echo(' "123456789012",')
676
+ click.echo(' "234567890123",')
677
+ click.echo(' "345678901234"')
678
+ click.echo(' ]')
679
+ click.echo(' }')
680
+ click.echo(' }')
681
+ click.echo("")
682
+ click.echo("Step 4: Save the file")
683
+ click.echo("")
684
+ click.echo("Step 5: Verify it works")
685
+ click.echo(" cc list-profiles")
686
+ click.echo("")
687
+ click.echo("Step 6: Configure AWS credentials")
688
+ click.echo(" Option A (SSO):")
689
+ click.echo(" aws sso login --profile my_aws_profile")
690
+ click.echo(" cc calculate --profile myprofile")
691
+ click.echo("")
692
+ click.echo(" Option B (Static credentials):")
693
+ click.echo(" cc configure --profile myprofile")
694
+ click.echo(" cc calculate --profile myprofile")
695
+ click.echo("")
696
+ click.echo("=" * 70)
697
+ click.echo("")
698
+ click.echo("For multiple profiles, add more entries to the JSON:")
699
+ click.echo("")
700
+ click.echo(' {')
701
+ click.echo(' "profile1": { ... },')
702
+ click.echo(' "profile2": { ... }')
703
+ click.echo(' }')
704
+ click.echo("")
705
+ click.echo(f"Full path example: {config_path_example}")
706
+ click.echo("=" * 70)
707
+
708
+
398
709
  @cli.command()
399
710
  @click.option('--profile', required=True, help='Profile name to configure')
400
711
  @click.option('--access-key-id', prompt=True, hide_input=False, help='AWS Access Key ID')
@@ -468,5 +779,566 @@ def configure(profile, access_key_id, secret_access_key, session_token, region):
468
779
  click.echo(" you'll need to reconfigure when they expire.")
469
780
 
470
781
 
782
+ @cli.command()
783
+ @click.option('--profile', required=True, help='Profile name')
784
+ @click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
785
+ @click.option('--output', default='cost_trends.md', help='Output markdown file (default: cost_trends.md)')
786
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
787
+ @click.option('--sso', help='AWS SSO profile name')
788
+ @click.option('--access-key-id', help='AWS Access Key ID')
789
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
790
+ @click.option('--session-token', help='AWS Session Token')
791
+ def trends(profile, weeks, output, json_output, sso, access_key_id, secret_access_key, session_token):
792
+ """Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
793
+
794
+ # Load profile configuration
795
+ config = load_profile(profile)
796
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
797
+
798
+ click.echo(f"Analyzing last {weeks} weeks...")
799
+ click.echo("")
800
+
801
+ # Execute via API or locally
802
+ trends_data = execute_trends(config, weeks)
803
+
804
+ if json_output:
805
+ # Output as JSON
806
+ import json
807
+ click.echo(json.dumps(trends_data, indent=2, default=str))
808
+ else:
809
+ # Generate markdown report
810
+ markdown = format_trends_markdown(trends_data)
811
+
812
+ # Save to file
813
+ with open(output, 'w') as f:
814
+ f.write(markdown)
815
+
816
+ click.echo(f"✓ Trends report saved to {output}")
817
+ click.echo("")
818
+
819
+ # Show summary
820
+ click.echo("WEEK-OVER-WEEK:")
821
+ for comparison in trends_data['wow_comparisons']:
822
+ prev_week = comparison['prev_week']['label']
823
+ curr_week = comparison['curr_week']['label']
824
+ num_increases = len(comparison['increases'])
825
+ num_decreases = len(comparison['decreases'])
826
+
827
+ click.echo(f" {prev_week} → {curr_week}")
828
+ click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
829
+
830
+ if comparison['increases']:
831
+ top = comparison['increases'][0]
832
+ click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
833
+
834
+ click.echo("")
835
+
836
+ click.echo("TRAILING 30-DAY (T-30):")
837
+ for comparison in trends_data['t30_comparisons']:
838
+ baseline_week = comparison['baseline_week']['label']
839
+ curr_week = comparison['curr_week']['label']
840
+ num_increases = len(comparison['increases'])
841
+ num_decreases = len(comparison['decreases'])
842
+
843
+ click.echo(f" {curr_week} vs {baseline_week}")
844
+ click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
845
+
846
+ if comparison['increases']:
847
+ top = comparison['increases'][0]
848
+ click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
849
+
850
+ click.echo("")
851
+
852
+
853
+ @cli.command()
854
+ @click.option('--profile', required=True, help='Profile name')
855
+ @click.option('--months', default=6, help='Number of months to analyze (default: 6)')
856
+ @click.option('--output', default='monthly_trends.md', help='Output markdown file (default: monthly_trends.md)')
857
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
858
+ @click.option('--sso', help='AWS SSO profile name')
859
+ @click.option('--access-key-id', help='AWS Access Key ID')
860
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
861
+ @click.option('--session-token', help='AWS Session Token')
862
+ def monthly(profile, months, output, json_output, sso, access_key_id, secret_access_key, session_token):
863
+ """Analyze month-over-month cost trends at service level"""
864
+
865
+ # Load profile
866
+ config = load_profile(profile)
867
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
868
+
869
+ click.echo(f"Analyzing last {months} months...")
870
+ click.echo("")
871
+
872
+ # Execute via API or locally
873
+ monthly_data = execute_monthly(config, months)
874
+
875
+ if json_output:
876
+ # Output as JSON
877
+ output_data = {
878
+ 'generated': datetime.now().isoformat(),
879
+ 'months': months,
880
+ 'comparisons': []
881
+ }
882
+
883
+ for comparison in monthly_data['comparisons']:
884
+ output_data['comparisons'].append({
885
+ 'prev_month': comparison['prev_month']['label'],
886
+ 'curr_month': comparison['curr_month']['label'],
887
+ 'increases': comparison['increases'],
888
+ 'decreases': comparison['decreases'],
889
+ 'total_increase': comparison['total_increase'],
890
+ 'total_decrease': comparison['total_decrease']
891
+ })
892
+
893
+ click.echo(json.dumps(output_data, indent=2))
894
+ else:
895
+ # Generate markdown report
896
+ markdown = format_monthly_markdown(monthly_data)
897
+
898
+ # Save to file
899
+ with open(output, 'w') as f:
900
+ f.write(markdown)
901
+
902
+ click.echo(f"✓ Monthly trends report saved to {output}")
903
+ click.echo("")
904
+
905
+ # Show summary
906
+ for comparison in monthly_data['comparisons']:
907
+ prev_month = comparison['prev_month']['label']
908
+ curr_month = comparison['curr_month']['label']
909
+ num_increases = len(comparison['increases'])
910
+ num_decreases = len(comparison['decreases'])
911
+
912
+ click.echo(f"{prev_month} → {curr_month}")
913
+ click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
914
+
915
+ if comparison['increases']:
916
+ top = comparison['increases'][0]
917
+ click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
918
+
919
+ click.echo("")
920
+
921
+
922
+ @cli.command()
923
+ @click.option('--profile', required=True, help='Profile name')
924
+ @click.option('--weeks', default=4, help='Number of weeks to analyze (default: 4)')
925
+ @click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
926
+ @click.option('--account', help='Filter by account ID')
927
+ @click.option('--usage-type', help='Filter by usage type')
928
+ @click.option('--resources', is_flag=True, help='Show individual resource IDs (requires CUR, uses Athena)')
929
+ @click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
930
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
931
+ @click.option('--sso', help='AWS SSO profile name')
932
+ @click.option('--access-key-id', help='AWS Access Key ID')
933
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
934
+ @click.option('--session-token', help='AWS Session Token')
935
+ def drill(profile, weeks, service, account, usage_type, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
936
+ """
937
+ Drill down into cost changes by service, account, or usage type
938
+
939
+ Add --resources flag to see individual resource IDs and costs (requires CUR data via Athena)
940
+ """
941
+
942
+ # Load profile
943
+ config = load_profile(profile)
944
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
945
+
946
+ # Show filters
947
+ click.echo(f"Analyzing last {weeks} weeks...")
948
+ if service:
949
+ click.echo(f" Service filter: {service}")
950
+ if account:
951
+ click.echo(f" Account filter: {account}")
952
+ if usage_type:
953
+ click.echo(f" Usage type filter: {usage_type}")
954
+ if resources:
955
+ click.echo(f" Mode: Resource-level (CUR via Athena)")
956
+ click.echo("")
957
+
958
+ # Execute via API or locally
959
+ drill_data = execute_drill(config, weeks, service, account, usage_type, resources)
960
+
961
+ # Handle resource-level output differently
962
+ if resources:
963
+ from cost_calculator.cur import format_resource_output
964
+ output_text = format_resource_output(drill_data)
965
+ click.echo(output_text)
966
+ return
967
+
968
+ if json_output:
969
+ # Output as JSON
970
+ output_data = {
971
+ 'generated': datetime.now().isoformat(),
972
+ 'weeks': weeks,
973
+ 'filters': drill_data['filters'],
974
+ 'group_by': drill_data['group_by'],
975
+ 'comparisons': []
976
+ }
977
+
978
+ for comparison in drill_data['comparisons']:
979
+ output_data['comparisons'].append({
980
+ 'prev_week': comparison['prev_week']['label'],
981
+ 'curr_week': comparison['curr_week']['label'],
982
+ 'increases': comparison['increases'],
983
+ 'decreases': comparison['decreases'],
984
+ 'total_increase': comparison['total_increase'],
985
+ 'total_decrease': comparison['total_decrease']
986
+ })
987
+
988
+ click.echo(json.dumps(output_data, indent=2))
989
+ else:
990
+ # Generate markdown report
991
+ markdown = format_drill_down_markdown(drill_data)
992
+
993
+ # Save to file
994
+ with open(output, 'w') as f:
995
+ f.write(markdown)
996
+
997
+ click.echo(f"✓ Drill-down report saved to {output}")
998
+ click.echo("")
999
+
1000
+ # Show summary
1001
+ group_by_label = {
1002
+ 'SERVICE': 'services',
1003
+ 'LINKED_ACCOUNT': 'accounts',
1004
+ 'USAGE_TYPE': 'usage types',
1005
+ 'REGION': 'regions'
1006
+ }.get(drill_data['group_by'], 'items')
1007
+
1008
+ click.echo(f"Showing top {group_by_label}:")
1009
+ for comparison in drill_data['comparisons']:
1010
+ prev_week = comparison['prev_week']['label']
1011
+ curr_week = comparison['curr_week']['label']
1012
+ num_increases = len(comparison['increases'])
1013
+ num_decreases = len(comparison['decreases'])
1014
+
1015
+ click.echo(f"{prev_week} → {curr_week}")
1016
+ click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
1017
+
1018
+ if comparison['increases']:
1019
+ top = comparison['increases'][0]
1020
+ click.echo(f" Top: {top['dimension'][:50]} (+${top['change']:,.2f})")
1021
+
1022
+ click.echo("")
1023
+
1024
+
1025
+ @cli.command()
1026
+ @click.option('--profile', required=True, help='Profile name')
1027
+ @click.option('--type', 'analysis_type', default='summary',
1028
+ type=click.Choice(['summary', 'volatility', 'trends', 'search']),
1029
+ help='Analysis type')
1030
+ @click.option('--weeks', default=12, help='Number of weeks (default: 12)')
1031
+ @click.option('--pattern', help='Service search pattern (for search type)')
1032
+ @click.option('--min-cost', type=float, help='Minimum cost filter (for search type)')
1033
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
1034
+ def analyze(profile, analysis_type, weeks, pattern, min_cost, json_output):
1035
+ """Perform pandas-based analysis (aggregations, volatility, trends, search)"""
1036
+
1037
+ config = load_profile(profile)
1038
+
1039
+ if not json_output:
1040
+ click.echo(f"Running {analysis_type} analysis for {weeks} weeks...")
1041
+
1042
+ from cost_calculator.executor import execute_analyze
1043
+ result = execute_analyze(config, weeks, analysis_type, pattern, min_cost)
1044
+
1045
+ if json_output:
1046
+ import json
1047
+ click.echo(json.dumps(result, indent=2, default=str))
1048
+ else:
1049
+ # Format output based on type
1050
+ if analysis_type == 'summary':
1051
+ click.echo(f"\n📊 Summary ({result.get('total_services', 0)} services)")
1052
+ click.echo(f"Weeks analyzed: {result.get('weeks_analyzed', 0)}")
1053
+ click.echo(f"\nTop 10 Services (by total change):")
1054
+ for svc in result.get('services', [])[:10]:
1055
+ click.echo(f" {svc['service']}")
1056
+ click.echo(f" Total: ${svc['change_sum']:,.2f}")
1057
+ click.echo(f" Average: ${svc['change_mean']:,.2f}")
1058
+ click.echo(f" Volatility: {svc['volatility']:.3f}")
1059
+
1060
+ elif analysis_type == 'volatility':
1061
+ click.echo(f"\n📈 High Volatility Services:")
1062
+ for svc in result.get('high_volatility_services', [])[:10]:
1063
+ click.echo(f" {svc['service']}: CV={svc['coefficient_of_variation']:.3f}")
1064
+
1065
+ outliers = result.get('outliers', [])
1066
+ if outliers:
1067
+ click.echo(f"\n⚠️ Outliers ({len(outliers)}):")
1068
+ for o in outliers[:5]:
1069
+ click.echo(f" {o['service']} ({o['week']}): ${o['change']:,.2f} (z={o['z_score']:.2f})")
1070
+
1071
+ elif analysis_type == 'trends':
1072
+ inc = result.get('increasing_trends', [])
1073
+ dec = result.get('decreasing_trends', [])
1074
+
1075
+ click.echo(f"\n📈 Increasing Trends ({len(inc)}):")
1076
+ for t in inc[:5]:
1077
+ click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
1078
+
1079
+ click.echo(f"\n📉 Decreasing Trends ({len(dec)}):")
1080
+ for t in dec[:5]:
1081
+ click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
1082
+
1083
+ elif analysis_type == 'search':
1084
+ matches = result.get('matches', [])
1085
+ click.echo(f"\n🔍 Search Results ({len(matches)} matches)")
1086
+ if pattern:
1087
+ click.echo(f"Pattern: {pattern}")
1088
+ if min_cost:
1089
+ click.echo(f"Min cost: ${min_cost:,.2f}")
1090
+
1091
+ for m in matches[:20]:
1092
+ click.echo(f" {m['service']}: ${m['curr_cost']:,.2f}")
1093
+
1094
+
1095
+ @cli.command()
1096
+ @click.argument('operation', type=click.Choice(['list', 'get', 'create', 'update', 'delete']))
1097
+ @click.option('--name', help='Profile name')
1098
+ @click.option('--accounts', help='Comma-separated account IDs')
1099
+ @click.option('--description', help='Profile description')
1100
+ def profile(operation, name, accounts, description):
1101
+ """Manage profiles (CRUD operations)"""
1102
+
1103
+ from cost_calculator.executor import execute_profile_operation
1104
+
1105
+ # Parse accounts if provided
1106
+ account_list = None
1107
+ if accounts:
1108
+ account_list = [a.strip() for a in accounts.split(',')]
1109
+
1110
+ result = execute_profile_operation(
1111
+ operation=operation,
1112
+ profile_name=name,
1113
+ accounts=account_list,
1114
+ description=description
1115
+ )
1116
+
1117
+ if operation == 'list':
1118
+ profiles = result.get('profiles', [])
1119
+ click.echo(f"\n📋 Profiles ({len(profiles)}):")
1120
+ for p in profiles:
1121
+ click.echo(f" {p['profile_name']}: {len(p.get('accounts', []))} accounts")
1122
+ if p.get('description'):
1123
+ click.echo(f" {p['description']}")
1124
+
1125
+ elif operation == 'get':
1126
+ profile_data = result.get('profile', {})
1127
+ click.echo(f"\n📋 Profile: {profile_data.get('profile_name')}")
1128
+ click.echo(f"Accounts: {len(profile_data.get('accounts', []))}")
1129
+ if profile_data.get('description'):
1130
+ click.echo(f"Description: {profile_data['description']}")
1131
+ click.echo(f"\nAccounts:")
1132
+ for acc in profile_data.get('accounts', []):
1133
+ click.echo(f" {acc}")
1134
+
1135
+ else:
1136
+ click.echo(result.get('message', 'Operation completed'))
1137
+
1138
+
1139
+ @cli.command()
1140
+ @click.option('--profile', required=True, help='Profile name')
1141
+ @click.option('--sso', help='AWS SSO profile to use')
1142
+ @click.option('--weeks', default=8, help='Number of weeks to analyze')
1143
+ @click.option('--account', help='Focus on specific account ID')
1144
+ @click.option('--service', help='Focus on specific service')
1145
+ @click.option('--no-cloudtrail', is_flag=True, help='Skip CloudTrail analysis (faster)')
1146
+ @click.option('--output', default='investigation_report.md', help='Output file path')
1147
+ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
1148
+ """
1149
+ Multi-stage cost investigation:
1150
+ 1. Analyze cost trends and drill-downs
1151
+ 2. Inventory actual resources in problem accounts
1152
+ 3. Analyze CloudTrail events (optional)
1153
+ 4. Generate comprehensive report
1154
+ """
1155
+ from cost_calculator.executor import execute_trends, execute_drill, get_credentials_dict
1156
+ from cost_calculator.api_client import call_lambda_api, is_api_configured
1157
+ from cost_calculator.forensics import format_investigation_report
1158
+ from datetime import datetime, timedelta
1159
+
1160
+ click.echo("=" * 80)
1161
+ click.echo("COST INVESTIGATION")
1162
+ click.echo("=" * 80)
1163
+ click.echo(f"Profile: {profile}")
1164
+ click.echo(f"Weeks: {weeks}")
1165
+ if account:
1166
+ click.echo(f"Account: {account}")
1167
+ if service:
1168
+ click.echo(f"Service: {service}")
1169
+ click.echo("")
1170
+
1171
+ # Load profile
1172
+ config = load_profile(profile)
1173
+
1174
+ # Override with SSO if provided
1175
+ if sso:
1176
+ config['aws_profile'] = sso
1177
+
1178
+ # Step 1: Cost Analysis
1179
+ click.echo("Step 1/3: Analyzing cost trends...")
1180
+ try:
1181
+ trends_data = execute_trends(config, weeks)
1182
+ click.echo(f"✓ Found cost data for {weeks} weeks")
1183
+ except Exception as e:
1184
+ click.echo(f"✗ Error analyzing trends: {str(e)}")
1185
+ trends_data = None
1186
+
1187
+ # Step 2: Drill-down
1188
+ click.echo("\nStep 2/3: Drilling down into costs...")
1189
+ drill_data = None
1190
+ if service or account:
1191
+ try:
1192
+ drill_data = execute_drill(config, weeks, service, account, None, False)
1193
+ click.echo(f"✓ Drill-down complete")
1194
+ except Exception as e:
1195
+ click.echo(f"✗ Error in drill-down: {str(e)}")
1196
+
1197
+ # Step 3: Resource Inventory
1198
+ click.echo("\nStep 3/3: Inventorying resources...")
1199
+ inventories = []
1200
+ cloudtrail_analyses = []
1201
+
1202
+ # Determine which accounts to investigate
1203
+ accounts_to_investigate = []
1204
+ if account:
1205
+ accounts_to_investigate = [account]
1206
+ else:
1207
+ # Extract top cost accounts from trends/drill data
1208
+ # For now, we'll need the user to specify
1209
+ click.echo("⚠️ No account specified. Use --account to inventory resources.")
1210
+
1211
+ # For each account, do inventory and CloudTrail via backend API
1212
+ for acc_id in accounts_to_investigate:
1213
+ click.echo(f"\n Investigating account {acc_id}...")
1214
+
1215
+ # Get credentials (SSO or static)
1216
+ account_creds = get_credentials_dict(config)
1217
+ if not account_creds:
1218
+ click.echo(f" ⚠️ No credentials available for account")
1219
+ continue
1220
+
1221
+ # Inventory resources via backend API
1222
+ try:
1223
+ regions = ['us-west-2', 'us-east-1', 'eu-west-1']
1224
+ for region in regions:
1225
+ try:
1226
+ if is_api_configured():
1227
+ inv = call_lambda_api(
1228
+ 'forensics',
1229
+ account_creds,
1230
+ [], # accounts not needed for forensics
1231
+ operation='inventory',
1232
+ account_id=acc_id,
1233
+ region=region
1234
+ )
1235
+ else:
1236
+ # Fallback to local execution
1237
+ from cost_calculator.forensics import inventory_resources
1238
+ acc_profile = find_account_profile(acc_id)
1239
+ if not acc_profile:
1240
+ raise Exception("No SSO profile found and API not configured")
1241
+ inv = inventory_resources(acc_id, acc_profile, region)
1242
+
1243
+ if not inv.get('error'):
1244
+ inventories.append(inv)
1245
+ click.echo(f" ✓ Inventory complete for {region}")
1246
+ click.echo(f" - EC2: {len(inv['ec2_instances'])} instances")
1247
+ click.echo(f" - EFS: {len(inv['efs_file_systems'])} file systems ({inv.get('total_efs_size_gb', 0):,.0f} GB)")
1248
+ click.echo(f" - ELB: {len(inv['load_balancers'])} load balancers")
1249
+ break
1250
+ except Exception as e:
1251
+ continue
1252
+ except Exception as e:
1253
+ click.echo(f" ✗ Inventory error: {str(e)}")
1254
+
1255
+ # CloudTrail analysis via backend API
1256
+ if not no_cloudtrail:
1257
+ try:
1258
+ start_date = (datetime.now() - timedelta(days=weeks * 7)).isoformat() + 'Z'
1259
+ end_date = datetime.now().isoformat() + 'Z'
1260
+
1261
+ if is_api_configured():
1262
+ ct_analysis = call_lambda_api(
1263
+ 'forensics',
1264
+ account_creds,
1265
+ [],
1266
+ operation='cloudtrail',
1267
+ account_id=acc_id,
1268
+ start_date=start_date,
1269
+ end_date=end_date,
1270
+ region='us-west-2'
1271
+ )
1272
+ else:
1273
+ # Fallback to local execution
1274
+ from cost_calculator.forensics import analyze_cloudtrail
1275
+ acc_profile = find_account_profile(acc_id)
1276
+ if not acc_profile:
1277
+ raise Exception("No SSO profile found and API not configured")
1278
+ start_dt = datetime.now() - timedelta(days=weeks * 7)
1279
+ end_dt = datetime.now()
1280
+ ct_analysis = analyze_cloudtrail(acc_id, acc_profile, start_dt, end_dt)
1281
+
1282
+ cloudtrail_analyses.append(ct_analysis)
1283
+
1284
+ if ct_analysis.get('error'):
1285
+ click.echo(f" ⚠️ CloudTrail: {ct_analysis['error']}")
1286
+ else:
1287
+ click.echo(f" ✓ CloudTrail analysis complete")
1288
+ click.echo(f" - {len(ct_analysis['event_summary'])} event types")
1289
+ click.echo(f" - {len(ct_analysis['write_events'])} resource changes")
1290
+ except Exception as e:
1291
+ click.echo(f" ✗ CloudTrail error: {str(e)}")
1292
+
1293
+ # Generate report
1294
+ click.echo(f"\nGenerating report...")
1295
+ report = format_investigation_report(trends_data, inventories, cloudtrail_analyses if not no_cloudtrail else None)
1296
+
1297
+ # Write to file
1298
+ with open(output, 'w') as f:
1299
+ f.write(report)
1300
+
1301
+ click.echo(f"\n✓ Investigation complete!")
1302
+ click.echo(f"✓ Report saved to: {output}")
1303
+ click.echo("")
1304
+
1305
+
1306
+ def find_account_profile(account_id):
1307
+ """
1308
+ Find the SSO profile name for a given account ID
1309
+ Returns profile name or None
1310
+ """
1311
+ import subprocess
1312
+
1313
+ try:
1314
+ # Get list of profiles
1315
+ result = subprocess.run(
1316
+ ['aws', 'configure', 'list-profiles'],
1317
+ capture_output=True,
1318
+ text=True
1319
+ )
1320
+
1321
+ profiles = result.stdout.strip().split('\n')
1322
+
1323
+ # Check each profile
1324
+ for profile in profiles:
1325
+ try:
1326
+ result = subprocess.run(
1327
+ ['aws', 'sts', 'get-caller-identity', '--profile', profile],
1328
+ capture_output=True,
1329
+ text=True,
1330
+ timeout=5
1331
+ )
1332
+
1333
+ if account_id in result.stdout:
1334
+ return profile
1335
+ except:
1336
+ continue
1337
+
1338
+ return None
1339
+ except:
1340
+ return None
1341
+
1342
+
471
1343
  if __name__ == '__main__':
472
1344
  cli()