aws-cost-calculator-cli 1.11.1__tar.gz → 2.1.0__tar.gz
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.11.1/aws_cost_calculator_cli.egg-info → aws_cost_calculator_cli-2.1.0}/PKG-INFO +1 -1
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0/aws_cost_calculator_cli.egg-info}/PKG-INFO +1 -1
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/cli.py +488 -177
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/executor.py +5 -2
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/setup.py +1 -1
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/CHANGES.md +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/LICENSE +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/MANIFEST.in +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/README.md +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/SOURCES.txt +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/dependency_links.txt +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/requires.txt +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/aws_cost_calculator_cli.egg-info/top_level.txt +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/__init__.py +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/api_client.py +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/cur.py +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/drill.py +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/forensics.py +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/monthly.py +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/trends.py +0 -0
- {aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aws-cost-calculator-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
|
|
5
5
|
Home-page: https://github.com/trilogy-group/aws-cost-calculator
|
|
6
6
|
Author: Cost Optimization Team
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aws-cost-calculator-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 2.1.0
|
|
4
4
|
Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
|
|
5
5
|
Home-page: https://github.com/trilogy-group/aws-cost-calculator
|
|
6
6
|
Author: Cost Optimization Team
|
|
@@ -18,6 +18,12 @@ from pathlib import Path
|
|
|
18
18
|
from cost_calculator.trends import format_trends_markdown
|
|
19
19
|
from cost_calculator.monthly import format_monthly_markdown
|
|
20
20
|
from cost_calculator.drill import format_drill_down_markdown
|
|
21
|
+
|
|
22
|
+
# API Configuration - can be overridden via environment variable
|
|
23
|
+
PROFILES_API_URL = os.environ.get(
|
|
24
|
+
'COST_CALCULATOR_PROFILES_URL',
|
|
25
|
+
'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
|
|
26
|
+
)
|
|
21
27
|
from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
|
|
22
28
|
|
|
23
29
|
|
|
@@ -67,127 +73,197 @@ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=N
|
|
|
67
73
|
return config
|
|
68
74
|
|
|
69
75
|
|
|
70
|
-
def
|
|
71
|
-
"""
|
|
76
|
+
def get_api_secret():
|
|
77
|
+
"""Get API secret from config file or environment variable"""
|
|
72
78
|
import os
|
|
73
|
-
import requests
|
|
74
79
|
|
|
75
|
-
|
|
76
|
-
config_file = config_dir / 'profiles.json'
|
|
77
|
-
creds_file = config_dir / 'credentials.json'
|
|
78
|
-
|
|
79
|
-
# Try DynamoDB API first if COST_API_SECRET is set
|
|
80
|
+
# Check environment variable first
|
|
80
81
|
api_secret = os.environ.get('COST_API_SECRET')
|
|
81
82
|
if api_secret:
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
timeout=10
|
|
88
|
-
)
|
|
89
|
-
|
|
90
|
-
if response.status_code == 200:
|
|
91
|
-
response_data = response.json()
|
|
92
|
-
# API returns {"profile": {...}} wrapper
|
|
93
|
-
profile_data = response_data.get('profile', response_data)
|
|
94
|
-
profile = {'accounts': profile_data['accounts']}
|
|
95
|
-
|
|
96
|
-
# If profile has aws_profile field, use it
|
|
97
|
-
if 'aws_profile' in profile_data:
|
|
98
|
-
profile['aws_profile'] = profile_data['aws_profile']
|
|
99
|
-
# Check for AWS_PROFILE environment variable (SSO support)
|
|
100
|
-
elif os.environ.get('AWS_PROFILE'):
|
|
101
|
-
profile['aws_profile'] = os.environ['AWS_PROFILE']
|
|
102
|
-
# Use environment credentials
|
|
103
|
-
elif os.environ.get('AWS_ACCESS_KEY_ID'):
|
|
104
|
-
profile['credentials'] = {
|
|
105
|
-
'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
|
|
106
|
-
'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
107
|
-
'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
|
|
108
|
-
}
|
|
109
|
-
else:
|
|
110
|
-
# Try to find a matching AWS profile by name
|
|
111
|
-
# This allows "khoros" profile to work with "khoros_umbrella" AWS profile
|
|
112
|
-
import subprocess
|
|
113
|
-
try:
|
|
114
|
-
result = subprocess.run(
|
|
115
|
-
['aws', 'configure', 'list-profiles'],
|
|
116
|
-
capture_output=True,
|
|
117
|
-
text=True,
|
|
118
|
-
timeout=5
|
|
119
|
-
)
|
|
120
|
-
if result.returncode == 0:
|
|
121
|
-
available_profiles = result.stdout.strip().split('\n')
|
|
122
|
-
# Try exact match first
|
|
123
|
-
if profile_name in available_profiles:
|
|
124
|
-
profile['aws_profile'] = profile_name
|
|
125
|
-
# Try with common suffixes
|
|
126
|
-
elif f"{profile_name}_umbrella" in available_profiles:
|
|
127
|
-
profile['aws_profile'] = f"{profile_name}_umbrella"
|
|
128
|
-
elif f"{profile_name}-umbrella" in available_profiles:
|
|
129
|
-
profile['aws_profile'] = f"{profile_name}-umbrella"
|
|
130
|
-
elif f"{profile_name}_prod" in available_profiles:
|
|
131
|
-
profile['aws_profile'] = f"{profile_name}_prod"
|
|
132
|
-
# If no match found, leave it unset - user must provide --sso
|
|
133
|
-
except:
|
|
134
|
-
# If we can't list profiles, leave it unset - user must provide --sso
|
|
135
|
-
pass
|
|
136
|
-
|
|
137
|
-
return profile
|
|
138
|
-
else:
|
|
139
|
-
raise click.ClickException(
|
|
140
|
-
f"Profile '{profile_name}' not found in DynamoDB.\n"
|
|
141
|
-
f"Run: cc profile create --name {profile_name} --accounts \"...\""
|
|
142
|
-
)
|
|
143
|
-
except requests.exceptions.RequestException as e:
|
|
144
|
-
raise click.ClickException(
|
|
145
|
-
f"Failed to fetch profile from API: {e}\n"
|
|
146
|
-
)
|
|
83
|
+
return api_secret
|
|
84
|
+
|
|
85
|
+
# Check config file
|
|
86
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
87
|
+
config_file = config_dir / 'config.json'
|
|
147
88
|
|
|
148
|
-
# Fallback to local file if no API secret
|
|
149
89
|
if config_file.exists():
|
|
150
90
|
with open(config_file) as f:
|
|
151
|
-
|
|
91
|
+
config = json.load(f)
|
|
92
|
+
return config.get('api_secret')
|
|
93
|
+
|
|
94
|
+
return None
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def get_exclusions(profile_name=None):
|
|
98
|
+
"""Get exclusions configuration from DynamoDB API"""
|
|
99
|
+
import requests
|
|
100
|
+
|
|
101
|
+
api_secret = get_api_secret()
|
|
102
|
+
if not api_secret:
|
|
103
|
+
raise click.ClickException(
|
|
104
|
+
"No API secret configured.\n"
|
|
105
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
try:
|
|
109
|
+
response = requests.post(
|
|
110
|
+
PROFILES_API_URL,
|
|
111
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
112
|
+
json={'operation': 'get_exclusions', 'profile_name': profile_name},
|
|
113
|
+
timeout=10
|
|
114
|
+
)
|
|
115
|
+
|
|
116
|
+
if response.status_code == 200:
|
|
117
|
+
return response.json().get('exclusions', {})
|
|
118
|
+
else:
|
|
119
|
+
# Return defaults if API fails
|
|
120
|
+
return {
|
|
121
|
+
'record_types': ['Tax', 'Support'],
|
|
122
|
+
'services': [],
|
|
123
|
+
'usage_types': [],
|
|
124
|
+
'line_item_types': []
|
|
125
|
+
}
|
|
126
|
+
except:
|
|
127
|
+
# Return defaults if request fails
|
|
128
|
+
return {
|
|
129
|
+
'record_types': ['Tax', 'Support'],
|
|
130
|
+
'services': [],
|
|
131
|
+
'usage_types': [],
|
|
132
|
+
'line_item_types': []
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
def build_exclusion_filter(exclusions):
|
|
137
|
+
"""Build AWS Cost Explorer filter from exclusions config"""
|
|
138
|
+
filter_parts = []
|
|
139
|
+
|
|
140
|
+
# Exclude record types (Tax, Support, etc.)
|
|
141
|
+
if exclusions.get('record_types'):
|
|
142
|
+
filter_parts.append({
|
|
143
|
+
"Not": {
|
|
144
|
+
"Dimensions": {
|
|
145
|
+
"Key": "RECORD_TYPE",
|
|
146
|
+
"Values": exclusions['record_types']
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
})
|
|
150
|
+
|
|
151
|
+
# Exclude specific services (OCBLateFee, etc.)
|
|
152
|
+
if exclusions.get('services'):
|
|
153
|
+
filter_parts.append({
|
|
154
|
+
"Not": {
|
|
155
|
+
"Dimensions": {
|
|
156
|
+
"Key": "SERVICE",
|
|
157
|
+
"Values": exclusions['services']
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
# Exclude usage types
|
|
163
|
+
if exclusions.get('usage_types'):
|
|
164
|
+
filter_parts.append({
|
|
165
|
+
"Not": {
|
|
166
|
+
"Dimensions": {
|
|
167
|
+
"Key": "USAGE_TYPE",
|
|
168
|
+
"Values": exclusions['usage_types']
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
})
|
|
172
|
+
|
|
173
|
+
# Exclude line item types (Refund, Credit, etc.)
|
|
174
|
+
if exclusions.get('line_item_types'):
|
|
175
|
+
filter_parts.append({
|
|
176
|
+
"Not": {
|
|
177
|
+
"Dimensions": {
|
|
178
|
+
"Key": "LINE_ITEM_TYPE",
|
|
179
|
+
"Values": exclusions['line_item_types']
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
})
|
|
183
|
+
|
|
184
|
+
return filter_parts
|
|
185
|
+
|
|
186
|
+
|
|
187
|
+
def load_profile(profile_name):
|
|
188
|
+
"""Load profile configuration from DynamoDB API (API-only, no local files)"""
|
|
189
|
+
import requests
|
|
190
|
+
|
|
191
|
+
# Get API secret
|
|
192
|
+
api_secret = get_api_secret()
|
|
193
|
+
|
|
194
|
+
if not api_secret:
|
|
195
|
+
raise click.ClickException(
|
|
196
|
+
"No API secret configured.\n"
|
|
197
|
+
"Run: cc configure --api-secret YOUR_SECRET\n"
|
|
198
|
+
"Or set environment variable: export COST_API_SECRET=YOUR_SECRET"
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
try:
|
|
202
|
+
response = requests.post(
|
|
203
|
+
PROFILES_API_URL,
|
|
204
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
205
|
+
json={'operation': 'get', 'profile_name': profile_name},
|
|
206
|
+
timeout=10
|
|
207
|
+
)
|
|
152
208
|
|
|
153
|
-
if
|
|
154
|
-
|
|
209
|
+
if response.status_code == 200:
|
|
210
|
+
response_data = response.json()
|
|
211
|
+
# API returns {"profile": {...}} wrapper
|
|
212
|
+
profile_data = response_data.get('profile', response_data)
|
|
213
|
+
profile = {'accounts': profile_data['accounts']}
|
|
155
214
|
|
|
156
|
-
#
|
|
157
|
-
if 'aws_profile'
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
177
|
-
|
|
178
|
-
|
|
179
|
-
f"Run: cc configure --profile {profile_name}"
|
|
215
|
+
# If profile has aws_profile field, use it
|
|
216
|
+
if 'aws_profile' in profile_data:
|
|
217
|
+
profile['aws_profile'] = profile_data['aws_profile']
|
|
218
|
+
# Check for AWS_PROFILE environment variable (SSO support)
|
|
219
|
+
elif os.environ.get('AWS_PROFILE'):
|
|
220
|
+
profile['aws_profile'] = os.environ['AWS_PROFILE']
|
|
221
|
+
# Use environment credentials
|
|
222
|
+
elif os.environ.get('AWS_ACCESS_KEY_ID'):
|
|
223
|
+
profile['credentials'] = {
|
|
224
|
+
'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
|
|
225
|
+
'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
226
|
+
'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
|
|
227
|
+
}
|
|
228
|
+
else:
|
|
229
|
+
# Try to find a matching AWS profile by name
|
|
230
|
+
# This allows "khoros" profile to work with "khoros_umbrella" AWS profile
|
|
231
|
+
import subprocess
|
|
232
|
+
try:
|
|
233
|
+
result = subprocess.run(
|
|
234
|
+
['aws', 'configure', 'list-profiles'],
|
|
235
|
+
capture_output=True,
|
|
236
|
+
text=True,
|
|
237
|
+
timeout=5
|
|
180
238
|
)
|
|
181
|
-
|
|
182
|
-
|
|
239
|
+
if result.returncode == 0:
|
|
240
|
+
available_profiles = result.stdout.strip().split('\n')
|
|
241
|
+
# Try exact match first
|
|
242
|
+
if profile_name in available_profiles:
|
|
243
|
+
profile['aws_profile'] = profile_name
|
|
244
|
+
# Try with common suffixes
|
|
245
|
+
elif f"{profile_name}_umbrella" in available_profiles:
|
|
246
|
+
profile['aws_profile'] = f"{profile_name}_umbrella"
|
|
247
|
+
elif f"{profile_name}-umbrella" in available_profiles:
|
|
248
|
+
profile['aws_profile'] = f"{profile_name}-umbrella"
|
|
249
|
+
elif f"{profile_name}_prod" in available_profiles:
|
|
250
|
+
profile['aws_profile'] = f"{profile_name}_prod"
|
|
251
|
+
# If no match found, leave it unset - user must provide --sso
|
|
252
|
+
except:
|
|
253
|
+
# If we can't list profiles, leave it unset - user must provide --sso
|
|
254
|
+
pass
|
|
183
255
|
|
|
184
256
|
return profile
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
257
|
+
else:
|
|
258
|
+
raise click.ClickException(
|
|
259
|
+
f"Profile '{profile_name}' not found in DynamoDB.\n"
|
|
260
|
+
f"Run: cc profile create --name {profile_name} --accounts \"...\""
|
|
261
|
+
)
|
|
262
|
+
except requests.exceptions.RequestException as e:
|
|
263
|
+
raise click.ClickException(
|
|
264
|
+
f"Failed to fetch profile from API: {e}\n"
|
|
265
|
+
"Check your API secret and network connection."
|
|
266
|
+
)
|
|
191
267
|
|
|
192
268
|
|
|
193
269
|
def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
@@ -265,7 +341,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
265
341
|
)
|
|
266
342
|
raise
|
|
267
343
|
|
|
268
|
-
# Build filter
|
|
344
|
+
# Build filter with dynamic exclusions
|
|
345
|
+
exclusions = get_exclusions() # Get from DynamoDB
|
|
346
|
+
exclusion_filters = build_exclusion_filter(exclusions)
|
|
347
|
+
|
|
269
348
|
cost_filter = {
|
|
270
349
|
"And": [
|
|
271
350
|
{
|
|
@@ -279,18 +358,13 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
279
358
|
"Key": "BILLING_ENTITY",
|
|
280
359
|
"Values": ["AWS"]
|
|
281
360
|
}
|
|
282
|
-
},
|
|
283
|
-
{
|
|
284
|
-
"Not": {
|
|
285
|
-
"Dimensions": {
|
|
286
|
-
"Key": "RECORD_TYPE",
|
|
287
|
-
"Values": ["Tax", "Support"]
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
361
|
}
|
|
291
362
|
]
|
|
292
363
|
}
|
|
293
364
|
|
|
365
|
+
# Add dynamic exclusion filters
|
|
366
|
+
cost_filter["And"].extend(exclusion_filters)
|
|
367
|
+
|
|
294
368
|
# Get daily costs
|
|
295
369
|
click.echo("Fetching cost data...")
|
|
296
370
|
try:
|
|
@@ -737,77 +811,81 @@ def setup():
|
|
|
737
811
|
|
|
738
812
|
|
|
739
813
|
@cli.command()
|
|
740
|
-
@click.option('--
|
|
741
|
-
@click.option('--
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
def configure(profile, access_key_id, secret_access_key, session_token, region):
|
|
746
|
-
"""Configure AWS credentials for a profile (alternative to SSO)"""
|
|
814
|
+
@click.option('--api-secret', help='API secret for DynamoDB profile access')
|
|
815
|
+
@click.option('--show', is_flag=True, help='Show current configuration')
|
|
816
|
+
def configure(api_secret, show):
|
|
817
|
+
"""
|
|
818
|
+
Configure Cost Calculator CLI settings.
|
|
747
819
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
creds_file = config_dir / 'credentials.json'
|
|
820
|
+
This tool requires an API secret to access profiles stored in DynamoDB.
|
|
821
|
+
The secret can be configured here or set via COST_API_SECRET environment variable.
|
|
751
822
|
|
|
752
|
-
|
|
823
|
+
Examples:
|
|
824
|
+
# Configure API secret
|
|
825
|
+
cc configure --api-secret YOUR_SECRET_KEY
|
|
826
|
+
|
|
827
|
+
# Show current configuration
|
|
828
|
+
cc configure --show
|
|
829
|
+
|
|
830
|
+
# Use environment variable instead (no configuration needed)
|
|
831
|
+
export COST_API_SECRET=YOUR_SECRET_KEY
|
|
832
|
+
"""
|
|
833
|
+
import os
|
|
834
|
+
|
|
835
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
753
836
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
837
|
+
config_file = config_dir / 'config.json'
|
|
754
838
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
839
|
+
if show:
|
|
840
|
+
# Show current configuration
|
|
841
|
+
if config_file.exists():
|
|
758
842
|
with open(config_file) as f:
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
843
|
+
config = json.load(f)
|
|
844
|
+
if 'api_secret' in config:
|
|
845
|
+
masked_secret = config['api_secret'][:8] + '...' + config['api_secret'][-4:]
|
|
846
|
+
click.echo(f"API Secret: {masked_secret} (configured)")
|
|
847
|
+
else:
|
|
848
|
+
click.echo("API Secret: Not configured")
|
|
849
|
+
else:
|
|
850
|
+
click.echo("No configuration file found")
|
|
851
|
+
|
|
852
|
+
# Check environment variable
|
|
853
|
+
import os
|
|
854
|
+
if os.environ.get('COST_API_SECRET'):
|
|
855
|
+
click.echo("Environment: COST_API_SECRET is set")
|
|
856
|
+
else:
|
|
857
|
+
click.echo("Environment: COST_API_SECRET is not set")
|
|
858
|
+
|
|
768
859
|
return
|
|
769
860
|
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
with open(config_file, 'w') as f:
|
|
776
|
-
json.dump(profiles, f, indent=2)
|
|
777
|
-
|
|
778
|
-
# Load or create credentials file
|
|
779
|
-
if creds_file.exists() and creds_file.stat().st_size > 0:
|
|
780
|
-
try:
|
|
781
|
-
with open(creds_file) as f:
|
|
782
|
-
creds = json.load(f)
|
|
783
|
-
except json.JSONDecodeError:
|
|
784
|
-
creds = {}
|
|
785
|
-
else:
|
|
786
|
-
creds = {}
|
|
861
|
+
if not api_secret:
|
|
862
|
+
raise click.ClickException(
|
|
863
|
+
"Please provide --api-secret or use --show to view current configuration\n"
|
|
864
|
+
"Example: cc configure --api-secret YOUR_SECRET_KEY"
|
|
865
|
+
)
|
|
787
866
|
|
|
788
|
-
#
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}
|
|
867
|
+
# Load existing config
|
|
868
|
+
config = {}
|
|
869
|
+
if config_file.exists():
|
|
870
|
+
with open(config_file) as f:
|
|
871
|
+
config = json.load(f)
|
|
794
872
|
|
|
795
|
-
|
|
796
|
-
|
|
873
|
+
# Update API secret
|
|
874
|
+
config['api_secret'] = api_secret
|
|
797
875
|
|
|
798
|
-
# Save
|
|
799
|
-
with open(
|
|
800
|
-
json.dump(
|
|
876
|
+
# Save config
|
|
877
|
+
with open(config_file, 'w') as f:
|
|
878
|
+
json.dump(config, f, indent=2)
|
|
801
879
|
|
|
802
|
-
# Set
|
|
803
|
-
|
|
880
|
+
# Set restrictive permissions (Unix/Mac only - Windows uses different permission model)
|
|
881
|
+
import platform
|
|
882
|
+
if platform.system() != 'Windows':
|
|
883
|
+
os.chmod(config_file, 0o600)
|
|
804
884
|
|
|
805
|
-
|
|
806
|
-
click.echo(f"✓
|
|
807
|
-
click.echo(f"\
|
|
808
|
-
click.echo("\nNote:
|
|
809
|
-
click.echo(" you'll need to reconfigure when they expire.")
|
|
810
|
-
|
|
885
|
+
masked_secret = api_secret[:8] + '...' + api_secret[-4:]
|
|
886
|
+
click.echo(f"✓ API secret configured: {masked_secret}")
|
|
887
|
+
click.echo(f"\nYou can now run: cc calculate --profile PROFILE_NAME")
|
|
888
|
+
click.echo(f"\nNote: Profiles are stored in DynamoDB and accessed via the API.")
|
|
811
889
|
|
|
812
890
|
@cli.command()
|
|
813
891
|
@click.option('--profile', required=True, help='Profile name')
|
|
@@ -2013,5 +2091,238 @@ def query(profile, query, database, output_bucket, sso):
|
|
|
2013
2091
|
raise click.ClickException(f"Query failed: {e}")
|
|
2014
2092
|
|
|
2015
2093
|
|
|
2094
|
+
@cli.group()
|
|
2095
|
+
def exclusions():
|
|
2096
|
+
"""
|
|
2097
|
+
Manage cost exclusions (services/types to exclude from calculations).
|
|
2098
|
+
|
|
2099
|
+
Exclusions are stored in DynamoDB and apply globally or per-profile.
|
|
2100
|
+
Common exclusions: Tax, Support, OCBLateFee, Refunds, Credits
|
|
2101
|
+
"""
|
|
2102
|
+
pass
|
|
2103
|
+
|
|
2104
|
+
|
|
2105
|
+
@exclusions.command('show')
|
|
2106
|
+
@click.option('--profile', help='Show profile-specific exclusions (default: global)')
|
|
2107
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
2108
|
+
def show_exclusions(profile, output_json):
|
|
2109
|
+
"""Show current exclusions configuration"""
|
|
2110
|
+
import requests
|
|
2111
|
+
|
|
2112
|
+
api_secret = get_api_secret()
|
|
2113
|
+
if not api_secret:
|
|
2114
|
+
raise click.ClickException(
|
|
2115
|
+
"No API secret configured.\n"
|
|
2116
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2117
|
+
)
|
|
2118
|
+
|
|
2119
|
+
try:
|
|
2120
|
+
response = requests.post(
|
|
2121
|
+
PROFILES_API_URL,
|
|
2122
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2123
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2124
|
+
timeout=10
|
|
2125
|
+
)
|
|
2126
|
+
|
|
2127
|
+
if response.status_code == 200:
|
|
2128
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2129
|
+
|
|
2130
|
+
if output_json:
|
|
2131
|
+
click.echo(json.dumps(exclusions_data, indent=2))
|
|
2132
|
+
else:
|
|
2133
|
+
scope = profile if profile else "global"
|
|
2134
|
+
click.echo(f"Exclusions ({scope}):")
|
|
2135
|
+
click.echo("")
|
|
2136
|
+
|
|
2137
|
+
if exclusions_data.get('record_types'):
|
|
2138
|
+
click.echo("Record Types:")
|
|
2139
|
+
for rt in exclusions_data['record_types']:
|
|
2140
|
+
click.echo(f" - {rt}")
|
|
2141
|
+
click.echo("")
|
|
2142
|
+
|
|
2143
|
+
if exclusions_data.get('services'):
|
|
2144
|
+
click.echo("Services:")
|
|
2145
|
+
for svc in exclusions_data['services']:
|
|
2146
|
+
click.echo(f" - {svc}")
|
|
2147
|
+
click.echo("")
|
|
2148
|
+
|
|
2149
|
+
if exclusions_data.get('line_item_types'):
|
|
2150
|
+
click.echo("Line Item Types:")
|
|
2151
|
+
for lit in exclusions_data['line_item_types']:
|
|
2152
|
+
click.echo(f" - {lit}")
|
|
2153
|
+
click.echo("")
|
|
2154
|
+
|
|
2155
|
+
if exclusions_data.get('usage_types'):
|
|
2156
|
+
click.echo("Usage Types:")
|
|
2157
|
+
for ut in exclusions_data['usage_types']:
|
|
2158
|
+
click.echo(f" - {ut}")
|
|
2159
|
+
else:
|
|
2160
|
+
raise click.ClickException(f"Failed to fetch exclusions: {response.status_code}")
|
|
2161
|
+
|
|
2162
|
+
except requests.exceptions.RequestException as e:
|
|
2163
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2164
|
+
|
|
2165
|
+
|
|
2166
|
+
@exclusions.command('add')
|
|
2167
|
+
@click.option('--record-type', help='Add record type exclusion (e.g., Tax, Support)')
|
|
2168
|
+
@click.option('--service', help='Add service exclusion (e.g., OCBLateFee)')
|
|
2169
|
+
@click.option('--line-item-type', help='Add line item type exclusion (e.g., Refund, Credit)')
|
|
2170
|
+
@click.option('--usage-type', help='Add usage type exclusion')
|
|
2171
|
+
@click.option('--profile', help='Add to profile-specific exclusions (default: global)')
|
|
2172
|
+
def add_exclusion(record_type, service, line_item_type, usage_type, profile):
|
|
2173
|
+
"""Add an exclusion"""
|
|
2174
|
+
import requests
|
|
2175
|
+
|
|
2176
|
+
if not any([record_type, service, line_item_type, usage_type]):
|
|
2177
|
+
raise click.ClickException(
|
|
2178
|
+
"Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
|
|
2179
|
+
)
|
|
2180
|
+
|
|
2181
|
+
api_secret = get_api_secret()
|
|
2182
|
+
if not api_secret:
|
|
2183
|
+
raise click.ClickException(
|
|
2184
|
+
"No API secret configured.\n"
|
|
2185
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2186
|
+
)
|
|
2187
|
+
|
|
2188
|
+
# Get current exclusions
|
|
2189
|
+
try:
|
|
2190
|
+
response = requests.post(
|
|
2191
|
+
PROFILES_API_URL,
|
|
2192
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2193
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2194
|
+
timeout=10
|
|
2195
|
+
)
|
|
2196
|
+
|
|
2197
|
+
if response.status_code == 200:
|
|
2198
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2199
|
+
else:
|
|
2200
|
+
exclusions_data = {
|
|
2201
|
+
'record_types': [],
|
|
2202
|
+
'services': [],
|
|
2203
|
+
'line_item_types': [],
|
|
2204
|
+
'usage_types': []
|
|
2205
|
+
}
|
|
2206
|
+
|
|
2207
|
+
# Add new exclusions
|
|
2208
|
+
if record_type and record_type not in exclusions_data.get('record_types', []):
|
|
2209
|
+
exclusions_data.setdefault('record_types', []).append(record_type)
|
|
2210
|
+
|
|
2211
|
+
if service and service not in exclusions_data.get('services', []):
|
|
2212
|
+
exclusions_data.setdefault('services', []).append(service)
|
|
2213
|
+
|
|
2214
|
+
if line_item_type and line_item_type not in exclusions_data.get('line_item_types', []):
|
|
2215
|
+
exclusions_data.setdefault('line_item_types', []).append(line_item_type)
|
|
2216
|
+
|
|
2217
|
+
if usage_type and usage_type not in exclusions_data.get('usage_types', []):
|
|
2218
|
+
exclusions_data.setdefault('usage_types', []).append(usage_type)
|
|
2219
|
+
|
|
2220
|
+
# Update exclusions
|
|
2221
|
+
update_response = requests.post(
|
|
2222
|
+
PROFILES_API_URL,
|
|
2223
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2224
|
+
json={
|
|
2225
|
+
'operation': 'update_exclusions',
|
|
2226
|
+
'profile_name': profile,
|
|
2227
|
+
'record_types': exclusions_data.get('record_types', []),
|
|
2228
|
+
'services': exclusions_data.get('services', []),
|
|
2229
|
+
'line_item_types': exclusions_data.get('line_item_types', []),
|
|
2230
|
+
'usage_types': exclusions_data.get('usage_types', [])
|
|
2231
|
+
},
|
|
2232
|
+
timeout=10
|
|
2233
|
+
)
|
|
2234
|
+
|
|
2235
|
+
if update_response.status_code == 200:
|
|
2236
|
+
scope = profile if profile else "global"
|
|
2237
|
+
click.echo(f"✓ Exclusion added to {scope} config")
|
|
2238
|
+
if record_type:
|
|
2239
|
+
click.echo(f" Record Type: {record_type}")
|
|
2240
|
+
if service:
|
|
2241
|
+
click.echo(f" Service: {service}")
|
|
2242
|
+
if line_item_type:
|
|
2243
|
+
click.echo(f" Line Item Type: {line_item_type}")
|
|
2244
|
+
if usage_type:
|
|
2245
|
+
click.echo(f" Usage Type: {usage_type}")
|
|
2246
|
+
else:
|
|
2247
|
+
raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
|
|
2248
|
+
|
|
2249
|
+
except requests.exceptions.RequestException as e:
|
|
2250
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2251
|
+
|
|
2252
|
+
|
|
2253
|
+
@exclusions.command('remove')
|
|
2254
|
+
@click.option('--record-type', help='Remove record type exclusion')
|
|
2255
|
+
@click.option('--service', help='Remove service exclusion')
|
|
2256
|
+
@click.option('--line-item-type', help='Remove line item type exclusion')
|
|
2257
|
+
@click.option('--usage-type', help='Remove usage type exclusion')
|
|
2258
|
+
@click.option('--profile', help='Remove from profile-specific exclusions (default: global)')
|
|
2259
|
+
def remove_exclusion(record_type, service, line_item_type, usage_type, profile):
|
|
2260
|
+
"""Remove an exclusion"""
|
|
2261
|
+
import requests
|
|
2262
|
+
|
|
2263
|
+
if not any([record_type, service, line_item_type, usage_type]):
|
|
2264
|
+
raise click.ClickException(
|
|
2265
|
+
"Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
|
|
2266
|
+
)
|
|
2267
|
+
|
|
2268
|
+
api_secret = get_api_secret()
|
|
2269
|
+
if not api_secret:
|
|
2270
|
+
raise click.ClickException(
|
|
2271
|
+
"No API secret configured.\n"
|
|
2272
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2273
|
+
)
|
|
2274
|
+
|
|
2275
|
+
# Get current exclusions
|
|
2276
|
+
try:
|
|
2277
|
+
response = requests.post(
|
|
2278
|
+
PROFILES_API_URL,
|
|
2279
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2280
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2281
|
+
timeout=10
|
|
2282
|
+
)
|
|
2283
|
+
|
|
2284
|
+
if response.status_code == 200:
|
|
2285
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2286
|
+
else:
|
|
2287
|
+
raise click.ClickException("No exclusions found")
|
|
2288
|
+
|
|
2289
|
+
# Remove exclusions
|
|
2290
|
+
if record_type and record_type in exclusions_data.get('record_types', []):
|
|
2291
|
+
exclusions_data['record_types'].remove(record_type)
|
|
2292
|
+
|
|
2293
|
+
if service and service in exclusions_data.get('services', []):
|
|
2294
|
+
exclusions_data['services'].remove(service)
|
|
2295
|
+
|
|
2296
|
+
if line_item_type and line_item_type in exclusions_data.get('line_item_types', []):
|
|
2297
|
+
exclusions_data['line_item_types'].remove(line_item_type)
|
|
2298
|
+
|
|
2299
|
+
if usage_type and usage_type in exclusions_data.get('usage_types', []):
|
|
2300
|
+
exclusions_data['usage_types'].remove(usage_type)
|
|
2301
|
+
|
|
2302
|
+
# Update exclusions
|
|
2303
|
+
update_response = requests.post(
|
|
2304
|
+
PROFILES_API_URL,
|
|
2305
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2306
|
+
json={
|
|
2307
|
+
'operation': 'update_exclusions',
|
|
2308
|
+
'profile_name': profile,
|
|
2309
|
+
'record_types': exclusions_data.get('record_types', []),
|
|
2310
|
+
'services': exclusions_data.get('services', []),
|
|
2311
|
+
'line_item_types': exclusions_data.get('line_item_types', []),
|
|
2312
|
+
'usage_types': exclusions_data.get('usage_types', [])
|
|
2313
|
+
},
|
|
2314
|
+
timeout=10
|
|
2315
|
+
)
|
|
2316
|
+
|
|
2317
|
+
if update_response.status_code == 200:
|
|
2318
|
+
scope = profile if profile else "global"
|
|
2319
|
+
click.echo(f"✓ Exclusion removed from {scope} config")
|
|
2320
|
+
else:
|
|
2321
|
+
raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
|
|
2322
|
+
|
|
2323
|
+
except requests.exceptions.RequestException as e:
|
|
2324
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2325
|
+
|
|
2326
|
+
|
|
2016
2327
|
if __name__ == '__main__':
|
|
2017
2328
|
cli()
|
{aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/executor.py
RENAMED
|
@@ -227,8 +227,11 @@ def execute_profile_operation(operation, profile_name=None, accounts=None, descr
|
|
|
227
227
|
|
|
228
228
|
api_secret = os.environ.get('COST_API_SECRET', '')
|
|
229
229
|
|
|
230
|
-
# Use profiles endpoint (
|
|
231
|
-
url =
|
|
230
|
+
# Use profiles endpoint (can be overridden via environment variable)
|
|
231
|
+
url = os.environ.get(
|
|
232
|
+
'COST_CALCULATOR_PROFILES_URL',
|
|
233
|
+
'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
|
|
234
|
+
)
|
|
232
235
|
|
|
233
236
|
payload = {'operation': operation}
|
|
234
237
|
if profile_name:
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/__init__.py
RENAMED
|
File without changes
|
{aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/api_client.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_cost_calculator_cli-1.11.1 → aws_cost_calculator_cli-2.1.0}/cost_calculator/forensics.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|