aws-cost-calculator-cli 1.5.2__py3-none-any.whl → 1.6.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.
@@ -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
@@ -19,49 +19,143 @@ from cost_calculator.drill import format_drill_down_markdown
19
19
  from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
20
20
 
21
21
 
22
+ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=None, session_token=None):
23
+ """Apply authentication options to profile config
24
+
25
+ Args:
26
+ config: Profile configuration dict
27
+ sso: AWS SSO profile name
28
+ access_key_id: AWS Access Key ID
29
+ secret_access_key: AWS Secret Access Key
30
+ session_token: AWS Session Token
31
+
32
+ Returns:
33
+ Updated config dict
34
+ """
35
+ import subprocess
36
+
37
+ if sso:
38
+ # SSO authentication - trigger login if needed
39
+ try:
40
+ # Test if SSO session is valid
41
+ result = subprocess.run(
42
+ ['aws', 'sts', 'get-caller-identity', '--profile', sso],
43
+ capture_output=True,
44
+ text=True,
45
+ timeout=5
46
+ )
47
+ if result.returncode != 0:
48
+ if 'expired' in result.stderr.lower() or 'token' in result.stderr.lower():
49
+ click.echo(f"SSO session expired or not initialized. Logging in...")
50
+ subprocess.run(['aws', 'sso', 'login', '--profile', sso], check=True)
51
+ except Exception as e:
52
+ click.echo(f"Warning: Could not verify SSO session: {e}")
53
+
54
+ config['aws_profile'] = sso
55
+ elif access_key_id and secret_access_key:
56
+ # Static credentials provided via CLI
57
+ config['credentials'] = {
58
+ 'aws_access_key_id': access_key_id,
59
+ 'aws_secret_access_key': secret_access_key,
60
+ 'region': 'us-east-1'
61
+ }
62
+ if session_token:
63
+ config['credentials']['aws_session_token'] = session_token
64
+
65
+ return config
66
+
67
+
22
68
  def load_profile(profile_name):
23
- """Load profile configuration from ~/.config/cost-calculator/profiles.json"""
69
+ """Load profile configuration from local file or DynamoDB API"""
70
+ import os
71
+ import requests
72
+
24
73
  config_dir = Path.home() / '.config' / 'cost-calculator'
25
74
  config_file = config_dir / 'profiles.json'
26
75
  creds_file = config_dir / 'credentials.json'
27
76
 
28
- if not config_file.exists():
77
+ # Try local file first
78
+ if config_file.exists():
79
+ with open(config_file) as f:
80
+ profiles = json.load(f)
81
+
82
+ if profile_name in profiles:
83
+ profile = profiles[profile_name]
84
+
85
+ # Load credentials if using static credentials (not SSO)
86
+ if 'aws_profile' not in profile:
87
+ if not creds_file.exists():
88
+ # Try environment variables
89
+ if os.environ.get('AWS_ACCESS_KEY_ID'):
90
+ profile['credentials'] = {
91
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
92
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
93
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
94
+ }
95
+ return profile
96
+
97
+ raise click.ClickException(
98
+ f"No credentials found for profile '{profile_name}'.\n"
99
+ f"Run: cc configure --profile {profile_name}"
100
+ )
101
+
102
+ with open(creds_file) as f:
103
+ creds = json.load(f)
104
+
105
+ if profile_name not in creds:
106
+ raise click.ClickException(
107
+ f"No credentials found for profile '{profile_name}'.\n"
108
+ f"Run: cc configure --profile {profile_name}"
109
+ )
110
+
111
+ profile['credentials'] = creds[profile_name]
112
+
113
+ return profile
114
+
115
+ # Profile not found locally - try DynamoDB API
116
+ api_secret = os.environ.get('COST_API_SECRET')
117
+ if not api_secret:
29
118
  raise click.ClickException(
30
- f"Profile configuration not found at {config_file}\n"
119
+ f"Profile '{profile_name}' not found locally and COST_API_SECRET not set.\n"
31
120
  f"Run: cc init --profile {profile_name}"
32
121
  )
33
122
 
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())}"
123
+ try:
124
+ response = requests.post(
125
+ 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
126
+ headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
127
+ json={'operation': 'get', 'profile_name': profile_name},
128
+ timeout=10
41
129
  )
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
-
53
- with open(creds_file) as f:
54
- creds = json.load(f)
55
130
 
56
- if profile_name not in creds:
131
+ if response.status_code == 200:
132
+ response_data = response.json()
133
+ # API returns {"profile": {...}} wrapper
134
+ profile_data = response_data.get('profile', response_data)
135
+ profile = {'accounts': profile_data['accounts']}
136
+
137
+ # Check for AWS_PROFILE environment variable (SSO support)
138
+ if os.environ.get('AWS_PROFILE'):
139
+ profile['aws_profile'] = os.environ['AWS_PROFILE']
140
+ # Use environment credentials
141
+ elif os.environ.get('AWS_ACCESS_KEY_ID'):
142
+ profile['credentials'] = {
143
+ 'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
144
+ 'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
145
+ 'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
146
+ }
147
+
148
+ return profile
149
+ else:
57
150
  raise click.ClickException(
58
- f"No credentials found for profile '{profile_name}'.\n"
59
- f"Run: cc configure --profile {profile_name}"
151
+ f"Profile '{profile_name}' not found in DynamoDB.\n"
152
+ f"Run: cc profile create --name {profile_name} --accounts \"...\""
60
153
  )
61
-
62
- profile['credentials'] = creds[profile_name]
63
-
64
- return profile
154
+ except requests.exceptions.RequestException as e:
155
+ raise click.ClickException(
156
+ f"Failed to fetch profile from API: {e}\n"
157
+ f"Run: cc init --profile {profile_name}"
158
+ )
65
159
 
66
160
 
67
161
  def calculate_costs(profile_config, accounts, start_date, offset, window):
@@ -231,7 +325,7 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
231
325
  support_month = support_month_date - timedelta(days=1) # Go back to previous month
232
326
  days_in_support_month = support_month.day # This gives us the last day of the month
233
327
 
234
- # Support allocation: divide by 2 (half to Khoros), then by days in month
328
+ # Support allocation: divide by 2 (50% allocation), then by days in month
235
329
  support_per_day = (support_cost / 2) / days_in_support_month
236
330
 
237
331
  # Calculate daily rate
@@ -296,12 +390,31 @@ def cli():
296
390
  @click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
297
391
  @click.option('--window', default=30, help='Number of days to analyze (default: 30)')
298
392
  @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"""
393
+ @click.option('--sso', help='AWS SSO profile name (e.g., my_sso_profile)')
394
+ @click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
395
+ @click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
396
+ @click.option('--session-token', help='AWS Session Token (for static credentials)')
397
+ def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
398
+ """
399
+ Calculate AWS costs for the specified period
400
+
401
+ \b
402
+ Authentication Options:
403
+ 1. SSO: --sso <profile_name>
404
+ Example: cc calculate --profile myprofile --sso my_sso_profile
405
+
406
+ 2. Static Credentials: --access-key-id, --secret-access-key, --session-token
407
+ Example: cc calculate --profile myprofile --access-key-id ASIA... --secret-access-key ... --session-token ...
408
+
409
+ 3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
410
+ """
301
411
 
302
412
  # Load profile configuration
303
413
  config = load_profile(profile)
304
414
 
415
+ # Apply authentication options
416
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
417
+
305
418
  # Calculate costs
306
419
  result = calculate_costs(
307
420
  profile_config=config,
@@ -547,12 +660,17 @@ def configure(profile, access_key_id, secret_access_key, session_token, region):
547
660
  @click.option('--profile', required=True, help='Profile name')
548
661
  @click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
549
662
  @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):
663
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
664
+ @click.option('--sso', help='AWS SSO profile name')
665
+ @click.option('--access-key-id', help='AWS Access Key ID')
666
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
667
+ @click.option('--session-token', help='AWS Session Token')
668
+ def trends(profile, weeks, output, json_output, sso, access_key_id, secret_access_key, session_token):
552
669
  """Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
553
670
 
554
671
  # Load profile configuration
555
672
  config = load_profile(profile)
673
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
556
674
 
557
675
  click.echo(f"Analyzing last {weeks} weeks...")
558
676
  click.echo("")
@@ -613,12 +731,17 @@ def trends(profile, weeks, output, json_output):
613
731
  @click.option('--profile', required=True, help='Profile name')
614
732
  @click.option('--months', default=6, help='Number of months to analyze (default: 6)')
615
733
  @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):
734
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
735
+ @click.option('--sso', help='AWS SSO profile name')
736
+ @click.option('--access-key-id', help='AWS Access Key ID')
737
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
738
+ @click.option('--session-token', help='AWS Session Token')
739
+ def monthly(profile, months, output, json_output, sso, access_key_id, secret_access_key, session_token):
618
740
  """Analyze month-over-month cost trends at service level"""
619
741
 
620
- # Load profile configuration
742
+ # Load profile
621
743
  config = load_profile(profile)
744
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
622
745
 
623
746
  click.echo(f"Analyzing last {months} months...")
624
747
  click.echo("")
@@ -680,12 +803,17 @@ def monthly(profile, months, output, json_output):
680
803
  @click.option('--account', help='Filter by account ID')
681
804
  @click.option('--usage-type', help='Filter by usage type')
682
805
  @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):
806
+ @click.option('--json-output', is_flag=True, help='Output as JSON')
807
+ @click.option('--sso', help='AWS SSO profile name')
808
+ @click.option('--access-key-id', help='AWS Access Key ID')
809
+ @click.option('--secret-access-key', help='AWS Secret Access Key')
810
+ @click.option('--session-token', help='AWS Session Token')
811
+ def drill(profile, weeks, service, account, usage_type, output, json_output, sso, access_key_id, secret_access_key, session_token):
685
812
  """Drill down into cost changes by service, account, or usage type"""
686
813
 
687
- # Load profile configuration
814
+ # Load profile
688
815
  config = load_profile(profile)
816
+ config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
689
817
 
690
818
  # Show filters
691
819
  click.echo(f"Analyzing last {weeks} weeks...")
@@ -1,13 +0,0 @@
1
- aws_cost_calculator_cli-1.5.2.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
2
- cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
3
- cost_calculator/api_client.py,sha256=LUzQmveDF0X9MqAyThp9mbSzJzkOO73Pk4F7IEJjASU,2353
4
- cost_calculator/cli.py,sha256=ufK28divdvrceEryWd8cCWjvG5pT2owaqprskX2epeQ,32589
5
- cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
6
- cost_calculator/executor.py,sha256=tVyyBtXIj9OPyG-xQj8CUmyFjDhb9IVK639360dUZDc,8076
7
- cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
8
- cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
9
- aws_cost_calculator_cli-1.5.2.dist-info/METADATA,sha256=0wxy-jgVC-paubGHN87mDObETGr_u9qU4ZIP3xV49hM,8176
10
- aws_cost_calculator_cli-1.5.2.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
11
- aws_cost_calculator_cli-1.5.2.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
12
- aws_cost_calculator_cli-1.5.2.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
13
- aws_cost_calculator_cli-1.5.2.dist-info/RECORD,,