aws-cost-calculator-cli 1.11.1__py3-none-any.whl → 2.3.1__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.11.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/METADATA +1 -1
- aws_cost_calculator_cli-2.3.1.dist-info/RECORD +16 -0
- {aws_cost_calculator_cli-1.11.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/WHEEL +1 -1
- cost_calculator/api_client.py +18 -21
- cost_calculator/cli.py +575 -308
- cost_calculator/dimensions.py +141 -0
- cost_calculator/executor.py +25 -12
- aws_cost_calculator_cli-1.11.1.dist-info/RECORD +0 -15
- {aws_cost_calculator_cli-1.11.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.11.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/licenses/LICENSE +0 -0
- {aws_cost_calculator_cli-1.11.1.dist-info → aws_cost_calculator_cli-2.3.1.dist-info}/top_level.txt +0 -0
cost_calculator/cli.py
CHANGED
|
@@ -18,6 +18,17 @@ 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
|
+
API_BASE_URL = os.environ.get(
|
|
24
|
+
'COST_CALCULATOR_API_URL',
|
|
25
|
+
'https://api.costcop.cloudfix.dev'
|
|
26
|
+
)
|
|
27
|
+
# Legacy profiles URL for backward compatibility
|
|
28
|
+
PROFILES_API_URL = os.environ.get(
|
|
29
|
+
'COST_CALCULATOR_PROFILES_URL',
|
|
30
|
+
f'{API_BASE_URL}/profiles'
|
|
31
|
+
)
|
|
21
32
|
from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
|
|
22
33
|
|
|
23
34
|
|
|
@@ -67,127 +78,197 @@ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=N
|
|
|
67
78
|
return config
|
|
68
79
|
|
|
69
80
|
|
|
70
|
-
def
|
|
71
|
-
"""
|
|
81
|
+
def get_api_secret():
|
|
82
|
+
"""Get API secret from config file or environment variable"""
|
|
72
83
|
import os
|
|
73
|
-
import requests
|
|
74
|
-
|
|
75
|
-
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
76
|
-
config_file = config_dir / 'profiles.json'
|
|
77
|
-
creds_file = config_dir / 'credentials.json'
|
|
78
84
|
|
|
79
|
-
#
|
|
85
|
+
# Check environment variable first
|
|
80
86
|
api_secret = os.environ.get('COST_API_SECRET')
|
|
81
87
|
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
|
-
)
|
|
88
|
+
return api_secret
|
|
89
|
+
|
|
90
|
+
# Check config file
|
|
91
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
92
|
+
config_file = config_dir / 'config.json'
|
|
147
93
|
|
|
148
|
-
# Fallback to local file if no API secret
|
|
149
94
|
if config_file.exists():
|
|
150
95
|
with open(config_file) as f:
|
|
151
|
-
|
|
96
|
+
config = json.load(f)
|
|
97
|
+
return config.get('api_secret')
|
|
98
|
+
|
|
99
|
+
return None
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
def get_exclusions(profile_name=None):
|
|
103
|
+
"""Get exclusions configuration from DynamoDB API"""
|
|
104
|
+
import requests
|
|
105
|
+
|
|
106
|
+
api_secret = get_api_secret()
|
|
107
|
+
if not api_secret:
|
|
108
|
+
raise click.ClickException(
|
|
109
|
+
"No API secret configured.\n"
|
|
110
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
111
|
+
)
|
|
112
|
+
|
|
113
|
+
try:
|
|
114
|
+
response = requests.post(
|
|
115
|
+
PROFILES_API_URL,
|
|
116
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
117
|
+
json={'operation': 'get_exclusions', 'profile_name': profile_name},
|
|
118
|
+
timeout=10
|
|
119
|
+
)
|
|
152
120
|
|
|
153
|
-
if
|
|
154
|
-
|
|
121
|
+
if response.status_code == 200:
|
|
122
|
+
return response.json().get('exclusions', {})
|
|
123
|
+
else:
|
|
124
|
+
# Return defaults if API fails
|
|
125
|
+
return {
|
|
126
|
+
'record_types': ['Tax', 'Support'],
|
|
127
|
+
'services': [],
|
|
128
|
+
'usage_types': [],
|
|
129
|
+
'line_item_types': []
|
|
130
|
+
}
|
|
131
|
+
except:
|
|
132
|
+
# Return defaults if request fails
|
|
133
|
+
return {
|
|
134
|
+
'record_types': ['Tax', 'Support'],
|
|
135
|
+
'services': [],
|
|
136
|
+
'usage_types': [],
|
|
137
|
+
'line_item_types': []
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def build_exclusion_filter(exclusions):
|
|
142
|
+
"""Build AWS Cost Explorer filter from exclusions config"""
|
|
143
|
+
filter_parts = []
|
|
144
|
+
|
|
145
|
+
# Exclude record types (Tax, Support, etc.)
|
|
146
|
+
if exclusions.get('record_types'):
|
|
147
|
+
filter_parts.append({
|
|
148
|
+
"Not": {
|
|
149
|
+
"Dimensions": {
|
|
150
|
+
"Key": "RECORD_TYPE",
|
|
151
|
+
"Values": exclusions['record_types']
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
})
|
|
155
|
+
|
|
156
|
+
# Exclude specific services (OCBLateFee, etc.)
|
|
157
|
+
if exclusions.get('services'):
|
|
158
|
+
filter_parts.append({
|
|
159
|
+
"Not": {
|
|
160
|
+
"Dimensions": {
|
|
161
|
+
"Key": "SERVICE",
|
|
162
|
+
"Values": exclusions['services']
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
})
|
|
166
|
+
|
|
167
|
+
# Exclude usage types
|
|
168
|
+
if exclusions.get('usage_types'):
|
|
169
|
+
filter_parts.append({
|
|
170
|
+
"Not": {
|
|
171
|
+
"Dimensions": {
|
|
172
|
+
"Key": "USAGE_TYPE",
|
|
173
|
+
"Values": exclusions['usage_types']
|
|
174
|
+
}
|
|
175
|
+
}
|
|
176
|
+
})
|
|
177
|
+
|
|
178
|
+
# Exclude line item types (Refund, Credit, etc.)
|
|
179
|
+
if exclusions.get('line_item_types'):
|
|
180
|
+
filter_parts.append({
|
|
181
|
+
"Not": {
|
|
182
|
+
"Dimensions": {
|
|
183
|
+
"Key": "LINE_ITEM_TYPE",
|
|
184
|
+
"Values": exclusions['line_item_types']
|
|
185
|
+
}
|
|
186
|
+
}
|
|
187
|
+
})
|
|
188
|
+
|
|
189
|
+
return filter_parts
|
|
190
|
+
|
|
191
|
+
|
|
192
|
+
def load_profile(profile_name):
|
|
193
|
+
"""Load profile configuration from DynamoDB API (API-only, no local files)"""
|
|
194
|
+
import requests
|
|
195
|
+
|
|
196
|
+
# Get API secret
|
|
197
|
+
api_secret = get_api_secret()
|
|
198
|
+
|
|
199
|
+
if not api_secret:
|
|
200
|
+
raise click.ClickException(
|
|
201
|
+
"No API secret configured.\n"
|
|
202
|
+
"Run: cc configure --api-secret YOUR_SECRET\n"
|
|
203
|
+
"Or set environment variable: export COST_API_SECRET=YOUR_SECRET"
|
|
204
|
+
)
|
|
205
|
+
|
|
206
|
+
try:
|
|
207
|
+
response = requests.post(
|
|
208
|
+
PROFILES_API_URL,
|
|
209
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
210
|
+
json={'operation': 'get', 'profile_name': profile_name},
|
|
211
|
+
timeout=10
|
|
212
|
+
)
|
|
213
|
+
|
|
214
|
+
if response.status_code == 200:
|
|
215
|
+
response_data = response.json()
|
|
216
|
+
# API returns {"profile": {...}} wrapper
|
|
217
|
+
profile_data = response_data.get('profile', response_data)
|
|
218
|
+
profile = {'accounts': profile_data['accounts']}
|
|
155
219
|
|
|
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}"
|
|
220
|
+
# If profile has aws_profile field, use it
|
|
221
|
+
if 'aws_profile' in profile_data:
|
|
222
|
+
profile['aws_profile'] = profile_data['aws_profile']
|
|
223
|
+
# Check for AWS_PROFILE environment variable (SSO support)
|
|
224
|
+
elif os.environ.get('AWS_PROFILE'):
|
|
225
|
+
profile['aws_profile'] = os.environ['AWS_PROFILE']
|
|
226
|
+
# Use environment credentials
|
|
227
|
+
elif os.environ.get('AWS_ACCESS_KEY_ID'):
|
|
228
|
+
profile['credentials'] = {
|
|
229
|
+
'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
|
|
230
|
+
'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
231
|
+
'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
|
|
232
|
+
}
|
|
233
|
+
else:
|
|
234
|
+
# Try to find a matching AWS profile by name
|
|
235
|
+
# This allows "khoros" profile to work with "khoros_umbrella" AWS profile
|
|
236
|
+
import subprocess
|
|
237
|
+
try:
|
|
238
|
+
result = subprocess.run(
|
|
239
|
+
['aws', 'configure', 'list-profiles'],
|
|
240
|
+
capture_output=True,
|
|
241
|
+
text=True,
|
|
242
|
+
timeout=5
|
|
180
243
|
)
|
|
181
|
-
|
|
182
|
-
|
|
244
|
+
if result.returncode == 0:
|
|
245
|
+
available_profiles = result.stdout.strip().split('\n')
|
|
246
|
+
# Try exact match first
|
|
247
|
+
if profile_name in available_profiles:
|
|
248
|
+
profile['aws_profile'] = profile_name
|
|
249
|
+
# Try with common suffixes
|
|
250
|
+
elif f"{profile_name}_umbrella" in available_profiles:
|
|
251
|
+
profile['aws_profile'] = f"{profile_name}_umbrella"
|
|
252
|
+
elif f"{profile_name}-umbrella" in available_profiles:
|
|
253
|
+
profile['aws_profile'] = f"{profile_name}-umbrella"
|
|
254
|
+
elif f"{profile_name}_prod" in available_profiles:
|
|
255
|
+
profile['aws_profile'] = f"{profile_name}_prod"
|
|
256
|
+
# If no match found, leave it unset - user must provide --sso
|
|
257
|
+
except:
|
|
258
|
+
# If we can't list profiles, leave it unset - user must provide --sso
|
|
259
|
+
pass
|
|
183
260
|
|
|
184
261
|
return profile
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
262
|
+
else:
|
|
263
|
+
raise click.ClickException(
|
|
264
|
+
f"Profile '{profile_name}' not found in DynamoDB.\n"
|
|
265
|
+
f"Run: cc profile create --name {profile_name} --accounts \"...\""
|
|
266
|
+
)
|
|
267
|
+
except requests.exceptions.RequestException as e:
|
|
268
|
+
raise click.ClickException(
|
|
269
|
+
f"Failed to fetch profile from API: {e}\n"
|
|
270
|
+
"Check your API secret and network connection."
|
|
271
|
+
)
|
|
191
272
|
|
|
192
273
|
|
|
193
274
|
def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
@@ -265,7 +346,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
265
346
|
)
|
|
266
347
|
raise
|
|
267
348
|
|
|
268
|
-
# Build filter
|
|
349
|
+
# Build filter with dynamic exclusions
|
|
350
|
+
exclusions = get_exclusions() # Get from DynamoDB
|
|
351
|
+
exclusion_filters = build_exclusion_filter(exclusions)
|
|
352
|
+
|
|
269
353
|
cost_filter = {
|
|
270
354
|
"And": [
|
|
271
355
|
{
|
|
@@ -279,18 +363,13 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
279
363
|
"Key": "BILLING_ENTITY",
|
|
280
364
|
"Values": ["AWS"]
|
|
281
365
|
}
|
|
282
|
-
},
|
|
283
|
-
{
|
|
284
|
-
"Not": {
|
|
285
|
-
"Dimensions": {
|
|
286
|
-
"Key": "RECORD_TYPE",
|
|
287
|
-
"Values": ["Tax", "Support"]
|
|
288
|
-
}
|
|
289
|
-
}
|
|
290
366
|
}
|
|
291
367
|
]
|
|
292
368
|
}
|
|
293
369
|
|
|
370
|
+
# Add dynamic exclusion filters
|
|
371
|
+
cost_filter["And"].extend(exclusion_filters)
|
|
372
|
+
|
|
294
373
|
# Get daily costs
|
|
295
374
|
click.echo("Fetching cost data...")
|
|
296
375
|
try:
|
|
@@ -595,219 +674,139 @@ def calculate(profile, start_date, offset, window, json_output, sso, access_key_
|
|
|
595
674
|
click.echo("=" * 60)
|
|
596
675
|
|
|
597
676
|
|
|
598
|
-
|
|
599
|
-
@click.option('--profile', required=True, help='Profile name to create')
|
|
600
|
-
@click.option('--aws-profile', required=True, help='AWS CLI profile name')
|
|
601
|
-
@click.option('--accounts', required=True, help='Comma-separated list of account IDs')
|
|
602
|
-
def init(profile, aws_profile, accounts):
|
|
603
|
-
"""Initialize a new profile configuration"""
|
|
604
|
-
|
|
605
|
-
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
606
|
-
config_file = config_dir / 'profiles.json'
|
|
607
|
-
|
|
608
|
-
# Create config directory if it doesn't exist
|
|
609
|
-
config_dir.mkdir(parents=True, exist_ok=True)
|
|
610
|
-
|
|
611
|
-
# Load existing profiles or create new
|
|
612
|
-
if config_file.exists() and config_file.stat().st_size > 0:
|
|
613
|
-
try:
|
|
614
|
-
with open(config_file) as f:
|
|
615
|
-
profiles = json.load(f)
|
|
616
|
-
except json.JSONDecodeError:
|
|
617
|
-
profiles = {}
|
|
618
|
-
else:
|
|
619
|
-
profiles = {}
|
|
620
|
-
|
|
621
|
-
# Parse accounts
|
|
622
|
-
account_list = [acc.strip() for acc in accounts.split(',')]
|
|
623
|
-
|
|
624
|
-
# Add new profile
|
|
625
|
-
profiles[profile] = {
|
|
626
|
-
'aws_profile': aws_profile,
|
|
627
|
-
'accounts': account_list
|
|
628
|
-
}
|
|
629
|
-
|
|
630
|
-
# Save
|
|
631
|
-
with open(config_file, 'w') as f:
|
|
632
|
-
json.dump(profiles, f, indent=2)
|
|
633
|
-
|
|
634
|
-
click.echo(f"✓ Profile '{profile}' created with {len(account_list)} accounts")
|
|
635
|
-
click.echo(f"✓ Configuration saved to {config_file}")
|
|
636
|
-
click.echo(f"\nUsage: cc calculate --profile {profile}")
|
|
677
|
+
# init command removed - use backend API via 'cc profile create' instead
|
|
637
678
|
|
|
638
679
|
|
|
639
680
|
@cli.command()
|
|
640
681
|
def list_profiles():
|
|
641
|
-
"""List all
|
|
642
|
-
|
|
643
|
-
config_file = Path.home() / '.config' / 'cost-calculator' / 'profiles.json'
|
|
644
|
-
|
|
645
|
-
if not config_file.exists():
|
|
646
|
-
click.echo("No profiles configured. Run: cc init --profile <name>")
|
|
647
|
-
return
|
|
648
|
-
|
|
649
|
-
with open(config_file) as f:
|
|
650
|
-
profiles = json.load(f)
|
|
682
|
+
"""List all profiles from backend API (no local caching)"""
|
|
683
|
+
import requests
|
|
651
684
|
|
|
652
|
-
|
|
653
|
-
|
|
685
|
+
api_secret = get_api_secret()
|
|
686
|
+
if not api_secret:
|
|
687
|
+
click.echo("No API secret configured.")
|
|
688
|
+
click.echo("Run: cc configure --api-secret YOUR_SECRET")
|
|
654
689
|
return
|
|
655
690
|
|
|
656
|
-
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
691
|
+
try:
|
|
692
|
+
response = requests.post(
|
|
693
|
+
f"{API_BASE_URL}/profiles",
|
|
694
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
695
|
+
json={'operation': 'list'},
|
|
696
|
+
timeout=10
|
|
697
|
+
)
|
|
698
|
+
|
|
699
|
+
if response.status_code != 200:
|
|
700
|
+
click.echo(f"Error: {response.status_code} - {response.text}")
|
|
701
|
+
return
|
|
702
|
+
|
|
703
|
+
result = response.json()
|
|
704
|
+
profiles = result.get('profiles', [])
|
|
705
|
+
|
|
706
|
+
if not profiles:
|
|
707
|
+
click.echo("No profiles found in backend.")
|
|
708
|
+
click.echo("Contact admin to create profiles in DynamoDB.")
|
|
709
|
+
return
|
|
710
|
+
|
|
711
|
+
click.echo("Profiles (from backend API):")
|
|
665
712
|
click.echo("")
|
|
713
|
+
for profile in profiles:
|
|
714
|
+
# Each profile is a dict, extract the profile_name
|
|
715
|
+
if isinstance(profile, dict):
|
|
716
|
+
name = profile.get('profile_name', 'unknown')
|
|
717
|
+
# Skip exclusions entries
|
|
718
|
+
if not name.startswith('exclusions:'):
|
|
719
|
+
accounts = profile.get('accounts', [])
|
|
720
|
+
click.echo(f" {name}")
|
|
721
|
+
click.echo(f" Accounts: {len(accounts)}")
|
|
722
|
+
click.echo("")
|
|
723
|
+
else:
|
|
724
|
+
click.echo(f" {profile}")
|
|
725
|
+
click.echo(f"Total: {len([p for p in profiles if isinstance(p, dict) and not p.get('profile_name', '').startswith('exclusions:')])} profile(s)")
|
|
726
|
+
|
|
727
|
+
except Exception as e:
|
|
728
|
+
click.echo(f"Error loading profiles: {e}")
|
|
666
729
|
|
|
667
730
|
|
|
668
|
-
|
|
669
|
-
def setup():
|
|
670
|
-
"""Show setup instructions for manual profile configuration"""
|
|
671
|
-
import platform
|
|
672
|
-
|
|
673
|
-
system = platform.system()
|
|
674
|
-
|
|
675
|
-
if system == "Windows":
|
|
676
|
-
config_path = "%USERPROFILE%\\.config\\cost-calculator\\profiles.json"
|
|
677
|
-
config_path_example = "C:\\Users\\YourName\\.config\\cost-calculator\\profiles.json"
|
|
678
|
-
mkdir_cmd = "mkdir %USERPROFILE%\\.config\\cost-calculator"
|
|
679
|
-
edit_cmd = "notepad %USERPROFILE%\\.config\\cost-calculator\\profiles.json"
|
|
680
|
-
else: # macOS/Linux
|
|
681
|
-
config_path = "~/.config/cost-calculator/profiles.json"
|
|
682
|
-
config_path_example = "/Users/yourname/.config/cost-calculator/profiles.json"
|
|
683
|
-
mkdir_cmd = "mkdir -p ~/.config/cost-calculator"
|
|
684
|
-
edit_cmd = "nano ~/.config/cost-calculator/profiles.json"
|
|
685
|
-
|
|
686
|
-
click.echo("=" * 70)
|
|
687
|
-
click.echo("AWS Cost Calculator - Manual Profile Setup")
|
|
688
|
-
click.echo("=" * 70)
|
|
689
|
-
click.echo("")
|
|
690
|
-
click.echo(f"Platform: {system}")
|
|
691
|
-
click.echo(f"Config location: {config_path}")
|
|
692
|
-
click.echo("")
|
|
693
|
-
click.echo("Step 1: Create the config directory")
|
|
694
|
-
click.echo(f" {mkdir_cmd}")
|
|
695
|
-
click.echo("")
|
|
696
|
-
click.echo("Step 2: Create the profiles.json file")
|
|
697
|
-
click.echo(f" {edit_cmd}")
|
|
698
|
-
click.echo("")
|
|
699
|
-
click.echo("Step 3: Add your profile configuration (JSON format):")
|
|
700
|
-
click.echo("")
|
|
701
|
-
click.echo(' {')
|
|
702
|
-
click.echo(' "myprofile": {')
|
|
703
|
-
click.echo(' "aws_profile": "my_aws_profile",')
|
|
704
|
-
click.echo(' "accounts": [')
|
|
705
|
-
click.echo(' "123456789012",')
|
|
706
|
-
click.echo(' "234567890123",')
|
|
707
|
-
click.echo(' "345678901234"')
|
|
708
|
-
click.echo(' ]')
|
|
709
|
-
click.echo(' }')
|
|
710
|
-
click.echo(' }')
|
|
711
|
-
click.echo("")
|
|
712
|
-
click.echo("Step 4: Save the file")
|
|
713
|
-
click.echo("")
|
|
714
|
-
click.echo("Step 5: Verify it works")
|
|
715
|
-
click.echo(" cc list-profiles")
|
|
716
|
-
click.echo("")
|
|
717
|
-
click.echo("Step 6: Configure AWS credentials")
|
|
718
|
-
click.echo(" Option A (SSO):")
|
|
719
|
-
click.echo(" aws sso login --profile my_aws_profile")
|
|
720
|
-
click.echo(" cc calculate --profile myprofile")
|
|
721
|
-
click.echo("")
|
|
722
|
-
click.echo(" Option B (Static credentials):")
|
|
723
|
-
click.echo(" cc configure --profile myprofile")
|
|
724
|
-
click.echo(" cc calculate --profile myprofile")
|
|
725
|
-
click.echo("")
|
|
726
|
-
click.echo("=" * 70)
|
|
727
|
-
click.echo("")
|
|
728
|
-
click.echo("For multiple profiles, add more entries to the JSON:")
|
|
729
|
-
click.echo("")
|
|
730
|
-
click.echo(' {')
|
|
731
|
-
click.echo(' "profile1": { ... },')
|
|
732
|
-
click.echo(' "profile2": { ... }')
|
|
733
|
-
click.echo(' }')
|
|
734
|
-
click.echo("")
|
|
735
|
-
click.echo(f"Full path example: {config_path_example}")
|
|
736
|
-
click.echo("=" * 70)
|
|
731
|
+
# setup command removed - profiles are managed in DynamoDB backend only
|
|
737
732
|
|
|
738
733
|
|
|
739
734
|
@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)"""
|
|
735
|
+
@click.option('--api-secret', help='API secret for DynamoDB profile access')
|
|
736
|
+
@click.option('--show', is_flag=True, help='Show current configuration')
|
|
737
|
+
def configure(api_secret, show):
|
|
738
|
+
"""
|
|
739
|
+
Configure Cost Calculator CLI settings.
|
|
747
740
|
|
|
748
|
-
|
|
749
|
-
|
|
750
|
-
|
|
741
|
+
This tool requires an API secret to access profiles stored in DynamoDB.
|
|
742
|
+
The secret can be configured here or set via COST_API_SECRET environment variable.
|
|
743
|
+
|
|
744
|
+
Examples:
|
|
745
|
+
# Configure API secret
|
|
746
|
+
cc configure --api-secret YOUR_SECRET_KEY
|
|
747
|
+
|
|
748
|
+
# Show current configuration
|
|
749
|
+
cc configure --show
|
|
750
|
+
|
|
751
|
+
# Use environment variable instead (no configuration needed)
|
|
752
|
+
export COST_API_SECRET=YOUR_SECRET_KEY
|
|
753
|
+
"""
|
|
754
|
+
import os
|
|
751
755
|
|
|
752
|
-
|
|
756
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
753
757
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
758
|
+
config_file = config_dir / 'config.json'
|
|
754
759
|
|
|
755
|
-
|
|
756
|
-
|
|
757
|
-
|
|
760
|
+
if show:
|
|
761
|
+
# Show current configuration
|
|
762
|
+
if config_file.exists():
|
|
758
763
|
with open(config_file) as f:
|
|
759
|
-
|
|
760
|
-
|
|
761
|
-
|
|
762
|
-
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
764
|
+
config = json.load(f)
|
|
765
|
+
if 'api_secret' in config:
|
|
766
|
+
masked_secret = config['api_secret'][:8] + '...' + config['api_secret'][-4:]
|
|
767
|
+
click.echo(f"API Secret: {masked_secret} (configured)")
|
|
768
|
+
else:
|
|
769
|
+
click.echo("API Secret: Not configured")
|
|
770
|
+
else:
|
|
771
|
+
click.echo("No configuration file found")
|
|
772
|
+
|
|
773
|
+
# Check environment variable
|
|
774
|
+
import os
|
|
775
|
+
if os.environ.get('COST_API_SECRET'):
|
|
776
|
+
click.echo("Environment: COST_API_SECRET is set")
|
|
777
|
+
else:
|
|
778
|
+
click.echo("Environment: COST_API_SECRET is not set")
|
|
779
|
+
|
|
768
780
|
return
|
|
769
781
|
|
|
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 = {}
|
|
782
|
+
if not api_secret:
|
|
783
|
+
raise click.ClickException(
|
|
784
|
+
"Please provide --api-secret or use --show to view current configuration\n"
|
|
785
|
+
"Example: cc configure --api-secret YOUR_SECRET_KEY"
|
|
786
|
+
)
|
|
787
787
|
|
|
788
|
-
#
|
|
789
|
-
|
|
790
|
-
|
|
791
|
-
|
|
792
|
-
|
|
793
|
-
}
|
|
788
|
+
# Load existing config
|
|
789
|
+
config = {}
|
|
790
|
+
if config_file.exists():
|
|
791
|
+
with open(config_file) as f:
|
|
792
|
+
config = json.load(f)
|
|
794
793
|
|
|
795
|
-
|
|
796
|
-
|
|
794
|
+
# Update API secret
|
|
795
|
+
config['api_secret'] = api_secret
|
|
797
796
|
|
|
798
|
-
# Save
|
|
799
|
-
with open(
|
|
800
|
-
json.dump(
|
|
797
|
+
# Save config
|
|
798
|
+
with open(config_file, 'w') as f:
|
|
799
|
+
json.dump(config, f, indent=2)
|
|
801
800
|
|
|
802
|
-
# Set
|
|
803
|
-
|
|
801
|
+
# Set restrictive permissions (Unix/Mac only - Windows uses different permission model)
|
|
802
|
+
import platform
|
|
803
|
+
if platform.system() != 'Windows':
|
|
804
|
+
os.chmod(config_file, 0o600)
|
|
804
805
|
|
|
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
|
-
|
|
806
|
+
masked_secret = api_secret[:8] + '...' + api_secret[-4:]
|
|
807
|
+
click.echo(f"✓ API secret configured: {masked_secret}")
|
|
808
|
+
click.echo(f"\nYou can now run: cc calculate --profile PROFILE_NAME")
|
|
809
|
+
click.echo(f"\nNote: Profiles are stored in DynamoDB and accessed via the API.")
|
|
811
810
|
|
|
812
811
|
@cli.command()
|
|
813
812
|
@click.option('--profile', required=True, help='Profile name')
|
|
@@ -955,6 +954,10 @@ def monthly(profile, months, output, json_output, sso, access_key_id, secret_acc
|
|
|
955
954
|
@click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
|
|
956
955
|
@click.option('--account', help='Filter by account ID')
|
|
957
956
|
@click.option('--usage-type', help='Filter by usage type')
|
|
957
|
+
@click.option('--dimension', type=click.Choice(['service', 'account', 'region', 'usage_type', 'resource', 'instance_type', 'operation', 'availability_zone']),
|
|
958
|
+
help='Dimension to analyze by (overrides --service/--account filters)')
|
|
959
|
+
@click.option('--backend', type=click.Choice(['auto', 'ce', 'athena']), default='auto',
|
|
960
|
+
help='Data source: auto (smart selection), ce (Cost Explorer), athena (CUR). Default: auto')
|
|
958
961
|
@click.option('--resources', is_flag=True, help='Show individual resource IDs (requires CUR, uses Athena)')
|
|
959
962
|
@click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
|
|
960
963
|
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
@@ -962,19 +965,49 @@ def monthly(profile, months, output, json_output, sso, access_key_id, secret_acc
|
|
|
962
965
|
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
963
966
|
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
964
967
|
@click.option('--session-token', help='AWS Session Token')
|
|
965
|
-
def drill(profile, weeks, service, account, usage_type, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
968
|
+
def drill(profile, weeks, service, account, usage_type, dimension, backend, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
966
969
|
"""
|
|
967
970
|
Drill down into cost changes by service, account, or usage type
|
|
968
971
|
|
|
969
972
|
Add --resources flag to see individual resource IDs and costs (requires CUR data via Athena)
|
|
973
|
+
|
|
974
|
+
Examples:
|
|
975
|
+
# Analyze by service (auto-selects Cost Explorer for speed)
|
|
976
|
+
cc drill --profile khoros --dimension service
|
|
977
|
+
|
|
978
|
+
# Analyze by region with specific service filter
|
|
979
|
+
cc drill --profile khoros --dimension region --service AWSELB
|
|
980
|
+
|
|
981
|
+
# Analyze by resource (auto-selects Athena - only source with resource IDs)
|
|
982
|
+
cc drill --profile khoros --dimension resource --service AWSELB --account 820054669588
|
|
983
|
+
|
|
984
|
+
# Force Athena backend for detailed analysis
|
|
985
|
+
cc drill --profile khoros --dimension service --backend athena
|
|
970
986
|
"""
|
|
971
987
|
|
|
972
988
|
# Load profile
|
|
973
989
|
config = load_profile(profile)
|
|
974
990
|
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
975
991
|
|
|
992
|
+
# Smart backend selection
|
|
993
|
+
def select_backend(dimension, resources_flag, backend_choice):
|
|
994
|
+
"""Auto-select backend based on query requirements"""
|
|
995
|
+
if backend_choice != 'auto':
|
|
996
|
+
return backend_choice
|
|
997
|
+
|
|
998
|
+
# Must use Athena if:
|
|
999
|
+
if dimension == 'resource' or resources_flag:
|
|
1000
|
+
return 'athena'
|
|
1001
|
+
|
|
1002
|
+
# Prefer CE for speed (unless explicitly requesting Athena)
|
|
1003
|
+
return 'ce'
|
|
1004
|
+
|
|
1005
|
+
selected_backend = select_backend(dimension, resources, backend)
|
|
1006
|
+
|
|
976
1007
|
# Show filters
|
|
977
1008
|
click.echo(f"Analyzing last {weeks} weeks...")
|
|
1009
|
+
if dimension:
|
|
1010
|
+
click.echo(f" Dimension: {dimension}")
|
|
978
1011
|
if service:
|
|
979
1012
|
click.echo(f" Service filter: {service}")
|
|
980
1013
|
if account:
|
|
@@ -983,10 +1016,11 @@ def drill(profile, weeks, service, account, usage_type, resources, output, json_
|
|
|
983
1016
|
click.echo(f" Usage type filter: {usage_type}")
|
|
984
1017
|
if resources:
|
|
985
1018
|
click.echo(f" Mode: Resource-level (CUR via Athena)")
|
|
1019
|
+
click.echo(f" Backend: {selected_backend.upper()}")
|
|
986
1020
|
click.echo("")
|
|
987
1021
|
|
|
988
1022
|
# Execute via API or locally
|
|
989
|
-
drill_data = execute_drill(config, weeks, service, account, usage_type, resources)
|
|
1023
|
+
drill_data = execute_drill(config, weeks, service, account, usage_type, resources, dimension, selected_backend)
|
|
990
1024
|
|
|
991
1025
|
# Handle resource-level output differently
|
|
992
1026
|
if resources:
|
|
@@ -2013,5 +2047,238 @@ def query(profile, query, database, output_bucket, sso):
|
|
|
2013
2047
|
raise click.ClickException(f"Query failed: {e}")
|
|
2014
2048
|
|
|
2015
2049
|
|
|
2050
|
+
@cli.group()
|
|
2051
|
+
def exclusions():
|
|
2052
|
+
"""
|
|
2053
|
+
Manage cost exclusions (services/types to exclude from calculations).
|
|
2054
|
+
|
|
2055
|
+
Exclusions are stored in DynamoDB and apply globally or per-profile.
|
|
2056
|
+
Common exclusions: Tax, Support, OCBLateFee, Refunds, Credits
|
|
2057
|
+
"""
|
|
2058
|
+
pass
|
|
2059
|
+
|
|
2060
|
+
|
|
2061
|
+
@exclusions.command('show')
|
|
2062
|
+
@click.option('--profile', help='Show profile-specific exclusions (default: global)')
|
|
2063
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
2064
|
+
def show_exclusions(profile, output_json):
|
|
2065
|
+
"""Show current exclusions configuration"""
|
|
2066
|
+
import requests
|
|
2067
|
+
|
|
2068
|
+
api_secret = get_api_secret()
|
|
2069
|
+
if not api_secret:
|
|
2070
|
+
raise click.ClickException(
|
|
2071
|
+
"No API secret configured.\n"
|
|
2072
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2073
|
+
)
|
|
2074
|
+
|
|
2075
|
+
try:
|
|
2076
|
+
response = requests.post(
|
|
2077
|
+
PROFILES_API_URL,
|
|
2078
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2079
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2080
|
+
timeout=10
|
|
2081
|
+
)
|
|
2082
|
+
|
|
2083
|
+
if response.status_code == 200:
|
|
2084
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2085
|
+
|
|
2086
|
+
if output_json:
|
|
2087
|
+
click.echo(json.dumps(exclusions_data, indent=2))
|
|
2088
|
+
else:
|
|
2089
|
+
scope = profile if profile else "global"
|
|
2090
|
+
click.echo(f"Exclusions ({scope}):")
|
|
2091
|
+
click.echo("")
|
|
2092
|
+
|
|
2093
|
+
if exclusions_data.get('record_types'):
|
|
2094
|
+
click.echo("Record Types:")
|
|
2095
|
+
for rt in exclusions_data['record_types']:
|
|
2096
|
+
click.echo(f" - {rt}")
|
|
2097
|
+
click.echo("")
|
|
2098
|
+
|
|
2099
|
+
if exclusions_data.get('services'):
|
|
2100
|
+
click.echo("Services:")
|
|
2101
|
+
for svc in exclusions_data['services']:
|
|
2102
|
+
click.echo(f" - {svc}")
|
|
2103
|
+
click.echo("")
|
|
2104
|
+
|
|
2105
|
+
if exclusions_data.get('line_item_types'):
|
|
2106
|
+
click.echo("Line Item Types:")
|
|
2107
|
+
for lit in exclusions_data['line_item_types']:
|
|
2108
|
+
click.echo(f" - {lit}")
|
|
2109
|
+
click.echo("")
|
|
2110
|
+
|
|
2111
|
+
if exclusions_data.get('usage_types'):
|
|
2112
|
+
click.echo("Usage Types:")
|
|
2113
|
+
for ut in exclusions_data['usage_types']:
|
|
2114
|
+
click.echo(f" - {ut}")
|
|
2115
|
+
else:
|
|
2116
|
+
raise click.ClickException(f"Failed to fetch exclusions: {response.status_code}")
|
|
2117
|
+
|
|
2118
|
+
except requests.exceptions.RequestException as e:
|
|
2119
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2120
|
+
|
|
2121
|
+
|
|
2122
|
+
@exclusions.command('add')
|
|
2123
|
+
@click.option('--record-type', help='Add record type exclusion (e.g., Tax, Support)')
|
|
2124
|
+
@click.option('--service', help='Add service exclusion (e.g., OCBLateFee)')
|
|
2125
|
+
@click.option('--line-item-type', help='Add line item type exclusion (e.g., Refund, Credit)')
|
|
2126
|
+
@click.option('--usage-type', help='Add usage type exclusion')
|
|
2127
|
+
@click.option('--profile', help='Add to profile-specific exclusions (default: global)')
|
|
2128
|
+
def add_exclusion(record_type, service, line_item_type, usage_type, profile):
|
|
2129
|
+
"""Add an exclusion"""
|
|
2130
|
+
import requests
|
|
2131
|
+
|
|
2132
|
+
if not any([record_type, service, line_item_type, usage_type]):
|
|
2133
|
+
raise click.ClickException(
|
|
2134
|
+
"Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
|
|
2135
|
+
)
|
|
2136
|
+
|
|
2137
|
+
api_secret = get_api_secret()
|
|
2138
|
+
if not api_secret:
|
|
2139
|
+
raise click.ClickException(
|
|
2140
|
+
"No API secret configured.\n"
|
|
2141
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2142
|
+
)
|
|
2143
|
+
|
|
2144
|
+
# Get current exclusions
|
|
2145
|
+
try:
|
|
2146
|
+
response = requests.post(
|
|
2147
|
+
PROFILES_API_URL,
|
|
2148
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2149
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2150
|
+
timeout=10
|
|
2151
|
+
)
|
|
2152
|
+
|
|
2153
|
+
if response.status_code == 200:
|
|
2154
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2155
|
+
else:
|
|
2156
|
+
exclusions_data = {
|
|
2157
|
+
'record_types': [],
|
|
2158
|
+
'services': [],
|
|
2159
|
+
'line_item_types': [],
|
|
2160
|
+
'usage_types': []
|
|
2161
|
+
}
|
|
2162
|
+
|
|
2163
|
+
# Add new exclusions
|
|
2164
|
+
if record_type and record_type not in exclusions_data.get('record_types', []):
|
|
2165
|
+
exclusions_data.setdefault('record_types', []).append(record_type)
|
|
2166
|
+
|
|
2167
|
+
if service and service not in exclusions_data.get('services', []):
|
|
2168
|
+
exclusions_data.setdefault('services', []).append(service)
|
|
2169
|
+
|
|
2170
|
+
if line_item_type and line_item_type not in exclusions_data.get('line_item_types', []):
|
|
2171
|
+
exclusions_data.setdefault('line_item_types', []).append(line_item_type)
|
|
2172
|
+
|
|
2173
|
+
if usage_type and usage_type not in exclusions_data.get('usage_types', []):
|
|
2174
|
+
exclusions_data.setdefault('usage_types', []).append(usage_type)
|
|
2175
|
+
|
|
2176
|
+
# Update exclusions
|
|
2177
|
+
update_response = requests.post(
|
|
2178
|
+
PROFILES_API_URL,
|
|
2179
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2180
|
+
json={
|
|
2181
|
+
'operation': 'update_exclusions',
|
|
2182
|
+
'profile_name': profile,
|
|
2183
|
+
'record_types': exclusions_data.get('record_types', []),
|
|
2184
|
+
'services': exclusions_data.get('services', []),
|
|
2185
|
+
'line_item_types': exclusions_data.get('line_item_types', []),
|
|
2186
|
+
'usage_types': exclusions_data.get('usage_types', [])
|
|
2187
|
+
},
|
|
2188
|
+
timeout=10
|
|
2189
|
+
)
|
|
2190
|
+
|
|
2191
|
+
if update_response.status_code == 200:
|
|
2192
|
+
scope = profile if profile else "global"
|
|
2193
|
+
click.echo(f"✓ Exclusion added to {scope} config")
|
|
2194
|
+
if record_type:
|
|
2195
|
+
click.echo(f" Record Type: {record_type}")
|
|
2196
|
+
if service:
|
|
2197
|
+
click.echo(f" Service: {service}")
|
|
2198
|
+
if line_item_type:
|
|
2199
|
+
click.echo(f" Line Item Type: {line_item_type}")
|
|
2200
|
+
if usage_type:
|
|
2201
|
+
click.echo(f" Usage Type: {usage_type}")
|
|
2202
|
+
else:
|
|
2203
|
+
raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
|
|
2204
|
+
|
|
2205
|
+
except requests.exceptions.RequestException as e:
|
|
2206
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2207
|
+
|
|
2208
|
+
|
|
2209
|
+
@exclusions.command('remove')
|
|
2210
|
+
@click.option('--record-type', help='Remove record type exclusion')
|
|
2211
|
+
@click.option('--service', help='Remove service exclusion')
|
|
2212
|
+
@click.option('--line-item-type', help='Remove line item type exclusion')
|
|
2213
|
+
@click.option('--usage-type', help='Remove usage type exclusion')
|
|
2214
|
+
@click.option('--profile', help='Remove from profile-specific exclusions (default: global)')
|
|
2215
|
+
def remove_exclusion(record_type, service, line_item_type, usage_type, profile):
|
|
2216
|
+
"""Remove an exclusion"""
|
|
2217
|
+
import requests
|
|
2218
|
+
|
|
2219
|
+
if not any([record_type, service, line_item_type, usage_type]):
|
|
2220
|
+
raise click.ClickException(
|
|
2221
|
+
"Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
|
|
2222
|
+
)
|
|
2223
|
+
|
|
2224
|
+
api_secret = get_api_secret()
|
|
2225
|
+
if not api_secret:
|
|
2226
|
+
raise click.ClickException(
|
|
2227
|
+
"No API secret configured.\n"
|
|
2228
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2229
|
+
)
|
|
2230
|
+
|
|
2231
|
+
# Get current exclusions
|
|
2232
|
+
try:
|
|
2233
|
+
response = requests.post(
|
|
2234
|
+
PROFILES_API_URL,
|
|
2235
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2236
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2237
|
+
timeout=10
|
|
2238
|
+
)
|
|
2239
|
+
|
|
2240
|
+
if response.status_code == 200:
|
|
2241
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2242
|
+
else:
|
|
2243
|
+
raise click.ClickException("No exclusions found")
|
|
2244
|
+
|
|
2245
|
+
# Remove exclusions
|
|
2246
|
+
if record_type and record_type in exclusions_data.get('record_types', []):
|
|
2247
|
+
exclusions_data['record_types'].remove(record_type)
|
|
2248
|
+
|
|
2249
|
+
if service and service in exclusions_data.get('services', []):
|
|
2250
|
+
exclusions_data['services'].remove(service)
|
|
2251
|
+
|
|
2252
|
+
if line_item_type and line_item_type in exclusions_data.get('line_item_types', []):
|
|
2253
|
+
exclusions_data['line_item_types'].remove(line_item_type)
|
|
2254
|
+
|
|
2255
|
+
if usage_type and usage_type in exclusions_data.get('usage_types', []):
|
|
2256
|
+
exclusions_data['usage_types'].remove(usage_type)
|
|
2257
|
+
|
|
2258
|
+
# Update exclusions
|
|
2259
|
+
update_response = requests.post(
|
|
2260
|
+
PROFILES_API_URL,
|
|
2261
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2262
|
+
json={
|
|
2263
|
+
'operation': 'update_exclusions',
|
|
2264
|
+
'profile_name': profile,
|
|
2265
|
+
'record_types': exclusions_data.get('record_types', []),
|
|
2266
|
+
'services': exclusions_data.get('services', []),
|
|
2267
|
+
'line_item_types': exclusions_data.get('line_item_types', []),
|
|
2268
|
+
'usage_types': exclusions_data.get('usage_types', [])
|
|
2269
|
+
},
|
|
2270
|
+
timeout=10
|
|
2271
|
+
)
|
|
2272
|
+
|
|
2273
|
+
if update_response.status_code == 200:
|
|
2274
|
+
scope = profile if profile else "global"
|
|
2275
|
+
click.echo(f"✓ Exclusion removed from {scope} config")
|
|
2276
|
+
else:
|
|
2277
|
+
raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
|
|
2278
|
+
|
|
2279
|
+
except requests.exceptions.RequestException as e:
|
|
2280
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2281
|
+
|
|
2282
|
+
|
|
2016
2283
|
if __name__ == '__main__':
|
|
2017
2284
|
cli()
|