aws-cost-calculator-cli 1.5.1__py3-none-any.whl → 1.6.3__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.

@@ -0,0 +1,148 @@
1
+ """
2
+ Lambda handler for profile CRUD operations.
3
+ """
4
+ import json
5
+ import os
6
+ import boto3
7
+ from datetime import datetime
8
+
9
+
10
+ def handler(event, context):
11
+ """Handle profile CRUD operations."""
12
+
13
+ # Parse request
14
+ try:
15
+ if isinstance(event.get('body'), str):
16
+ body = json.loads(event['body'])
17
+ else:
18
+ body = event.get('body', {})
19
+ except:
20
+ body = event
21
+
22
+ # Validate API secret
23
+ headers = event.get('headers', {})
24
+ api_secret = headers.get('X-API-Secret') or headers.get('x-api-secret')
25
+
26
+ secret_name = os.environ.get('SECRET_NAME', 'cost-calculator-api-secret')
27
+ secrets_client = boto3.client('secretsmanager')
28
+
29
+ try:
30
+ secret_response = secrets_client.get_secret_value(SecretId=secret_name)
31
+ expected_secret = secret_response['SecretString']
32
+
33
+ if api_secret != expected_secret:
34
+ return {
35
+ 'statusCode': 401,
36
+ 'headers': {'Content-Type': 'application/json'},
37
+ 'body': json.dumps({'error': 'Unauthorized'})
38
+ }
39
+ except Exception as e:
40
+ return {
41
+ 'statusCode': 500,
42
+ 'headers': {'Content-Type': 'application/json'},
43
+ 'body': json.dumps({'error': f'Secret validation failed: {str(e)}'})
44
+ }
45
+
46
+ # Get operation
47
+ operation = body.get('operation') # list, get, create, update, delete
48
+ profile_name = body.get('profile_name')
49
+
50
+ # DynamoDB table
51
+ table_name = os.environ.get('PROFILES_TABLE', 'cost-calculator-profiles')
52
+ dynamodb = boto3.resource('dynamodb')
53
+ table = dynamodb.Table(table_name)
54
+
55
+ try:
56
+ if operation == 'list':
57
+ # List all profiles
58
+ response = table.scan()
59
+ profiles = response.get('Items', [])
60
+ return {
61
+ 'statusCode': 200,
62
+ 'headers': {'Content-Type': 'application/json'},
63
+ 'body': json.dumps({'profiles': profiles})
64
+ }
65
+
66
+ elif operation == 'get':
67
+ # Get specific profile
68
+ if not profile_name:
69
+ return {
70
+ 'statusCode': 400,
71
+ 'headers': {'Content-Type': 'application/json'},
72
+ 'body': json.dumps({'error': 'profile_name required'})
73
+ }
74
+
75
+ response = table.get_item(Key={'profile_name': profile_name})
76
+ if 'Item' not in response:
77
+ return {
78
+ 'statusCode': 404,
79
+ 'headers': {'Content-Type': 'application/json'},
80
+ 'body': json.dumps({'error': 'Profile not found'})
81
+ }
82
+
83
+ return {
84
+ 'statusCode': 200,
85
+ 'headers': {'Content-Type': 'application/json'},
86
+ 'body': json.dumps({'profile': response['Item']})
87
+ }
88
+
89
+ elif operation == 'create' or operation == 'update':
90
+ # Create or update profile
91
+ if not profile_name:
92
+ return {
93
+ 'statusCode': 400,
94
+ 'headers': {'Content-Type': 'application/json'},
95
+ 'body': json.dumps({'error': 'profile_name required'})
96
+ }
97
+
98
+ accounts = body.get('accounts', [])
99
+ description = body.get('description', '')
100
+
101
+ item = {
102
+ 'profile_name': profile_name,
103
+ 'accounts': accounts,
104
+ 'description': description,
105
+ 'updated_at': datetime.utcnow().isoformat()
106
+ }
107
+
108
+ if operation == 'create':
109
+ item['created_at'] = datetime.utcnow().isoformat()
110
+
111
+ table.put_item(Item=item)
112
+
113
+ return {
114
+ 'statusCode': 200,
115
+ 'headers': {'Content-Type': 'application/json'},
116
+ 'body': json.dumps({'message': 'Profile saved', 'profile': item})
117
+ }
118
+
119
+ elif operation == 'delete':
120
+ # Delete profile
121
+ if not profile_name:
122
+ return {
123
+ 'statusCode': 400,
124
+ 'headers': {'Content-Type': 'application/json'},
125
+ 'body': json.dumps({'error': 'profile_name required'})
126
+ }
127
+
128
+ table.delete_item(Key={'profile_name': profile_name})
129
+
130
+ return {
131
+ 'statusCode': 200,
132
+ 'headers': {'Content-Type': 'application/json'},
133
+ 'body': json.dumps({'message': 'Profile deleted'})
134
+ }
135
+
136
+ else:
137
+ return {
138
+ 'statusCode': 400,
139
+ 'headers': {'Content-Type': 'application/json'},
140
+ 'body': json.dumps({'error': 'Invalid operation. Use: list, get, create, update, delete'})
141
+ }
142
+
143
+ except Exception as e:
144
+ return {
145
+ 'statusCode': 500,
146
+ 'headers': {'Content-Type': 'application/json'},
147
+ 'body': json.dumps({'error': str(e)})
148
+ }
@@ -0,0 +1,106 @@
1
+ """
2
+ Lambda handler for trends analysis.
3
+ """
4
+ import json
5
+ import boto3
6
+ import os
7
+ from algorithms.trends import analyze_trends
8
+
9
+ # Get API secret from Secrets Manager
10
+ secrets_client = boto3.client('secretsmanager')
11
+ api_secret_arn = os.environ['API_SECRET_ARN']
12
+ api_secret = secrets_client.get_secret_value(SecretId=api_secret_arn)['SecretString']
13
+
14
+
15
+ def handler(event, context):
16
+ """
17
+ Lambda handler for trends analysis.
18
+
19
+ Expected event:
20
+ {
21
+ "credentials": {
22
+ "access_key": "AKIA...",
23
+ "secret_key": "...",
24
+ "session_token": "..." (optional)
25
+ },
26
+ "accounts": ["123456789012", "987654321098"],
27
+ "weeks": 4
28
+ }
29
+ """
30
+ # Handle OPTIONS for CORS
31
+ if event.get('requestContext', {}).get('http', {}).get('method') == 'OPTIONS':
32
+ return {
33
+ 'statusCode': 200,
34
+ 'headers': {
35
+ 'Access-Control-Allow-Origin': '*',
36
+ 'Access-Control-Allow-Headers': '*',
37
+ 'Access-Control-Allow-Methods': 'POST, OPTIONS'
38
+ },
39
+ 'body': ''
40
+ }
41
+
42
+ try:
43
+ # Validate API secret
44
+ headers = event.get('headers', {})
45
+ provided_secret = headers.get('x-api-secret') or headers.get('X-API-Secret')
46
+
47
+ if provided_secret != api_secret:
48
+ return {
49
+ 'statusCode': 401,
50
+ 'headers': {'Access-Control-Allow-Origin': '*'},
51
+ 'body': json.dumps({'error': 'Unauthorized'})
52
+ }
53
+
54
+ # Parse request body
55
+ body = json.loads(event.get('body', '{}'))
56
+
57
+ credentials = body.get('credentials', {})
58
+ accounts = body.get('accounts', [])
59
+ weeks = body.get('weeks', 3)
60
+
61
+ if not credentials or not accounts:
62
+ return {
63
+ 'statusCode': 400,
64
+ 'headers': {'Access-Control-Allow-Origin': '*'},
65
+ 'body': json.dumps({'error': 'Missing credentials or accounts'})
66
+ }
67
+
68
+ # Create Cost Explorer client with provided credentials
69
+ ce_client = boto3.client(
70
+ 'ce',
71
+ region_name='us-east-1',
72
+ aws_access_key_id=credentials['access_key'],
73
+ aws_secret_access_key=credentials['secret_key'],
74
+ aws_session_token=credentials.get('session_token')
75
+ )
76
+
77
+ # Run analysis
78
+ trends_data = analyze_trends(ce_client, accounts, weeks)
79
+
80
+ # Convert datetime objects to strings for JSON serialization
81
+ def convert_dates(obj):
82
+ if isinstance(obj, dict):
83
+ return {k: convert_dates(v) for k, v in obj.items()}
84
+ elif isinstance(obj, list):
85
+ return [convert_dates(item) for item in obj]
86
+ elif hasattr(obj, 'isoformat'):
87
+ return obj.isoformat()
88
+ return obj
89
+
90
+ trends_data = convert_dates(trends_data)
91
+
92
+ return {
93
+ 'statusCode': 200,
94
+ 'headers': {
95
+ 'Access-Control-Allow-Origin': '*',
96
+ 'Content-Type': 'application/json'
97
+ },
98
+ 'body': json.dumps(trends_data)
99
+ }
100
+
101
+ except Exception as e:
102
+ return {
103
+ 'statusCode': 500,
104
+ 'headers': {'Access-Control-Allow-Origin': '*'},
105
+ 'body': json.dumps({'error': str(e)})
106
+ }
cost_calculator/cli.py CHANGED
@@ -11,6 +11,8 @@ Usage:
11
11
  import click
12
12
  import boto3
13
13
  import json
14
+ import os
15
+ import platform
14
16
  from datetime import datetime, timedelta
15
17
  from pathlib import Path
16
18
  from cost_calculator.trends import format_trends_markdown
@@ -19,49 +21,143 @@ from cost_calculator.drill import format_drill_down_markdown
19
21
  from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
20
22
 
21
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
68
+
69
+
22
70
  def load_profile(profile_name):
23
- """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
+
24
75
  config_dir = Path.home() / '.config' / 'cost-calculator'
25
76
  config_file = config_dir / 'profiles.json'
26
77
  creds_file = config_dir / 'credentials.json'
27
78
 
28
- 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:
29
120
  raise click.ClickException(
30
- f"Profile configuration not found at {config_file}\n"
121
+ f"Profile '{profile_name}' not found locally and COST_API_SECRET not set.\n"
31
122
  f"Run: cc init --profile {profile_name}"
32
123
  )
33
124
 
34
- with open(config_file) as f:
35
- profiles = json.load(f)
36
-
37
- if profile_name not in profiles:
38
- raise click.ClickException(
39
- f"Profile '{profile_name}' not found in {config_file}\n"
40
- 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
41
131
  )
42
-
43
- profile = profiles[profile_name]
44
-
45
- # Load credentials if using static credentials (not SSO)
46
- if 'aws_profile' not in profile:
47
- if not creds_file.exists():
48
- raise click.ClickException(
49
- f"No credentials found for profile '{profile_name}'.\n"
50
- f"Run: cc configure --profile {profile_name}"
51
- )
52
132
 
53
- with open(creds_file) as f:
54
- creds = json.load(f)
55
-
56
- 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:
57
152
  raise click.ClickException(
58
- f"No credentials found for profile '{profile_name}'.\n"
59
- 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 \"...\""
60
155
  )
61
-
62
- profile['credentials'] = creds[profile_name]
63
-
64
- 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
+ )
65
161
 
66
162
 
67
163
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -231,7 +327,7 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
231
327
  support_month = support_month_date - timedelta(days=1) # Go back to previous month
232
328
  days_in_support_month = support_month.day # This gives us the last day of the month
233
329
 
234
- # Support allocation: divide by 2 (half to Khoros), then by days in month
330
+ # Support allocation: divide by 2 (50% allocation), then by days in month
235
331
  support_per_day = (support_cost / 2) / days_in_support_month
236
332
 
237
333
  # Calculate daily rate
@@ -290,18 +386,120 @@ def cli():
290
386
  pass
291
387
 
292
388
 
389
+ @cli.command('setup-api')
390
+ @click.option('--api-secret', required=True, prompt=True, hide_input=True, help='COST_API_SECRET value')
391
+ def setup_api(api_secret):
392
+ """
393
+ Configure COST_API_SECRET for backend API access
394
+
395
+ Saves the API secret to the appropriate location based on your OS:
396
+ - Mac/Linux: ~/.zshrc or ~/.bashrc
397
+ - Windows: User environment variables
398
+
399
+ Example:
400
+ cc setup-api --api-secret your-secret-here
401
+
402
+ Or let it prompt you (input will be hidden):
403
+ cc setup-api
404
+ """
405
+ system = platform.system()
406
+
407
+ if system == "Windows":
408
+ # Windows: Set user environment variable
409
+ try:
410
+ import winreg
411
+ key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_SET_VALUE)
412
+ winreg.SetValueEx(key, 'COST_API_SECRET', 0, winreg.REG_SZ, api_secret)
413
+ winreg.CloseKey(key)
414
+ click.echo("✓ COST_API_SECRET saved to Windows user environment variables")
415
+ click.echo(" Please restart your terminal for changes to take effect")
416
+ except Exception as e:
417
+ click.echo(f"✗ Error setting Windows environment variable: {e}", err=True)
418
+ click.echo("\nManual setup:")
419
+ click.echo("1. Open System Properties > Environment Variables")
420
+ click.echo("2. Add new User variable:")
421
+ click.echo(" Name: COST_API_SECRET")
422
+ click.echo(f" Value: {api_secret}")
423
+ return
424
+ else:
425
+ # Mac/Linux: Add to shell profile
426
+ shell = os.environ.get('SHELL', '/bin/bash')
427
+
428
+ if 'zsh' in shell:
429
+ profile_file = Path.home() / '.zshrc'
430
+ else:
431
+ profile_file = Path.home() / '.bashrc'
432
+
433
+ # Check if already exists
434
+ export_line = f'export COST_API_SECRET="{api_secret}"'
435
+
436
+ try:
437
+ if profile_file.exists():
438
+ content = profile_file.read_text()
439
+ if 'COST_API_SECRET' in content:
440
+ # Replace existing
441
+ lines = content.split('\n')
442
+ new_lines = []
443
+ for line in lines:
444
+ if 'COST_API_SECRET' in line and line.strip().startswith('export'):
445
+ new_lines.append(export_line)
446
+ else:
447
+ new_lines.append(line)
448
+ profile_file.write_text('\n'.join(new_lines))
449
+ click.echo(f"✓ Updated COST_API_SECRET in {profile_file}")
450
+ else:
451
+ # Append
452
+ with profile_file.open('a') as f:
453
+ f.write(f'\n# AWS Cost Calculator API Secret\n{export_line}\n')
454
+ click.echo(f"✓ Added COST_API_SECRET to {profile_file}")
455
+ else:
456
+ # Create new file
457
+ profile_file.write_text(f'# AWS Cost Calculator API Secret\n{export_line}\n')
458
+ click.echo(f"✓ Created {profile_file} with COST_API_SECRET")
459
+
460
+ # Also set for current session
461
+ os.environ['COST_API_SECRET'] = api_secret
462
+ click.echo(f"✓ Set COST_API_SECRET for current session")
463
+ click.echo(f"\nTo use in new terminals, run: source {profile_file}")
464
+
465
+ except Exception as e:
466
+ click.echo(f"✗ Error writing to {profile_file}: {e}", err=True)
467
+ click.echo(f"\nManual setup: Add this line to {profile_file}:")
468
+ click.echo(f" {export_line}")
469
+ return
470
+
471
+
293
472
  @cli.command()
294
473
  @click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
295
474
  @click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
296
475
  @click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
297
476
  @click.option('--window', default=30, help='Number of days to analyze (default: 30)')
298
477
  @click.option('--json-output', is_flag=True, help='Output as JSON')
299
- def calculate(profile, start_date, offset, window, json_output):
300
- """Calculate AWS costs for the specified period"""
478
+ @click.option('--sso', help='AWS SSO profile name (e.g., my_sso_profile)')
479
+ @click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
480
+ @click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
481
+ @click.option('--session-token', help='AWS Session Token (for static credentials)')
482
+ def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
483
+ """
484
+ Calculate AWS costs for the specified period
485
+
486
+ \b
487
+ Authentication Options:
488
+ 1. SSO: --sso <profile_name>
489
+ Example: cc calculate --profile myprofile --sso my_sso_profile
490
+
491
+ 2. Static Credentials: --access-key-id, --secret-access-key, --session-token
492
+ Example: cc calculate --profile myprofile --access-key-id ASIA... --secret-access-key ... --session-token ...
493
+
494
+ 3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
495
+ """
301
496
 
302
497
  # Load profile configuration
303
498
  config = load_profile(profile)
304
499
 
500
+ # Apply authentication options
501
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
502
+
305
503
  # Calculate costs
306
504
  result = calculate_costs(
307
505
  profile_config=config,
@@ -547,12 +745,17 @@ def configure(profile, access_key_id, secret_access_key, session_token, region):
547
745
  @click.option('--profile', required=True, help='Profile name')
548
746
  @click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
549
747
  @click.option('--output', default='cost_trends.md', help='Output markdown file (default: cost_trends.md)')
550
- @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
551
- def trends(profile, weeks, output, json_output):
748
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
749
+ @click.option('--sso', help='AWS SSO profile name')
750
+ @click.option('--access-key-id', help='AWS Access Key ID')
751
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
752
+ @click.option('--session-token', help='AWS Session Token')
753
+ def trends(profile, weeks, output, json_output, sso, access_key_id, secret_access_key, session_token):
552
754
  """Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
553
755
 
554
756
  # Load profile configuration
555
757
  config = load_profile(profile)
758
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
556
759
 
557
760
  click.echo(f"Analyzing last {weeks} weeks...")
558
761
  click.echo("")
@@ -613,12 +816,17 @@ def trends(profile, weeks, output, json_output):
613
816
  @click.option('--profile', required=True, help='Profile name')
614
817
  @click.option('--months', default=6, help='Number of months to analyze (default: 6)')
615
818
  @click.option('--output', default='monthly_trends.md', help='Output markdown file (default: monthly_trends.md)')
616
- @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
617
- def monthly(profile, months, output, json_output):
819
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
820
+ @click.option('--sso', help='AWS SSO profile name')
821
+ @click.option('--access-key-id', help='AWS Access Key ID')
822
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
823
+ @click.option('--session-token', help='AWS Session Token')
824
+ def monthly(profile, months, output, json_output, sso, access_key_id, secret_access_key, session_token):
618
825
  """Analyze month-over-month cost trends at service level"""
619
826
 
620
- # Load profile configuration
827
+ # Load profile
621
828
  config = load_profile(profile)
829
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
622
830
 
623
831
  click.echo(f"Analyzing last {months} months...")
624
832
  click.echo("")
@@ -680,12 +888,17 @@ def monthly(profile, months, output, json_output):
680
888
  @click.option('--account', help='Filter by account ID')
681
889
  @click.option('--usage-type', help='Filter by usage type')
682
890
  @click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
683
- @click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
684
- def drill(profile, weeks, service, account, usage_type, output, json_output):
891
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
892
+ @click.option('--sso', help='AWS SSO profile name')
893
+ @click.option('--access-key-id', help='AWS Access Key ID')
894
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
895
+ @click.option('--session-token', help='AWS Session Token')
896
+ def drill(profile, weeks, service, account, usage_type, output, json_output, sso, access_key_id, secret_access_key, session_token):
685
897
  """Drill down into cost changes by service, account, or usage type"""
686
898
 
687
- # Load profile configuration
899
+ # Load profile
688
900
  config = load_profile(profile)
901
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
689
902
 
690
903
  # Show filters
691
904
  click.echo(f"Analyzing last {weeks} weeks...")
@@ -11,22 +11,33 @@ def get_credentials_dict(config):
11
11
  Extract credentials from config in format needed for API.
12
12
 
13
13
  Returns:
14
- dict with access_key, secret_key, session_token
14
+ dict with access_key, secret_key, session_token, or None if profile is 'dummy'
15
15
  """
16
16
  if 'aws_profile' in config:
17
- # Get temporary credentials from SSO session
18
- session = boto3.Session(profile_name=config['aws_profile'])
19
- credentials = session.get_credentials()
20
- frozen_creds = credentials.get_frozen_credentials()
17
+ # Skip credential loading for dummy profile (API-only mode)
18
+ if config['aws_profile'] == 'dummy':
19
+ return None
21
20
 
22
- return {
23
- 'access_key': frozen_creds.access_key,
24
- 'secret_key': frozen_creds.secret_key,
25
- 'session_token': frozen_creds.token
26
- }
21
+ # Get temporary credentials from SSO session
22
+ try:
23
+ session = boto3.Session(profile_name=config['aws_profile'])
24
+ credentials = session.get_credentials()
25
+ frozen_creds = credentials.get_frozen_credentials()
26
+
27
+ return {
28
+ 'access_key': frozen_creds.access_key,
29
+ 'secret_key': frozen_creds.secret_key,
30
+ 'session_token': frozen_creds.token
31
+ }
32
+ except Exception:
33
+ # If profile not found, return None (API will handle)
34
+ return None
27
35
  else:
28
36
  # Use static credentials
29
- creds = config['credentials']
37
+ creds = config.get('credentials', {})
38
+ if not creds:
39
+ return None
40
+
30
41
  result = {
31
42
  'access_key': creds['aws_access_key_id'],
32
43
  'secret_key': creds['aws_secret_access_key']