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.
- {aws_cost_calculator_cli-1.5.1.dist-info → aws_cost_calculator_cli-1.6.3.dist-info}/METADATA +158 -24
- aws_cost_calculator_cli-1.6.3.dist-info/RECORD +25 -0
- {aws_cost_calculator_cli-1.5.1.dist-info → aws_cost_calculator_cli-1.6.3.dist-info}/WHEEL +1 -1
- {aws_cost_calculator_cli-1.5.1.dist-info → aws_cost_calculator_cli-1.6.3.dist-info}/top_level.txt +1 -0
- backend/__init__.py +1 -0
- backend/algorithms/__init__.py +1 -0
- backend/algorithms/analyze.py +272 -0
- backend/algorithms/drill.py +323 -0
- backend/algorithms/monthly.py +242 -0
- backend/algorithms/trends.py +353 -0
- backend/handlers/__init__.py +1 -0
- backend/handlers/analyze.py +112 -0
- backend/handlers/drill.py +117 -0
- backend/handlers/monthly.py +106 -0
- backend/handlers/profiles.py +148 -0
- backend/handlers/trends.py +106 -0
- cost_calculator/cli.py +254 -41
- cost_calculator/executor.py +22 -11
- aws_cost_calculator_cli-1.5.1.dist-info/RECORD +0 -13
- {aws_cost_calculator_cli-1.5.1.dist-info → aws_cost_calculator_cli-1.6.3.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.5.1.dist-info → aws_cost_calculator_cli-1.6.3.dist-info}/licenses/LICENSE +0 -0
|
@@ -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
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
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
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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"
|
|
59
|
-
f"Run: cc
|
|
153
|
+
f"Profile '{profile_name}' not found in DynamoDB.\n"
|
|
154
|
+
f"Run: cc profile create --name {profile_name} --accounts \"...\""
|
|
60
155
|
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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 (
|
|
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
|
-
|
|
300
|
-
|
|
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
|
|
551
|
-
|
|
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
|
|
617
|
-
|
|
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
|
|
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
|
|
684
|
-
|
|
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
|
|
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...")
|
cost_calculator/executor.py
CHANGED
|
@@ -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
|
-
#
|
|
18
|
-
|
|
19
|
-
|
|
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
|
-
|
|
23
|
-
|
|
24
|
-
'
|
|
25
|
-
|
|
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
|
|
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']
|