aws-cost-calculator-cli 1.4.0__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.
- {aws_cost_calculator_cli-1.4.0.dist-info → aws_cost_calculator_cli-1.6.2.dist-info}/METADATA +161 -32
- aws_cost_calculator_cli-1.6.2.dist-info/RECORD +25 -0
- {aws_cost_calculator_cli-1.4.0.dist-info → aws_cost_calculator_cli-1.6.2.dist-info}/WHEEL +1 -1
- {aws_cost_calculator_cli-1.4.0.dist-info → aws_cost_calculator_cli-1.6.2.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/api_client.py +13 -34
- cost_calculator/cli.py +283 -41
- cost_calculator/executor.py +93 -11
- aws_cost_calculator_cli-1.4.0.dist-info/RECORD +0 -13
- {aws_cost_calculator_cli-1.4.0.dist-info → aws_cost_calculator_cli-1.6.2.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.4.0.dist-info → aws_cost_calculator_cli-1.6.2.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/api_client.py
CHANGED
|
@@ -10,33 +10,15 @@ from pathlib import Path
|
|
|
10
10
|
|
|
11
11
|
def get_api_config():
|
|
12
12
|
"""
|
|
13
|
-
Get API configuration
|
|
13
|
+
Get API configuration.
|
|
14
14
|
|
|
15
15
|
Returns:
|
|
16
|
-
dict
|
|
16
|
+
dict: API configuration with api_secret, or None if not configured
|
|
17
17
|
"""
|
|
18
|
-
|
|
19
|
-
base_url = os.environ.get('COST_API_URL')
|
|
20
|
-
api_secret = os.environ.get('COST_API_SECRET')
|
|
18
|
+
api_secret = os.environ.get('COST_API_SECRET', '')
|
|
21
19
|
|
|
22
|
-
if
|
|
23
|
-
return {
|
|
24
|
-
'base_url': base_url.rstrip('/'),
|
|
25
|
-
'api_secret': api_secret
|
|
26
|
-
}
|
|
27
|
-
|
|
28
|
-
# Try config file
|
|
29
|
-
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
30
|
-
api_config_file = config_dir / 'api_config.json'
|
|
31
|
-
|
|
32
|
-
if api_config_file.exists():
|
|
33
|
-
with open(api_config_file, 'r') as f:
|
|
34
|
-
config = json.load(f)
|
|
35
|
-
if 'base_url' in config and 'api_secret' in config:
|
|
36
|
-
return {
|
|
37
|
-
'base_url': config['base_url'].rstrip('/'),
|
|
38
|
-
'api_secret': config['api_secret']
|
|
39
|
-
}
|
|
20
|
+
if api_secret:
|
|
21
|
+
return {'api_secret': api_secret}
|
|
40
22
|
|
|
41
23
|
return None
|
|
42
24
|
|
|
@@ -60,21 +42,18 @@ def call_lambda_api(endpoint, credentials, accounts, **kwargs):
|
|
|
60
42
|
api_config = get_api_config()
|
|
61
43
|
|
|
62
44
|
if not api_config:
|
|
63
|
-
raise Exception("API not configured. Set
|
|
45
|
+
raise Exception("API not configured. Set COST_API_SECRET environment variable.")
|
|
64
46
|
|
|
65
|
-
# Map endpoint names to URLs
|
|
47
|
+
# Map endpoint names to Lambda URLs
|
|
66
48
|
endpoint_urls = {
|
|
67
|
-
'trends':
|
|
68
|
-
'monthly':
|
|
69
|
-
'drill':
|
|
49
|
+
'trends': 'https://pq3mqntc6vuwi4zw5flulsoleq0yiqtl.lambda-url.us-east-1.on.aws/',
|
|
50
|
+
'monthly': 'https://6aueebodw6q4zdeu3aaexb6tle0fqhhr.lambda-url.us-east-1.on.aws/',
|
|
51
|
+
'drill': 'https://3ncm2gzxrsyptrhud3ua3x5lju0akvsr.lambda-url.us-east-1.on.aws/',
|
|
52
|
+
'analyze': 'https://y6npmidtxwzg62nrqzkbacfs5q0edwgs.lambda-url.us-east-1.on.aws/',
|
|
53
|
+
'profiles': 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
|
|
70
54
|
}
|
|
71
55
|
|
|
72
|
-
|
|
73
|
-
if '/trends' not in api_config['base_url']:
|
|
74
|
-
# Base URL is the function URL itself
|
|
75
|
-
url = api_config['base_url']
|
|
76
|
-
else:
|
|
77
|
-
url = endpoint_urls.get(endpoint)
|
|
56
|
+
url = endpoint_urls.get(endpoint)
|
|
78
57
|
|
|
79
58
|
if not url:
|
|
80
59
|
raise Exception(f"Unknown endpoint: {endpoint}")
|