aws-cost-calculator-cli 1.5.2__py3-none-any.whl → 2.4.0__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.5.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/METADATA +169 -24
- aws_cost_calculator_cli-2.4.0.dist-info/RECORD +16 -0
- {aws_cost_calculator_cli-1.5.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/WHEEL +1 -1
- cost_calculator/api_client.py +18 -20
- cost_calculator/cli.py +1871 -242
- cost_calculator/cur.py +244 -0
- cost_calculator/dimensions.py +141 -0
- cost_calculator/executor.py +124 -101
- cost_calculator/forensics.py +323 -0
- aws_cost_calculator_cli-1.5.2.dist-info/RECORD +0 -13
- {aws_cost_calculator_cli-1.5.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.5.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/licenses/LICENSE +0 -0
- {aws_cost_calculator_cli-1.5.2.dist-info → aws_cost_calculator_cli-2.4.0.dist-info}/top_level.txt +0 -0
cost_calculator/cli.py
CHANGED
|
@@ -11,57 +11,267 @@ 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
|
|
17
19
|
from cost_calculator.monthly import format_monthly_markdown
|
|
18
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
|
+
)
|
|
19
32
|
from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
|
|
20
33
|
|
|
21
34
|
|
|
22
|
-
def
|
|
23
|
-
"""
|
|
35
|
+
def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=None, session_token=None):
|
|
36
|
+
"""Apply authentication options to profile config
|
|
37
|
+
|
|
38
|
+
Args:
|
|
39
|
+
config: Profile configuration dict
|
|
40
|
+
sso: AWS SSO profile name
|
|
41
|
+
access_key_id: AWS Access Key ID
|
|
42
|
+
secret_access_key: AWS Secret Access Key
|
|
43
|
+
session_token: AWS Session Token
|
|
44
|
+
|
|
45
|
+
Returns:
|
|
46
|
+
Updated config dict
|
|
47
|
+
"""
|
|
48
|
+
import subprocess
|
|
49
|
+
|
|
50
|
+
if sso:
|
|
51
|
+
# SSO authentication - trigger login if needed
|
|
52
|
+
try:
|
|
53
|
+
# Test if SSO session is valid
|
|
54
|
+
result = subprocess.run(
|
|
55
|
+
['aws', 'sts', 'get-caller-identity', '--profile', sso],
|
|
56
|
+
capture_output=True,
|
|
57
|
+
text=True,
|
|
58
|
+
timeout=5
|
|
59
|
+
)
|
|
60
|
+
if result.returncode != 0:
|
|
61
|
+
if 'expired' in result.stderr.lower() or 'token' in result.stderr.lower():
|
|
62
|
+
click.echo(f"SSO session expired or not initialized. Logging in...")
|
|
63
|
+
subprocess.run(['aws', 'sso', 'login', '--profile', sso], check=True)
|
|
64
|
+
except Exception as e:
|
|
65
|
+
click.echo(f"Warning: Could not verify SSO session: {e}")
|
|
66
|
+
|
|
67
|
+
config['aws_profile'] = sso
|
|
68
|
+
elif access_key_id and secret_access_key:
|
|
69
|
+
# Static credentials provided via CLI
|
|
70
|
+
config['credentials'] = {
|
|
71
|
+
'aws_access_key_id': access_key_id,
|
|
72
|
+
'aws_secret_access_key': secret_access_key,
|
|
73
|
+
'region': 'us-east-1'
|
|
74
|
+
}
|
|
75
|
+
if session_token:
|
|
76
|
+
config['credentials']['aws_session_token'] = session_token
|
|
77
|
+
|
|
78
|
+
return config
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def get_api_secret():
|
|
82
|
+
"""Get API secret from config file or environment variable"""
|
|
83
|
+
import os
|
|
84
|
+
|
|
85
|
+
# Check environment variable first
|
|
86
|
+
api_secret = os.environ.get('COST_API_SECRET')
|
|
87
|
+
if api_secret:
|
|
88
|
+
return api_secret
|
|
89
|
+
|
|
90
|
+
# Check config file
|
|
24
91
|
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
25
|
-
config_file = config_dir / '
|
|
26
|
-
|
|
92
|
+
config_file = config_dir / 'config.json'
|
|
93
|
+
|
|
94
|
+
if config_file.exists():
|
|
95
|
+
with open(config_file) as f:
|
|
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
|
|
27
105
|
|
|
28
|
-
|
|
106
|
+
api_secret = get_api_secret()
|
|
107
|
+
if not api_secret:
|
|
29
108
|
raise click.ClickException(
|
|
30
|
-
|
|
31
|
-
|
|
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
|
|
32
119
|
)
|
|
120
|
+
|
|
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
|
|
33
195
|
|
|
34
|
-
|
|
35
|
-
|
|
196
|
+
# Get API secret
|
|
197
|
+
api_secret = get_api_secret()
|
|
36
198
|
|
|
37
|
-
if
|
|
199
|
+
if not api_secret:
|
|
38
200
|
raise click.ClickException(
|
|
39
|
-
|
|
40
|
-
|
|
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"
|
|
41
204
|
)
|
|
42
205
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
f"Run: cc configure --profile {profile_name}"
|
|
51
|
-
)
|
|
52
|
-
|
|
53
|
-
with open(creds_file) as f:
|
|
54
|
-
creds = json.load(f)
|
|
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
|
+
)
|
|
55
213
|
|
|
56
|
-
if
|
|
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 = {
|
|
219
|
+
'accounts': profile_data['accounts'],
|
|
220
|
+
'profile_name': profile_name # Store profile name for API calls
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
# If profile has aws_profile field, use it
|
|
224
|
+
if 'aws_profile' in profile_data:
|
|
225
|
+
profile['aws_profile'] = profile_data['aws_profile']
|
|
226
|
+
# Check for AWS_PROFILE environment variable (SSO support)
|
|
227
|
+
elif os.environ.get('AWS_PROFILE'):
|
|
228
|
+
profile['aws_profile'] = os.environ['AWS_PROFILE']
|
|
229
|
+
# Use environment credentials
|
|
230
|
+
elif os.environ.get('AWS_ACCESS_KEY_ID'):
|
|
231
|
+
profile['credentials'] = {
|
|
232
|
+
'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
|
|
233
|
+
'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
234
|
+
'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
|
|
235
|
+
}
|
|
236
|
+
else:
|
|
237
|
+
# Try to find a matching AWS profile by name
|
|
238
|
+
# This allows "khoros" profile to work with "khoros_umbrella" AWS profile
|
|
239
|
+
import subprocess
|
|
240
|
+
try:
|
|
241
|
+
result = subprocess.run(
|
|
242
|
+
['aws', 'configure', 'list-profiles'],
|
|
243
|
+
capture_output=True,
|
|
244
|
+
text=True,
|
|
245
|
+
timeout=5
|
|
246
|
+
)
|
|
247
|
+
if result.returncode == 0:
|
|
248
|
+
available_profiles = result.stdout.strip().split('\n')
|
|
249
|
+
# Try exact match first
|
|
250
|
+
if profile_name in available_profiles:
|
|
251
|
+
profile['aws_profile'] = profile_name
|
|
252
|
+
# Try with common suffixes
|
|
253
|
+
elif f"{profile_name}_umbrella" in available_profiles:
|
|
254
|
+
profile['aws_profile'] = f"{profile_name}_umbrella"
|
|
255
|
+
elif f"{profile_name}-umbrella" in available_profiles:
|
|
256
|
+
profile['aws_profile'] = f"{profile_name}-umbrella"
|
|
257
|
+
elif f"{profile_name}_prod" in available_profiles:
|
|
258
|
+
profile['aws_profile'] = f"{profile_name}_prod"
|
|
259
|
+
# If no match found, leave it unset - user must provide --sso
|
|
260
|
+
except:
|
|
261
|
+
# If we can't list profiles, leave it unset - user must provide --sso
|
|
262
|
+
pass
|
|
263
|
+
|
|
264
|
+
return profile
|
|
265
|
+
else:
|
|
57
266
|
raise click.ClickException(
|
|
58
|
-
f"
|
|
59
|
-
f"Run: cc
|
|
267
|
+
f"Profile '{profile_name}' not found in DynamoDB.\n"
|
|
268
|
+
f"Run: cc profile create --name {profile_name} --accounts \"...\""
|
|
60
269
|
)
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
270
|
+
except requests.exceptions.RequestException as e:
|
|
271
|
+
raise click.ClickException(
|
|
272
|
+
f"Failed to fetch profile from API: {e}\n"
|
|
273
|
+
"Check your API secret and network connection."
|
|
274
|
+
)
|
|
65
275
|
|
|
66
276
|
|
|
67
277
|
def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
@@ -139,7 +349,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
139
349
|
)
|
|
140
350
|
raise
|
|
141
351
|
|
|
142
|
-
# Build filter
|
|
352
|
+
# Build filter with dynamic exclusions
|
|
353
|
+
exclusions = get_exclusions() # Get from DynamoDB
|
|
354
|
+
exclusion_filters = build_exclusion_filter(exclusions)
|
|
355
|
+
|
|
143
356
|
cost_filter = {
|
|
144
357
|
"And": [
|
|
145
358
|
{
|
|
@@ -153,18 +366,13 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
153
366
|
"Key": "BILLING_ENTITY",
|
|
154
367
|
"Values": ["AWS"]
|
|
155
368
|
}
|
|
156
|
-
},
|
|
157
|
-
{
|
|
158
|
-
"Not": {
|
|
159
|
-
"Dimensions": {
|
|
160
|
-
"Key": "RECORD_TYPE",
|
|
161
|
-
"Values": ["Tax", "Support"]
|
|
162
|
-
}
|
|
163
|
-
}
|
|
164
369
|
}
|
|
165
370
|
]
|
|
166
371
|
}
|
|
167
372
|
|
|
373
|
+
# Add dynamic exclusion filters
|
|
374
|
+
cost_filter["And"].extend(exclusion_filters)
|
|
375
|
+
|
|
168
376
|
# Get daily costs
|
|
169
377
|
click.echo("Fetching cost data...")
|
|
170
378
|
try:
|
|
@@ -229,9 +437,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
229
437
|
# Calculate days in the month that the support covers
|
|
230
438
|
# Support on Nov 1 covers October (31 days)
|
|
231
439
|
support_month = support_month_date - timedelta(days=1) # Go back to previous month
|
|
232
|
-
|
|
440
|
+
import calendar
|
|
441
|
+
days_in_support_month = calendar.monthrange(support_month.year, support_month.month)[1]
|
|
233
442
|
|
|
234
|
-
# Support allocation: divide by 2 (
|
|
443
|
+
# Support allocation: divide by 2 (50% allocation), then by days in month
|
|
235
444
|
support_per_day = (support_cost / 2) / days_in_support_month
|
|
236
445
|
|
|
237
446
|
# Calculate daily rate
|
|
@@ -290,18 +499,157 @@ def cli():
|
|
|
290
499
|
pass
|
|
291
500
|
|
|
292
501
|
|
|
502
|
+
@cli.command('setup-cur')
|
|
503
|
+
@click.option('--database', required=True, prompt='CUR Athena Database', help='Athena database name for CUR')
|
|
504
|
+
@click.option('--table', required=True, prompt='CUR Table Name', help='CUR table name')
|
|
505
|
+
@click.option('--s3-output', required=True, prompt='S3 Output Location', help='S3 bucket for Athena query results')
|
|
506
|
+
def setup_cur(database, table, s3_output):
|
|
507
|
+
"""
|
|
508
|
+
Configure CUR (Cost and Usage Report) settings for resource-level queries
|
|
509
|
+
|
|
510
|
+
Saves CUR configuration to ~/.config/cost-calculator/cur_config.json
|
|
511
|
+
|
|
512
|
+
Example:
|
|
513
|
+
cc setup-cur --database my_cur_db --table cur_table --s3-output s3://my-bucket/
|
|
514
|
+
"""
|
|
515
|
+
import json
|
|
516
|
+
|
|
517
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
518
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
519
|
+
|
|
520
|
+
config_file = config_dir / 'cur_config.json'
|
|
521
|
+
|
|
522
|
+
config = {
|
|
523
|
+
'database': database,
|
|
524
|
+
'table': table,
|
|
525
|
+
's3_output': s3_output
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
with open(config_file, 'w') as f:
|
|
529
|
+
json.dump(config, f, indent=2)
|
|
530
|
+
|
|
531
|
+
click.echo(f"✓ CUR configuration saved to {config_file}")
|
|
532
|
+
click.echo(f" Database: {database}")
|
|
533
|
+
click.echo(f" Table: {table}")
|
|
534
|
+
click.echo(f" S3 Output: {s3_output}")
|
|
535
|
+
click.echo("")
|
|
536
|
+
click.echo("You can now use: cc drill --service 'EC2 - Other' --resources")
|
|
537
|
+
|
|
538
|
+
|
|
539
|
+
@cli.command('setup-api')
|
|
540
|
+
@click.option('--api-secret', required=True, prompt=True, hide_input=True, help='COST_API_SECRET value')
|
|
541
|
+
def setup_api(api_secret):
|
|
542
|
+
"""
|
|
543
|
+
Configure COST_API_SECRET for backend API access
|
|
544
|
+
|
|
545
|
+
Saves the API secret to the appropriate location based on your OS:
|
|
546
|
+
- Mac/Linux: ~/.zshrc or ~/.bashrc
|
|
547
|
+
- Windows: User environment variables
|
|
548
|
+
|
|
549
|
+
Example:
|
|
550
|
+
cc setup-api --api-secret your-secret-here
|
|
551
|
+
|
|
552
|
+
Or let it prompt you (input will be hidden):
|
|
553
|
+
cc setup-api
|
|
554
|
+
"""
|
|
555
|
+
system = platform.system()
|
|
556
|
+
|
|
557
|
+
if system == "Windows":
|
|
558
|
+
# Windows: Set user environment variable
|
|
559
|
+
try:
|
|
560
|
+
import winreg
|
|
561
|
+
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_SET_VALUE)
|
|
562
|
+
winreg.SetValueEx(key, 'COST_API_SECRET', 0, winreg.REG_SZ, api_secret)
|
|
563
|
+
winreg.CloseKey(key)
|
|
564
|
+
click.echo("✓ COST_API_SECRET saved to Windows user environment variables")
|
|
565
|
+
click.echo(" Please restart your terminal for changes to take effect")
|
|
566
|
+
except Exception as e:
|
|
567
|
+
click.echo(f"✗ Error setting Windows environment variable: {e}", err=True)
|
|
568
|
+
click.echo("\nManual setup:")
|
|
569
|
+
click.echo("1. Open System Properties > Environment Variables")
|
|
570
|
+
click.echo("2. Add new User variable:")
|
|
571
|
+
click.echo(" Name: COST_API_SECRET")
|
|
572
|
+
click.echo(f" Value: {api_secret}")
|
|
573
|
+
return
|
|
574
|
+
else:
|
|
575
|
+
# Mac/Linux: Add to shell profile
|
|
576
|
+
shell = os.environ.get('SHELL', '/bin/bash')
|
|
577
|
+
|
|
578
|
+
if 'zsh' in shell:
|
|
579
|
+
profile_file = Path.home() / '.zshrc'
|
|
580
|
+
else:
|
|
581
|
+
profile_file = Path.home() / '.bashrc'
|
|
582
|
+
|
|
583
|
+
# Check if already exists
|
|
584
|
+
export_line = f'export COST_API_SECRET="{api_secret}"'
|
|
585
|
+
|
|
586
|
+
try:
|
|
587
|
+
if profile_file.exists():
|
|
588
|
+
content = profile_file.read_text()
|
|
589
|
+
if 'COST_API_SECRET' in content:
|
|
590
|
+
# Replace existing
|
|
591
|
+
lines = content.split('\n')
|
|
592
|
+
new_lines = []
|
|
593
|
+
for line in lines:
|
|
594
|
+
if 'COST_API_SECRET' in line and line.strip().startswith('export'):
|
|
595
|
+
new_lines.append(export_line)
|
|
596
|
+
else:
|
|
597
|
+
new_lines.append(line)
|
|
598
|
+
profile_file.write_text('\n'.join(new_lines))
|
|
599
|
+
click.echo(f"✓ Updated COST_API_SECRET in {profile_file}")
|
|
600
|
+
else:
|
|
601
|
+
# Append
|
|
602
|
+
with profile_file.open('a') as f:
|
|
603
|
+
f.write(f'\n# AWS Cost Calculator API Secret\n{export_line}\n')
|
|
604
|
+
click.echo(f"✓ Added COST_API_SECRET to {profile_file}")
|
|
605
|
+
else:
|
|
606
|
+
# Create new file
|
|
607
|
+
profile_file.write_text(f'# AWS Cost Calculator API Secret\n{export_line}\n')
|
|
608
|
+
click.echo(f"✓ Created {profile_file} with COST_API_SECRET")
|
|
609
|
+
|
|
610
|
+
# Also set for current session
|
|
611
|
+
os.environ['COST_API_SECRET'] = api_secret
|
|
612
|
+
click.echo(f"✓ Set COST_API_SECRET for current session")
|
|
613
|
+
click.echo(f"\nTo use in new terminals, run: source {profile_file}")
|
|
614
|
+
|
|
615
|
+
except Exception as e:
|
|
616
|
+
click.echo(f"✗ Error writing to {profile_file}: {e}", err=True)
|
|
617
|
+
click.echo(f"\nManual setup: Add this line to {profile_file}:")
|
|
618
|
+
click.echo(f" {export_line}")
|
|
619
|
+
return
|
|
620
|
+
|
|
621
|
+
|
|
293
622
|
@cli.command()
|
|
294
623
|
@click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
|
|
295
624
|
@click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
|
|
296
625
|
@click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
|
|
297
626
|
@click.option('--window', default=30, help='Number of days to analyze (default: 30)')
|
|
298
627
|
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
299
|
-
|
|
300
|
-
|
|
628
|
+
@click.option('--sso', help='AWS SSO profile name (e.g., my_sso_profile)')
|
|
629
|
+
@click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
|
|
630
|
+
@click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
|
|
631
|
+
@click.option('--session-token', help='AWS Session Token (for static credentials)')
|
|
632
|
+
def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
633
|
+
"""
|
|
634
|
+
Calculate AWS costs for the specified period
|
|
635
|
+
|
|
636
|
+
\b
|
|
637
|
+
Authentication Options:
|
|
638
|
+
1. SSO: --sso <profile_name>
|
|
639
|
+
Example: cc calculate --profile myprofile --sso my_sso_profile
|
|
640
|
+
|
|
641
|
+
2. Static Credentials: --access-key-id, --secret-access-key, --session-token
|
|
642
|
+
Example: cc calculate --profile myprofile --access-key-id ASIA... --secret-access-key ... --session-token ...
|
|
643
|
+
|
|
644
|
+
3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
|
|
645
|
+
"""
|
|
301
646
|
|
|
302
647
|
# Load profile configuration
|
|
303
648
|
config = load_profile(profile)
|
|
304
649
|
|
|
650
|
+
# Apply authentication options
|
|
651
|
+
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
652
|
+
|
|
305
653
|
# Calculate costs
|
|
306
654
|
result = calculate_costs(
|
|
307
655
|
profile_config=config,
|
|
@@ -329,230 +677,155 @@ def calculate(profile, start_date, offset, window, json_output):
|
|
|
329
677
|
click.echo("=" * 60)
|
|
330
678
|
|
|
331
679
|
|
|
332
|
-
|
|
333
|
-
@click.option('--profile', required=True, help='Profile name to create')
|
|
334
|
-
@click.option('--aws-profile', required=True, help='AWS CLI profile name')
|
|
335
|
-
@click.option('--accounts', required=True, help='Comma-separated list of account IDs')
|
|
336
|
-
def init(profile, aws_profile, accounts):
|
|
337
|
-
"""Initialize a new profile configuration"""
|
|
338
|
-
|
|
339
|
-
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
340
|
-
config_file = config_dir / 'profiles.json'
|
|
341
|
-
|
|
342
|
-
# Create config directory if it doesn't exist
|
|
343
|
-
config_dir.mkdir(parents=True, exist_ok=True)
|
|
344
|
-
|
|
345
|
-
# Load existing profiles or create new
|
|
346
|
-
if config_file.exists() and config_file.stat().st_size > 0:
|
|
347
|
-
try:
|
|
348
|
-
with open(config_file) as f:
|
|
349
|
-
profiles = json.load(f)
|
|
350
|
-
except json.JSONDecodeError:
|
|
351
|
-
profiles = {}
|
|
352
|
-
else:
|
|
353
|
-
profiles = {}
|
|
354
|
-
|
|
355
|
-
# Parse accounts
|
|
356
|
-
account_list = [acc.strip() for acc in accounts.split(',')]
|
|
357
|
-
|
|
358
|
-
# Add new profile
|
|
359
|
-
profiles[profile] = {
|
|
360
|
-
'aws_profile': aws_profile,
|
|
361
|
-
'accounts': account_list
|
|
362
|
-
}
|
|
363
|
-
|
|
364
|
-
# Save
|
|
365
|
-
with open(config_file, 'w') as f:
|
|
366
|
-
json.dump(profiles, f, indent=2)
|
|
367
|
-
|
|
368
|
-
click.echo(f"✓ Profile '{profile}' created with {len(account_list)} accounts")
|
|
369
|
-
click.echo(f"✓ Configuration saved to {config_file}")
|
|
370
|
-
click.echo(f"\nUsage: cc calculate --profile {profile}")
|
|
680
|
+
# init command removed - use backend API via 'cc profile create' instead
|
|
371
681
|
|
|
372
682
|
|
|
373
683
|
@cli.command()
|
|
374
684
|
def list_profiles():
|
|
375
|
-
"""List all
|
|
376
|
-
|
|
377
|
-
config_file = Path.home() / '.config' / 'cost-calculator' / 'profiles.json'
|
|
378
|
-
|
|
379
|
-
if not config_file.exists():
|
|
380
|
-
click.echo("No profiles configured. Run: cc init --profile <name>")
|
|
381
|
-
return
|
|
382
|
-
|
|
383
|
-
with open(config_file) as f:
|
|
384
|
-
profiles = json.load(f)
|
|
685
|
+
"""List all profiles from backend API (no local caching)"""
|
|
686
|
+
import requests
|
|
385
687
|
|
|
386
|
-
|
|
387
|
-
|
|
688
|
+
api_secret = get_api_secret()
|
|
689
|
+
if not api_secret:
|
|
690
|
+
click.echo("No API secret configured.")
|
|
691
|
+
click.echo("Run: cc configure --api-secret YOUR_SECRET")
|
|
388
692
|
return
|
|
389
693
|
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
694
|
+
try:
|
|
695
|
+
response = requests.post(
|
|
696
|
+
f"{API_BASE_URL}/profiles",
|
|
697
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
698
|
+
json={'operation': 'list'},
|
|
699
|
+
timeout=10
|
|
700
|
+
)
|
|
701
|
+
|
|
702
|
+
if response.status_code != 200:
|
|
703
|
+
click.echo(f"Error: {response.status_code} - {response.text}")
|
|
704
|
+
return
|
|
705
|
+
|
|
706
|
+
result = response.json()
|
|
707
|
+
profiles = result.get('profiles', [])
|
|
708
|
+
|
|
709
|
+
if not profiles:
|
|
710
|
+
click.echo("No profiles found in backend.")
|
|
711
|
+
click.echo("Contact admin to create profiles in DynamoDB.")
|
|
712
|
+
return
|
|
713
|
+
|
|
714
|
+
click.echo("Profiles (from backend API):")
|
|
399
715
|
click.echo("")
|
|
716
|
+
for profile in profiles:
|
|
717
|
+
# Each profile is a dict, extract the profile_name
|
|
718
|
+
if isinstance(profile, dict):
|
|
719
|
+
name = profile.get('profile_name', 'unknown')
|
|
720
|
+
# Skip exclusions entries
|
|
721
|
+
if not name.startswith('exclusions:'):
|
|
722
|
+
accounts = profile.get('accounts', [])
|
|
723
|
+
click.echo(f" {name}")
|
|
724
|
+
click.echo(f" Accounts: {len(accounts)}")
|
|
725
|
+
click.echo("")
|
|
726
|
+
else:
|
|
727
|
+
click.echo(f" {profile}")
|
|
728
|
+
click.echo(f"Total: {len([p for p in profiles if isinstance(p, dict) and not p.get('profile_name', '').startswith('exclusions:')])} profile(s)")
|
|
729
|
+
|
|
730
|
+
except Exception as e:
|
|
731
|
+
click.echo(f"Error loading profiles: {e}")
|
|
400
732
|
|
|
401
733
|
|
|
402
|
-
|
|
403
|
-
def setup():
|
|
404
|
-
"""Show setup instructions for manual profile configuration"""
|
|
405
|
-
import platform
|
|
406
|
-
|
|
407
|
-
system = platform.system()
|
|
408
|
-
|
|
409
|
-
if system == "Windows":
|
|
410
|
-
config_path = "%USERPROFILE%\\.config\\cost-calculator\\profiles.json"
|
|
411
|
-
config_path_example = "C:\\Users\\YourName\\.config\\cost-calculator\\profiles.json"
|
|
412
|
-
mkdir_cmd = "mkdir %USERPROFILE%\\.config\\cost-calculator"
|
|
413
|
-
edit_cmd = "notepad %USERPROFILE%\\.config\\cost-calculator\\profiles.json"
|
|
414
|
-
else: # macOS/Linux
|
|
415
|
-
config_path = "~/.config/cost-calculator/profiles.json"
|
|
416
|
-
config_path_example = "/Users/yourname/.config/cost-calculator/profiles.json"
|
|
417
|
-
mkdir_cmd = "mkdir -p ~/.config/cost-calculator"
|
|
418
|
-
edit_cmd = "nano ~/.config/cost-calculator/profiles.json"
|
|
419
|
-
|
|
420
|
-
click.echo("=" * 70)
|
|
421
|
-
click.echo("AWS Cost Calculator - Manual Profile Setup")
|
|
422
|
-
click.echo("=" * 70)
|
|
423
|
-
click.echo("")
|
|
424
|
-
click.echo(f"Platform: {system}")
|
|
425
|
-
click.echo(f"Config location: {config_path}")
|
|
426
|
-
click.echo("")
|
|
427
|
-
click.echo("Step 1: Create the config directory")
|
|
428
|
-
click.echo(f" {mkdir_cmd}")
|
|
429
|
-
click.echo("")
|
|
430
|
-
click.echo("Step 2: Create the profiles.json file")
|
|
431
|
-
click.echo(f" {edit_cmd}")
|
|
432
|
-
click.echo("")
|
|
433
|
-
click.echo("Step 3: Add your profile configuration (JSON format):")
|
|
434
|
-
click.echo("")
|
|
435
|
-
click.echo(' {')
|
|
436
|
-
click.echo(' "myprofile": {')
|
|
437
|
-
click.echo(' "aws_profile": "my_aws_profile",')
|
|
438
|
-
click.echo(' "accounts": [')
|
|
439
|
-
click.echo(' "123456789012",')
|
|
440
|
-
click.echo(' "234567890123",')
|
|
441
|
-
click.echo(' "345678901234"')
|
|
442
|
-
click.echo(' ]')
|
|
443
|
-
click.echo(' }')
|
|
444
|
-
click.echo(' }')
|
|
445
|
-
click.echo("")
|
|
446
|
-
click.echo("Step 4: Save the file")
|
|
447
|
-
click.echo("")
|
|
448
|
-
click.echo("Step 5: Verify it works")
|
|
449
|
-
click.echo(" cc list-profiles")
|
|
450
|
-
click.echo("")
|
|
451
|
-
click.echo("Step 6: Configure AWS credentials")
|
|
452
|
-
click.echo(" Option A (SSO):")
|
|
453
|
-
click.echo(" aws sso login --profile my_aws_profile")
|
|
454
|
-
click.echo(" cc calculate --profile myprofile")
|
|
455
|
-
click.echo("")
|
|
456
|
-
click.echo(" Option B (Static credentials):")
|
|
457
|
-
click.echo(" cc configure --profile myprofile")
|
|
458
|
-
click.echo(" cc calculate --profile myprofile")
|
|
459
|
-
click.echo("")
|
|
460
|
-
click.echo("=" * 70)
|
|
461
|
-
click.echo("")
|
|
462
|
-
click.echo("For multiple profiles, add more entries to the JSON:")
|
|
463
|
-
click.echo("")
|
|
464
|
-
click.echo(' {')
|
|
465
|
-
click.echo(' "profile1": { ... },')
|
|
466
|
-
click.echo(' "profile2": { ... }')
|
|
467
|
-
click.echo(' }')
|
|
468
|
-
click.echo("")
|
|
469
|
-
click.echo(f"Full path example: {config_path_example}")
|
|
470
|
-
click.echo("=" * 70)
|
|
734
|
+
# setup command removed - profiles are managed in DynamoDB backend only
|
|
471
735
|
|
|
472
736
|
|
|
473
737
|
@cli.command()
|
|
474
|
-
@click.option('--
|
|
475
|
-
@click.option('--
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
def configure(profile, access_key_id, secret_access_key, session_token, region):
|
|
480
|
-
"""Configure AWS credentials for a profile (alternative to SSO)"""
|
|
738
|
+
@click.option('--api-secret', help='API secret for DynamoDB profile access')
|
|
739
|
+
@click.option('--show', is_flag=True, help='Show current configuration')
|
|
740
|
+
def configure(api_secret, show):
|
|
741
|
+
"""
|
|
742
|
+
Configure Cost Calculator CLI settings.
|
|
481
743
|
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
creds_file = config_dir / 'credentials.json'
|
|
744
|
+
This tool requires an API secret to access profiles stored in DynamoDB.
|
|
745
|
+
The secret can be configured here or set via COST_API_SECRET environment variable.
|
|
485
746
|
|
|
486
|
-
|
|
747
|
+
Examples:
|
|
748
|
+
# Configure API secret
|
|
749
|
+
cc configure --api-secret YOUR_SECRET_KEY
|
|
750
|
+
|
|
751
|
+
# Show current configuration
|
|
752
|
+
cc configure --show
|
|
753
|
+
|
|
754
|
+
# Use environment variable instead (no configuration needed)
|
|
755
|
+
export COST_API_SECRET=YOUR_SECRET_KEY
|
|
756
|
+
"""
|
|
757
|
+
import os
|
|
758
|
+
|
|
759
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
487
760
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
761
|
+
config_file = config_dir / 'config.json'
|
|
488
762
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
763
|
+
if show:
|
|
764
|
+
# Show current configuration
|
|
765
|
+
if config_file.exists():
|
|
492
766
|
with open(config_file) as f:
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
767
|
+
config = json.load(f)
|
|
768
|
+
if 'api_secret' in config:
|
|
769
|
+
masked_secret = config['api_secret'][:8] + '...' + config['api_secret'][-4:]
|
|
770
|
+
click.echo(f"API Secret: {masked_secret} (configured)")
|
|
771
|
+
else:
|
|
772
|
+
click.echo("API Secret: Not configured")
|
|
773
|
+
else:
|
|
774
|
+
click.echo("No configuration file found")
|
|
775
|
+
|
|
776
|
+
# Check environment variable
|
|
777
|
+
import os
|
|
778
|
+
if os.environ.get('COST_API_SECRET'):
|
|
779
|
+
click.echo("Environment: COST_API_SECRET is set")
|
|
780
|
+
else:
|
|
781
|
+
click.echo("Environment: COST_API_SECRET is not set")
|
|
782
|
+
|
|
502
783
|
return
|
|
503
784
|
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
with open(config_file, 'w') as f:
|
|
510
|
-
json.dump(profiles, f, indent=2)
|
|
511
|
-
|
|
512
|
-
# Load or create credentials file
|
|
513
|
-
if creds_file.exists() and creds_file.stat().st_size > 0:
|
|
514
|
-
try:
|
|
515
|
-
with open(creds_file) as f:
|
|
516
|
-
creds = json.load(f)
|
|
517
|
-
except json.JSONDecodeError:
|
|
518
|
-
creds = {}
|
|
519
|
-
else:
|
|
520
|
-
creds = {}
|
|
785
|
+
if not api_secret:
|
|
786
|
+
raise click.ClickException(
|
|
787
|
+
"Please provide --api-secret or use --show to view current configuration\n"
|
|
788
|
+
"Example: cc configure --api-secret YOUR_SECRET_KEY"
|
|
789
|
+
)
|
|
521
790
|
|
|
522
|
-
#
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
}
|
|
791
|
+
# Load existing config
|
|
792
|
+
config = {}
|
|
793
|
+
if config_file.exists():
|
|
794
|
+
with open(config_file) as f:
|
|
795
|
+
config = json.load(f)
|
|
528
796
|
|
|
529
|
-
|
|
530
|
-
|
|
797
|
+
# Update API secret
|
|
798
|
+
config['api_secret'] = api_secret
|
|
531
799
|
|
|
532
|
-
# Save
|
|
533
|
-
with open(
|
|
534
|
-
json.dump(
|
|
800
|
+
# Save config
|
|
801
|
+
with open(config_file, 'w') as f:
|
|
802
|
+
json.dump(config, f, indent=2)
|
|
535
803
|
|
|
536
|
-
# Set
|
|
537
|
-
|
|
804
|
+
# Set restrictive permissions (Unix/Mac only - Windows uses different permission model)
|
|
805
|
+
import platform
|
|
806
|
+
if platform.system() != 'Windows':
|
|
807
|
+
os.chmod(config_file, 0o600)
|
|
538
808
|
|
|
539
|
-
|
|
540
|
-
click.echo(f"✓
|
|
541
|
-
click.echo(f"\
|
|
542
|
-
click.echo("\nNote:
|
|
543
|
-
click.echo(" you'll need to reconfigure when they expire.")
|
|
544
|
-
|
|
809
|
+
masked_secret = api_secret[:8] + '...' + api_secret[-4:]
|
|
810
|
+
click.echo(f"✓ API secret configured: {masked_secret}")
|
|
811
|
+
click.echo(f"\nYou can now run: cc calculate --profile PROFILE_NAME")
|
|
812
|
+
click.echo(f"\nNote: Profiles are stored in DynamoDB and accessed via the API.")
|
|
545
813
|
|
|
546
814
|
@cli.command()
|
|
547
815
|
@click.option('--profile', required=True, help='Profile name')
|
|
548
816
|
@click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
|
|
549
817
|
@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
|
-
|
|
818
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
819
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
820
|
+
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
821
|
+
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
822
|
+
@click.option('--session-token', help='AWS Session Token')
|
|
823
|
+
def trends(profile, weeks, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
552
824
|
"""Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
|
|
553
825
|
|
|
554
826
|
# Load profile configuration
|
|
555
827
|
config = load_profile(profile)
|
|
828
|
+
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
556
829
|
|
|
557
830
|
click.echo(f"Analyzing last {weeks} weeks...")
|
|
558
831
|
click.echo("")
|
|
@@ -613,12 +886,17 @@ def trends(profile, weeks, output, json_output):
|
|
|
613
886
|
@click.option('--profile', required=True, help='Profile name')
|
|
614
887
|
@click.option('--months', default=6, help='Number of months to analyze (default: 6)')
|
|
615
888
|
@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
|
-
|
|
889
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
890
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
891
|
+
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
892
|
+
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
893
|
+
@click.option('--session-token', help='AWS Session Token')
|
|
894
|
+
def monthly(profile, months, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
618
895
|
"""Analyze month-over-month cost trends at service level"""
|
|
619
896
|
|
|
620
|
-
# Load profile
|
|
897
|
+
# Load profile
|
|
621
898
|
config = load_profile(profile)
|
|
899
|
+
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
622
900
|
|
|
623
901
|
click.echo(f"Analyzing last {months} months...")
|
|
624
902
|
click.echo("")
|
|
@@ -679,26 +957,80 @@ def monthly(profile, months, output, json_output):
|
|
|
679
957
|
@click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
|
|
680
958
|
@click.option('--account', help='Filter by account ID')
|
|
681
959
|
@click.option('--usage-type', help='Filter by usage type')
|
|
960
|
+
@click.option('--dimension', type=click.Choice(['service', 'account', 'region', 'usage_type', 'resource', 'instance_type', 'operation', 'availability_zone']),
|
|
961
|
+
help='Dimension to analyze by (overrides --service/--account filters)')
|
|
962
|
+
@click.option('--backend', type=click.Choice(['auto', 'ce', 'athena']), default='auto',
|
|
963
|
+
help='Data source: auto (smart selection), ce (Cost Explorer), athena (CUR). Default: auto')
|
|
964
|
+
@click.option('--resources', is_flag=True, help='Show individual resource IDs (requires CUR, uses Athena)')
|
|
682
965
|
@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
|
-
|
|
685
|
-
|
|
966
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
967
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
968
|
+
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
969
|
+
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
970
|
+
@click.option('--session-token', help='AWS Session Token')
|
|
971
|
+
def drill(profile, weeks, service, account, usage_type, dimension, backend, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
972
|
+
"""
|
|
973
|
+
Drill down into cost changes by service, account, or usage type
|
|
686
974
|
|
|
687
|
-
|
|
975
|
+
Add --resources flag to see individual resource IDs and costs (requires CUR data via Athena)
|
|
976
|
+
|
|
977
|
+
Examples:
|
|
978
|
+
# Analyze by service (auto-selects Cost Explorer for speed)
|
|
979
|
+
cc drill --profile khoros --dimension service
|
|
980
|
+
|
|
981
|
+
# Analyze by region with specific service filter
|
|
982
|
+
cc drill --profile khoros --dimension region --service AWSELB
|
|
983
|
+
|
|
984
|
+
# Analyze by resource (auto-selects Athena - only source with resource IDs)
|
|
985
|
+
cc drill --profile khoros --dimension resource --service AWSELB --account 820054669588
|
|
986
|
+
|
|
987
|
+
# Force Athena backend for detailed analysis
|
|
988
|
+
cc drill --profile khoros --dimension service --backend athena
|
|
989
|
+
"""
|
|
990
|
+
|
|
991
|
+
# Load profile
|
|
688
992
|
config = load_profile(profile)
|
|
993
|
+
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
994
|
+
|
|
995
|
+
# Smart backend selection
|
|
996
|
+
def select_backend(dimension, resources_flag, backend_choice):
|
|
997
|
+
"""Auto-select backend based on query requirements"""
|
|
998
|
+
if backend_choice != 'auto':
|
|
999
|
+
return backend_choice
|
|
1000
|
+
|
|
1001
|
+
# Must use Athena if:
|
|
1002
|
+
if dimension == 'resource' or resources_flag:
|
|
1003
|
+
return 'athena'
|
|
1004
|
+
|
|
1005
|
+
# Prefer CE for speed (unless explicitly requesting Athena)
|
|
1006
|
+
return 'ce'
|
|
1007
|
+
|
|
1008
|
+
selected_backend = select_backend(dimension, resources, backend)
|
|
689
1009
|
|
|
690
1010
|
# Show filters
|
|
691
1011
|
click.echo(f"Analyzing last {weeks} weeks...")
|
|
1012
|
+
if dimension:
|
|
1013
|
+
click.echo(f" Dimension: {dimension}")
|
|
692
1014
|
if service:
|
|
693
1015
|
click.echo(f" Service filter: {service}")
|
|
694
1016
|
if account:
|
|
695
1017
|
click.echo(f" Account filter: {account}")
|
|
696
1018
|
if usage_type:
|
|
697
1019
|
click.echo(f" Usage type filter: {usage_type}")
|
|
1020
|
+
if resources:
|
|
1021
|
+
click.echo(f" Mode: Resource-level (CUR via Athena)")
|
|
1022
|
+
click.echo(f" Backend: {selected_backend.upper()}")
|
|
698
1023
|
click.echo("")
|
|
699
1024
|
|
|
700
1025
|
# Execute via API or locally
|
|
701
|
-
drill_data = execute_drill(config, weeks, service, account, usage_type)
|
|
1026
|
+
drill_data = execute_drill(config, weeks, service, account, usage_type, resources, dimension, selected_backend)
|
|
1027
|
+
|
|
1028
|
+
# Handle resource-level output differently
|
|
1029
|
+
if resources:
|
|
1030
|
+
from cost_calculator.cur import format_resource_output
|
|
1031
|
+
output_text = format_resource_output(drill_data)
|
|
1032
|
+
click.echo(output_text)
|
|
1033
|
+
return
|
|
702
1034
|
|
|
703
1035
|
if json_output:
|
|
704
1036
|
# Output as JSON
|
|
@@ -871,5 +1203,1302 @@ def profile(operation, name, accounts, description):
|
|
|
871
1203
|
click.echo(result.get('message', 'Operation completed'))
|
|
872
1204
|
|
|
873
1205
|
|
|
1206
|
+
@cli.command()
|
|
1207
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1208
|
+
@click.option('--sso', help='AWS SSO profile to use')
|
|
1209
|
+
@click.option('--weeks', default=8, help='Number of weeks to analyze')
|
|
1210
|
+
@click.option('--account', help='Focus on specific account ID')
|
|
1211
|
+
@click.option('--service', help='Focus on specific service')
|
|
1212
|
+
@click.option('--no-cloudtrail', is_flag=True, help='Skip CloudTrail analysis (faster)')
|
|
1213
|
+
@click.option('--output', default='investigation_report.md', help='Output file path')
|
|
1214
|
+
def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
|
|
1215
|
+
"""
|
|
1216
|
+
Multi-stage cost investigation:
|
|
1217
|
+
1. Analyze cost trends and drill-downs
|
|
1218
|
+
2. Inventory actual resources in problem accounts
|
|
1219
|
+
3. Analyze CloudTrail events (optional)
|
|
1220
|
+
4. Generate comprehensive report
|
|
1221
|
+
"""
|
|
1222
|
+
from cost_calculator.executor import execute_trends, execute_drill, get_credentials_dict
|
|
1223
|
+
from cost_calculator.api_client import call_lambda_api, is_api_configured
|
|
1224
|
+
from cost_calculator.forensics import format_investigation_report
|
|
1225
|
+
from datetime import datetime, timedelta
|
|
1226
|
+
|
|
1227
|
+
click.echo("=" * 80)
|
|
1228
|
+
click.echo("COST INVESTIGATION")
|
|
1229
|
+
click.echo("=" * 80)
|
|
1230
|
+
click.echo(f"Profile: {profile}")
|
|
1231
|
+
click.echo(f"Weeks: {weeks}")
|
|
1232
|
+
if account:
|
|
1233
|
+
click.echo(f"Account: {account}")
|
|
1234
|
+
if service:
|
|
1235
|
+
click.echo(f"Service: {service}")
|
|
1236
|
+
click.echo("")
|
|
1237
|
+
|
|
1238
|
+
# Load profile
|
|
1239
|
+
config = load_profile(profile)
|
|
1240
|
+
|
|
1241
|
+
# Override with SSO if provided
|
|
1242
|
+
if sso:
|
|
1243
|
+
config['aws_profile'] = sso
|
|
1244
|
+
|
|
1245
|
+
# Validate that we have a way to get credentials
|
|
1246
|
+
if 'aws_profile' not in config and 'credentials' not in config:
|
|
1247
|
+
import subprocess
|
|
1248
|
+
try:
|
|
1249
|
+
result = subprocess.run(
|
|
1250
|
+
['aws', 'configure', 'list-profiles'],
|
|
1251
|
+
capture_output=True,
|
|
1252
|
+
text=True,
|
|
1253
|
+
timeout=5
|
|
1254
|
+
)
|
|
1255
|
+
available = result.stdout.strip().split('\n') if result.returncode == 0 else []
|
|
1256
|
+
suggestion = f"\nAvailable AWS profiles: {', '.join(available[:5])}" if available else ""
|
|
1257
|
+
except:
|
|
1258
|
+
suggestion = ""
|
|
1259
|
+
|
|
1260
|
+
raise click.ClickException(
|
|
1261
|
+
f"Profile '{profile}' has no AWS authentication configured.\n"
|
|
1262
|
+
f"Use --sso flag to specify your AWS SSO profile:\n"
|
|
1263
|
+
f" cc investigate --profile {profile} --sso YOUR_AWS_PROFILE{suggestion}"
|
|
1264
|
+
)
|
|
1265
|
+
|
|
1266
|
+
# Step 1: Cost Analysis
|
|
1267
|
+
click.echo("Step 1/3: Analyzing cost trends...")
|
|
1268
|
+
try:
|
|
1269
|
+
trends_data = execute_trends(config, weeks)
|
|
1270
|
+
click.echo(f"✓ Found cost data for {weeks} weeks")
|
|
1271
|
+
except Exception as e:
|
|
1272
|
+
click.echo(f"✗ Error analyzing trends: {str(e)}")
|
|
1273
|
+
trends_data = None
|
|
1274
|
+
|
|
1275
|
+
# Step 2: Drill-down
|
|
1276
|
+
click.echo("\nStep 2/3: Drilling down into costs...")
|
|
1277
|
+
drill_data = None
|
|
1278
|
+
if service or account:
|
|
1279
|
+
try:
|
|
1280
|
+
drill_data = execute_drill(config, weeks, service, account, None, False)
|
|
1281
|
+
click.echo(f"✓ Drill-down complete")
|
|
1282
|
+
except Exception as e:
|
|
1283
|
+
click.echo(f"✗ Error in drill-down: {str(e)}")
|
|
1284
|
+
|
|
1285
|
+
# Step 3: Resource Inventory
|
|
1286
|
+
click.echo("\nStep 3/3: Inventorying resources...")
|
|
1287
|
+
inventories = []
|
|
1288
|
+
cloudtrail_analyses = []
|
|
1289
|
+
|
|
1290
|
+
# Determine which accounts to investigate
|
|
1291
|
+
accounts_to_investigate = []
|
|
1292
|
+
if account:
|
|
1293
|
+
accounts_to_investigate = [account]
|
|
1294
|
+
else:
|
|
1295
|
+
# Extract top cost accounts from trends/drill data
|
|
1296
|
+
# For now, we'll need the user to specify
|
|
1297
|
+
click.echo("⚠️ No account specified. Use --account to inventory resources.")
|
|
1298
|
+
|
|
1299
|
+
# For each account, do inventory and CloudTrail via backend API
|
|
1300
|
+
for acc_id in accounts_to_investigate:
|
|
1301
|
+
click.echo(f"\n Investigating account {acc_id}...")
|
|
1302
|
+
|
|
1303
|
+
# Get credentials (SSO or static)
|
|
1304
|
+
account_creds = get_credentials_dict(config)
|
|
1305
|
+
if not account_creds:
|
|
1306
|
+
click.echo(f" ⚠️ No credentials available for account")
|
|
1307
|
+
continue
|
|
1308
|
+
|
|
1309
|
+
# Inventory resources via backend API only
|
|
1310
|
+
if not is_api_configured():
|
|
1311
|
+
click.echo(f" ✗ API not configured. Set COST_API_SECRET environment variable.")
|
|
1312
|
+
continue
|
|
1313
|
+
|
|
1314
|
+
try:
|
|
1315
|
+
regions = ['us-west-2', 'us-east-1', 'eu-west-1']
|
|
1316
|
+
for region in regions:
|
|
1317
|
+
try:
|
|
1318
|
+
inv = call_lambda_api(
|
|
1319
|
+
'forensics',
|
|
1320
|
+
account_creds,
|
|
1321
|
+
[], # accounts not needed for forensics
|
|
1322
|
+
operation='inventory',
|
|
1323
|
+
account_id=acc_id,
|
|
1324
|
+
region=region
|
|
1325
|
+
)
|
|
1326
|
+
|
|
1327
|
+
if not inv.get('error'):
|
|
1328
|
+
inventories.append(inv)
|
|
1329
|
+
click.echo(f" ✓ Inventory complete for {region}")
|
|
1330
|
+
click.echo(f" - EC2: {len(inv['ec2_instances'])} instances")
|
|
1331
|
+
click.echo(f" - EFS: {len(inv['efs_file_systems'])} file systems ({inv.get('total_efs_size_gb', 0):,.0f} GB)")
|
|
1332
|
+
click.echo(f" - ELB: {len(inv['load_balancers'])} load balancers")
|
|
1333
|
+
break
|
|
1334
|
+
except Exception as e:
|
|
1335
|
+
continue
|
|
1336
|
+
except Exception as e:
|
|
1337
|
+
click.echo(f" ✗ Inventory error: {str(e)}")
|
|
1338
|
+
|
|
1339
|
+
# CloudTrail analysis via backend API only
|
|
1340
|
+
if not no_cloudtrail:
|
|
1341
|
+
if not is_api_configured():
|
|
1342
|
+
click.echo(f" ✗ CloudTrail skipped: API not configured")
|
|
1343
|
+
else:
|
|
1344
|
+
try:
|
|
1345
|
+
start_date = (datetime.now() - timedelta(days=weeks * 7)).isoformat() + 'Z'
|
|
1346
|
+
end_date = datetime.now().isoformat() + 'Z'
|
|
1347
|
+
|
|
1348
|
+
ct_analysis = call_lambda_api(
|
|
1349
|
+
'forensics',
|
|
1350
|
+
account_creds,
|
|
1351
|
+
[],
|
|
1352
|
+
operation='cloudtrail',
|
|
1353
|
+
account_id=acc_id,
|
|
1354
|
+
start_date=start_date,
|
|
1355
|
+
end_date=end_date,
|
|
1356
|
+
region='us-west-2'
|
|
1357
|
+
)
|
|
1358
|
+
|
|
1359
|
+
cloudtrail_analyses.append(ct_analysis)
|
|
1360
|
+
|
|
1361
|
+
if ct_analysis.get('error'):
|
|
1362
|
+
click.echo(f" ⚠️ CloudTrail: {ct_analysis['error']}")
|
|
1363
|
+
else:
|
|
1364
|
+
click.echo(f" ✓ CloudTrail analysis complete")
|
|
1365
|
+
click.echo(f" - {len(ct_analysis['event_summary'])} event types")
|
|
1366
|
+
click.echo(f" - {len(ct_analysis['write_events'])} resource changes")
|
|
1367
|
+
except Exception as e:
|
|
1368
|
+
click.echo(f" ✗ CloudTrail error: {str(e)}")
|
|
1369
|
+
|
|
1370
|
+
# Generate report
|
|
1371
|
+
click.echo(f"\nGenerating report...")
|
|
1372
|
+
report = format_investigation_report(trends_data, inventories, cloudtrail_analyses if not no_cloudtrail else None)
|
|
1373
|
+
|
|
1374
|
+
# Write to file
|
|
1375
|
+
with open(output, 'w') as f:
|
|
1376
|
+
f.write(report)
|
|
1377
|
+
|
|
1378
|
+
click.echo(f"\n✓ Investigation complete!")
|
|
1379
|
+
click.echo(f"✓ Report saved to: {output}")
|
|
1380
|
+
click.echo("")
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
def find_account_profile(account_id):
|
|
1384
|
+
"""
|
|
1385
|
+
Find the SSO profile name for a given account ID
|
|
1386
|
+
Returns profile name or None
|
|
1387
|
+
"""
|
|
1388
|
+
import subprocess
|
|
1389
|
+
|
|
1390
|
+
try:
|
|
1391
|
+
# Get list of profiles
|
|
1392
|
+
result = subprocess.run(
|
|
1393
|
+
['aws', 'configure', 'list-profiles'],
|
|
1394
|
+
capture_output=True,
|
|
1395
|
+
text=True
|
|
1396
|
+
)
|
|
1397
|
+
|
|
1398
|
+
profiles = result.stdout.strip().split('\n')
|
|
1399
|
+
|
|
1400
|
+
# Check each profile
|
|
1401
|
+
for profile in profiles:
|
|
1402
|
+
try:
|
|
1403
|
+
result = subprocess.run(
|
|
1404
|
+
['aws', 'sts', 'get-caller-identity', '--profile', profile],
|
|
1405
|
+
capture_output=True,
|
|
1406
|
+
text=True,
|
|
1407
|
+
timeout=5
|
|
1408
|
+
)
|
|
1409
|
+
|
|
1410
|
+
if account_id in result.stdout:
|
|
1411
|
+
return profile
|
|
1412
|
+
except:
|
|
1413
|
+
continue
|
|
1414
|
+
|
|
1415
|
+
return None
|
|
1416
|
+
except:
|
|
1417
|
+
return None
|
|
1418
|
+
|
|
1419
|
+
|
|
1420
|
+
@cli.command()
|
|
1421
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1422
|
+
@click.option('--start-date', help='Start date (YYYY-MM-DD)')
|
|
1423
|
+
@click.option('--end-date', help='End date (YYYY-MM-DD)')
|
|
1424
|
+
@click.option('--days', type=int, default=10, help='Number of days to analyze (default: 10)')
|
|
1425
|
+
@click.option('--service', help='Filter by service name')
|
|
1426
|
+
@click.option('--account', help='Filter by account ID')
|
|
1427
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1428
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
1429
|
+
def daily(profile, start_date, end_date, days, service, account, sso, output_json):
|
|
1430
|
+
"""
|
|
1431
|
+
Get daily cost breakdown with granular detail.
|
|
1432
|
+
|
|
1433
|
+
Shows day-by-day costs for specific services and accounts, useful for:
|
|
1434
|
+
- Identifying cost spikes on specific dates
|
|
1435
|
+
- Validating daily cost patterns
|
|
1436
|
+
- Calculating precise daily averages
|
|
1437
|
+
|
|
1438
|
+
Examples:
|
|
1439
|
+
# Last 10 days of CloudWatch costs for specific account
|
|
1440
|
+
cc daily --profile khoros --days 10 --service AmazonCloudWatch --account 820054669588
|
|
1441
|
+
|
|
1442
|
+
# Custom date range with JSON output for automation
|
|
1443
|
+
cc daily --profile khoros --start-date 2025-10-28 --end-date 2025-11-06 --json
|
|
1444
|
+
|
|
1445
|
+
# Find high-cost days using jq
|
|
1446
|
+
cc daily --profile khoros --days 30 --json | jq '.daily_costs | map(select(.cost > 1000))'
|
|
1447
|
+
"""
|
|
1448
|
+
# Load profile
|
|
1449
|
+
config = load_profile(profile)
|
|
1450
|
+
|
|
1451
|
+
# Apply SSO if provided
|
|
1452
|
+
if sso:
|
|
1453
|
+
config['aws_profile'] = sso
|
|
1454
|
+
|
|
1455
|
+
# Calculate date range
|
|
1456
|
+
if end_date:
|
|
1457
|
+
end = datetime.strptime(end_date, '%Y-%m-%d')
|
|
1458
|
+
else:
|
|
1459
|
+
end = datetime.now()
|
|
1460
|
+
|
|
1461
|
+
if start_date:
|
|
1462
|
+
start = datetime.strptime(start_date, '%Y-%m-%d')
|
|
1463
|
+
else:
|
|
1464
|
+
start = end - timedelta(days=days)
|
|
1465
|
+
|
|
1466
|
+
click.echo(f"Daily breakdown: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
|
|
1467
|
+
if service:
|
|
1468
|
+
click.echo(f"Service filter: {service}")
|
|
1469
|
+
if account:
|
|
1470
|
+
click.echo(f"Account filter: {account}")
|
|
1471
|
+
click.echo("")
|
|
1472
|
+
|
|
1473
|
+
# Get credentials
|
|
1474
|
+
try:
|
|
1475
|
+
if 'aws_profile' in config:
|
|
1476
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1477
|
+
else:
|
|
1478
|
+
creds = config['credentials']
|
|
1479
|
+
session = boto3.Session(
|
|
1480
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1481
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1482
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1483
|
+
)
|
|
1484
|
+
|
|
1485
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
1486
|
+
|
|
1487
|
+
# Build filter
|
|
1488
|
+
filter_parts = []
|
|
1489
|
+
|
|
1490
|
+
# Account filter
|
|
1491
|
+
if account:
|
|
1492
|
+
filter_parts.append({
|
|
1493
|
+
"Dimensions": {
|
|
1494
|
+
"Key": "LINKED_ACCOUNT",
|
|
1495
|
+
"Values": [account]
|
|
1496
|
+
}
|
|
1497
|
+
})
|
|
1498
|
+
else:
|
|
1499
|
+
filter_parts.append({
|
|
1500
|
+
"Dimensions": {
|
|
1501
|
+
"Key": "LINKED_ACCOUNT",
|
|
1502
|
+
"Values": config['accounts']
|
|
1503
|
+
}
|
|
1504
|
+
})
|
|
1505
|
+
|
|
1506
|
+
# Service filter
|
|
1507
|
+
if service:
|
|
1508
|
+
filter_parts.append({
|
|
1509
|
+
"Dimensions": {
|
|
1510
|
+
"Key": "SERVICE",
|
|
1511
|
+
"Values": [service]
|
|
1512
|
+
}
|
|
1513
|
+
})
|
|
1514
|
+
|
|
1515
|
+
# Exclude support and tax
|
|
1516
|
+
filter_parts.append({
|
|
1517
|
+
"Not": {
|
|
1518
|
+
"Dimensions": {
|
|
1519
|
+
"Key": "RECORD_TYPE",
|
|
1520
|
+
"Values": ["Tax", "Support"]
|
|
1521
|
+
}
|
|
1522
|
+
}
|
|
1523
|
+
})
|
|
1524
|
+
|
|
1525
|
+
cost_filter = {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
|
|
1526
|
+
|
|
1527
|
+
# Get daily costs
|
|
1528
|
+
response = ce_client.get_cost_and_usage(
|
|
1529
|
+
TimePeriod={
|
|
1530
|
+
'Start': start.strftime('%Y-%m-%d'),
|
|
1531
|
+
'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1532
|
+
},
|
|
1533
|
+
Granularity='DAILY',
|
|
1534
|
+
Metrics=['UnblendedCost'],
|
|
1535
|
+
Filter=cost_filter
|
|
1536
|
+
)
|
|
1537
|
+
|
|
1538
|
+
# Collect results
|
|
1539
|
+
daily_costs = []
|
|
1540
|
+
total = 0
|
|
1541
|
+
for day in response['ResultsByTime']:
|
|
1542
|
+
date = day['TimePeriod']['Start']
|
|
1543
|
+
cost = float(day['Total']['UnblendedCost']['Amount'])
|
|
1544
|
+
total += cost
|
|
1545
|
+
daily_costs.append({'date': date, 'cost': cost})
|
|
1546
|
+
|
|
1547
|
+
num_days = len(response['ResultsByTime'])
|
|
1548
|
+
daily_avg = total / num_days if num_days > 0 else 0
|
|
1549
|
+
annual = daily_avg * 365
|
|
1550
|
+
|
|
1551
|
+
# Output results
|
|
1552
|
+
if output_json:
|
|
1553
|
+
import json
|
|
1554
|
+
result = {
|
|
1555
|
+
'period': {
|
|
1556
|
+
'start': start.strftime('%Y-%m-%d'),
|
|
1557
|
+
'end': end.strftime('%Y-%m-%d'),
|
|
1558
|
+
'days': num_days
|
|
1559
|
+
},
|
|
1560
|
+
'filters': {
|
|
1561
|
+
'service': service,
|
|
1562
|
+
'account': account
|
|
1563
|
+
},
|
|
1564
|
+
'daily_costs': daily_costs,
|
|
1565
|
+
'summary': {
|
|
1566
|
+
'total': total,
|
|
1567
|
+
'daily_avg': daily_avg,
|
|
1568
|
+
'annual_projection': annual
|
|
1569
|
+
}
|
|
1570
|
+
}
|
|
1571
|
+
click.echo(json.dumps(result, indent=2))
|
|
1572
|
+
else:
|
|
1573
|
+
click.echo("Date | Cost")
|
|
1574
|
+
click.echo("-----------|-----------")
|
|
1575
|
+
for item in daily_costs:
|
|
1576
|
+
click.echo(f"{item['date']} | ${item['cost']:,.2f}")
|
|
1577
|
+
click.echo("-----------|-----------")
|
|
1578
|
+
click.echo(f"Total | ${total:,.2f}")
|
|
1579
|
+
click.echo(f"Daily Avg | ${daily_avg:,.2f}")
|
|
1580
|
+
click.echo(f"Annual | ${annual:,.0f}")
|
|
1581
|
+
|
|
1582
|
+
except Exception as e:
|
|
1583
|
+
raise click.ClickException(f"Failed to get daily costs: {e}")
|
|
1584
|
+
|
|
1585
|
+
|
|
1586
|
+
@cli.command()
|
|
1587
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1588
|
+
@click.option('--account', help='Account ID to compare')
|
|
1589
|
+
@click.option('--service', help='Service to compare')
|
|
1590
|
+
@click.option('--before', required=True, help='Before period (YYYY-MM-DD:YYYY-MM-DD)')
|
|
1591
|
+
@click.option('--after', required=True, help='After period (YYYY-MM-DD:YYYY-MM-DD)')
|
|
1592
|
+
@click.option('--expected-reduction', type=float, help='Expected reduction percentage')
|
|
1593
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1594
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
1595
|
+
def compare(profile, account, service, before, after, expected_reduction, sso, output_json):
|
|
1596
|
+
"""
|
|
1597
|
+
Compare costs between two periods for validation and analysis.
|
|
1598
|
+
|
|
1599
|
+
Perfect for:
|
|
1600
|
+
- Validating cost optimization savings
|
|
1601
|
+
- Before/after migration analysis
|
|
1602
|
+
- Measuring impact of infrastructure changes
|
|
1603
|
+
- Automated savings validation in CI/CD
|
|
1604
|
+
|
|
1605
|
+
Examples:
|
|
1606
|
+
# Validate Datadog migration savings (expect 50% reduction)
|
|
1607
|
+
cc compare --profile khoros --account 180770971501 --service AmazonCloudWatch \
|
|
1608
|
+
--before "2025-10-28:2025-11-06" --after "2025-11-17:2025-11-26" --expected-reduction 50
|
|
1609
|
+
|
|
1610
|
+
# Compare total costs across all accounts
|
|
1611
|
+
cc compare --profile khoros --before "2025-10-01:2025-10-31" --after "2025-11-01:2025-11-30"
|
|
1612
|
+
|
|
1613
|
+
# JSON output for automated validation
|
|
1614
|
+
cc compare --profile khoros --service EC2 --before "2025-10-01:2025-10-07" \
|
|
1615
|
+
--after "2025-11-08:2025-11-14" --json | jq '.comparison.met_expectation'
|
|
1616
|
+
"""
|
|
1617
|
+
# Load profile
|
|
1618
|
+
config = load_profile(profile)
|
|
1619
|
+
|
|
1620
|
+
# Apply SSO if provided
|
|
1621
|
+
if sso:
|
|
1622
|
+
config['aws_profile'] = sso
|
|
1623
|
+
|
|
1624
|
+
# Parse periods
|
|
1625
|
+
try:
|
|
1626
|
+
before_start, before_end = before.split(':')
|
|
1627
|
+
after_start, after_end = after.split(':')
|
|
1628
|
+
except ValueError:
|
|
1629
|
+
raise click.ClickException("Period format must be 'YYYY-MM-DD:YYYY-MM-DD'")
|
|
1630
|
+
|
|
1631
|
+
if not output_json:
|
|
1632
|
+
click.echo(f"Comparing periods:")
|
|
1633
|
+
click.echo(f" Before: {before_start} to {before_end}")
|
|
1634
|
+
click.echo(f" After: {after_start} to {after_end}")
|
|
1635
|
+
if service:
|
|
1636
|
+
click.echo(f" Service: {service}")
|
|
1637
|
+
if account:
|
|
1638
|
+
click.echo(f" Account: {account}")
|
|
1639
|
+
click.echo("")
|
|
1640
|
+
|
|
1641
|
+
# Get credentials
|
|
1642
|
+
try:
|
|
1643
|
+
if 'aws_profile' in config:
|
|
1644
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1645
|
+
else:
|
|
1646
|
+
creds = config['credentials']
|
|
1647
|
+
session = boto3.Session(
|
|
1648
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1649
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1650
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1651
|
+
)
|
|
1652
|
+
|
|
1653
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
1654
|
+
|
|
1655
|
+
# Build filter
|
|
1656
|
+
def build_filter():
|
|
1657
|
+
filter_parts = []
|
|
1658
|
+
|
|
1659
|
+
if account:
|
|
1660
|
+
filter_parts.append({
|
|
1661
|
+
"Dimensions": {
|
|
1662
|
+
"Key": "LINKED_ACCOUNT",
|
|
1663
|
+
"Values": [account]
|
|
1664
|
+
}
|
|
1665
|
+
})
|
|
1666
|
+
else:
|
|
1667
|
+
filter_parts.append({
|
|
1668
|
+
"Dimensions": {
|
|
1669
|
+
"Key": "LINKED_ACCOUNT",
|
|
1670
|
+
"Values": config['accounts']
|
|
1671
|
+
}
|
|
1672
|
+
})
|
|
1673
|
+
|
|
1674
|
+
if service:
|
|
1675
|
+
filter_parts.append({
|
|
1676
|
+
"Dimensions": {
|
|
1677
|
+
"Key": "SERVICE",
|
|
1678
|
+
"Values": [service]
|
|
1679
|
+
}
|
|
1680
|
+
})
|
|
1681
|
+
|
|
1682
|
+
filter_parts.append({
|
|
1683
|
+
"Not": {
|
|
1684
|
+
"Dimensions": {
|
|
1685
|
+
"Key": "RECORD_TYPE",
|
|
1686
|
+
"Values": ["Tax", "Support"]
|
|
1687
|
+
}
|
|
1688
|
+
}
|
|
1689
|
+
})
|
|
1690
|
+
|
|
1691
|
+
return {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
|
|
1692
|
+
|
|
1693
|
+
cost_filter = build_filter()
|
|
1694
|
+
|
|
1695
|
+
# Get before period costs
|
|
1696
|
+
before_response = ce_client.get_cost_and_usage(
|
|
1697
|
+
TimePeriod={
|
|
1698
|
+
'Start': before_start,
|
|
1699
|
+
'End': (datetime.strptime(before_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1700
|
+
},
|
|
1701
|
+
Granularity='DAILY',
|
|
1702
|
+
Metrics=['UnblendedCost'],
|
|
1703
|
+
Filter=cost_filter
|
|
1704
|
+
)
|
|
1705
|
+
|
|
1706
|
+
# Get after period costs
|
|
1707
|
+
after_response = ce_client.get_cost_and_usage(
|
|
1708
|
+
TimePeriod={
|
|
1709
|
+
'Start': after_start,
|
|
1710
|
+
'End': (datetime.strptime(after_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1711
|
+
},
|
|
1712
|
+
Granularity='DAILY',
|
|
1713
|
+
Metrics=['UnblendedCost'],
|
|
1714
|
+
Filter=cost_filter
|
|
1715
|
+
)
|
|
1716
|
+
|
|
1717
|
+
# Calculate totals
|
|
1718
|
+
before_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in before_response['ResultsByTime'])
|
|
1719
|
+
after_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in after_response['ResultsByTime'])
|
|
1720
|
+
|
|
1721
|
+
before_days = len(before_response['ResultsByTime'])
|
|
1722
|
+
after_days = len(after_response['ResultsByTime'])
|
|
1723
|
+
|
|
1724
|
+
before_daily = before_total / before_days if before_days > 0 else 0
|
|
1725
|
+
after_daily = after_total / after_days if after_days > 0 else 0
|
|
1726
|
+
|
|
1727
|
+
reduction = before_daily - after_daily
|
|
1728
|
+
reduction_pct = (reduction / before_daily * 100) if before_daily > 0 else 0
|
|
1729
|
+
annual_savings = reduction * 365
|
|
1730
|
+
|
|
1731
|
+
# Output results
|
|
1732
|
+
if output_json:
|
|
1733
|
+
import json
|
|
1734
|
+
result = {
|
|
1735
|
+
'before': {
|
|
1736
|
+
'period': {'start': before_start, 'end': before_end},
|
|
1737
|
+
'total': before_total,
|
|
1738
|
+
'daily_avg': before_daily,
|
|
1739
|
+
'days': before_days
|
|
1740
|
+
},
|
|
1741
|
+
'after': {
|
|
1742
|
+
'period': {'start': after_start, 'end': after_end},
|
|
1743
|
+
'total': after_total,
|
|
1744
|
+
'daily_avg': after_daily,
|
|
1745
|
+
'days': after_days
|
|
1746
|
+
},
|
|
1747
|
+
'comparison': {
|
|
1748
|
+
'daily_reduction': reduction,
|
|
1749
|
+
'reduction_pct': reduction_pct,
|
|
1750
|
+
'annual_savings': annual_savings
|
|
1751
|
+
}
|
|
1752
|
+
}
|
|
1753
|
+
|
|
1754
|
+
if expected_reduction is not None:
|
|
1755
|
+
result['comparison']['expected_reduction_pct'] = expected_reduction
|
|
1756
|
+
result['comparison']['met_expectation'] = reduction_pct >= expected_reduction
|
|
1757
|
+
|
|
1758
|
+
click.echo(json.dumps(result, indent=2))
|
|
1759
|
+
else:
|
|
1760
|
+
click.echo("Before Period:")
|
|
1761
|
+
click.echo(f" Total: ${before_total:,.2f}")
|
|
1762
|
+
click.echo(f" Daily Avg: ${before_daily:,.2f}")
|
|
1763
|
+
click.echo(f" Days: {before_days}")
|
|
1764
|
+
click.echo("")
|
|
1765
|
+
click.echo("After Period:")
|
|
1766
|
+
click.echo(f" Total: ${after_total:,.2f}")
|
|
1767
|
+
click.echo(f" Daily Avg: ${after_daily:,.2f}")
|
|
1768
|
+
click.echo(f" Days: {after_days}")
|
|
1769
|
+
click.echo("")
|
|
1770
|
+
click.echo("Comparison:")
|
|
1771
|
+
click.echo(f" Daily Reduction: ${reduction:,.2f}")
|
|
1772
|
+
click.echo(f" Reduction %: {reduction_pct:.1f}%")
|
|
1773
|
+
click.echo(f" Annual Savings: ${annual_savings:,.0f}")
|
|
1774
|
+
|
|
1775
|
+
if expected_reduction is not None:
|
|
1776
|
+
click.echo("")
|
|
1777
|
+
if reduction_pct >= expected_reduction:
|
|
1778
|
+
click.echo(f"✅ Savings achieved: {reduction_pct:.1f}% (expected {expected_reduction}%)")
|
|
1779
|
+
else:
|
|
1780
|
+
click.echo(f"⚠️ Below target: {reduction_pct:.1f}% (expected {expected_reduction}%)")
|
|
1781
|
+
|
|
1782
|
+
except Exception as e:
|
|
1783
|
+
raise click.ClickException(f"Comparison failed: {e}")
|
|
1784
|
+
|
|
1785
|
+
|
|
1786
|
+
@cli.command()
|
|
1787
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1788
|
+
@click.option('--tag-key', required=True, help='Tag key to filter by')
|
|
1789
|
+
@click.option('--tag-value', help='Tag value to filter by (optional)')
|
|
1790
|
+
@click.option('--start-date', help='Start date (YYYY-MM-DD)')
|
|
1791
|
+
@click.option('--end-date', help='End date (YYYY-MM-DD)')
|
|
1792
|
+
@click.option('--days', type=int, default=30, help='Number of days to analyze (default: 30)')
|
|
1793
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1794
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
1795
|
+
def tags(profile, tag_key, tag_value, start_date, end_date, days, sso, output_json):
|
|
1796
|
+
"""
|
|
1797
|
+
Analyze costs grouped by resource tags for cost attribution.
|
|
1798
|
+
|
|
1799
|
+
Useful for:
|
|
1800
|
+
- Cost allocation by team, project, or environment
|
|
1801
|
+
- Identifying untagged resources (cost attribution gaps)
|
|
1802
|
+
- Tracking costs by cost center or department
|
|
1803
|
+
- Validating tagging compliance
|
|
1804
|
+
|
|
1805
|
+
Examples:
|
|
1806
|
+
# See all costs by Environment tag
|
|
1807
|
+
cc tags --profile khoros --tag-key "Environment" --days 30
|
|
1808
|
+
|
|
1809
|
+
# Filter to specific tag value
|
|
1810
|
+
cc tags --profile khoros --tag-key "Team" --tag-value "Platform" --days 30
|
|
1811
|
+
|
|
1812
|
+
# Find top cost centers with JSON output
|
|
1813
|
+
cc tags --profile khoros --tag-key "CostCenter" --days 30 --json | \
|
|
1814
|
+
jq '.tag_costs | sort_by(-.cost) | .[:5]'
|
|
1815
|
+
|
|
1816
|
+
# Identify untagged resources (look for empty tag values)
|
|
1817
|
+
cc tags --profile khoros --tag-key "Owner" --days 7
|
|
1818
|
+
"""
|
|
1819
|
+
# Load profile
|
|
1820
|
+
config = load_profile(profile)
|
|
1821
|
+
|
|
1822
|
+
# Apply SSO if provided
|
|
1823
|
+
if sso:
|
|
1824
|
+
config['aws_profile'] = sso
|
|
1825
|
+
|
|
1826
|
+
# Calculate date range
|
|
1827
|
+
if end_date:
|
|
1828
|
+
end = datetime.strptime(end_date, '%Y-%m-%d')
|
|
1829
|
+
else:
|
|
1830
|
+
end = datetime.now()
|
|
1831
|
+
|
|
1832
|
+
if start_date:
|
|
1833
|
+
start = datetime.strptime(start_date, '%Y-%m-%d')
|
|
1834
|
+
else:
|
|
1835
|
+
start = end - timedelta(days=days)
|
|
1836
|
+
|
|
1837
|
+
if not output_json:
|
|
1838
|
+
click.echo(f"Tag-based cost analysis: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
|
|
1839
|
+
click.echo(f"Tag key: {tag_key}")
|
|
1840
|
+
if tag_value:
|
|
1841
|
+
click.echo(f"Tag value: {tag_value}")
|
|
1842
|
+
click.echo("")
|
|
1843
|
+
|
|
1844
|
+
# Get credentials
|
|
1845
|
+
try:
|
|
1846
|
+
if 'aws_profile' in config:
|
|
1847
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1848
|
+
else:
|
|
1849
|
+
creds = config['credentials']
|
|
1850
|
+
session = boto3.Session(
|
|
1851
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1852
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1853
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1854
|
+
)
|
|
1855
|
+
|
|
1856
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
1857
|
+
|
|
1858
|
+
# Build filter
|
|
1859
|
+
filter_parts = [
|
|
1860
|
+
{
|
|
1861
|
+
"Dimensions": {
|
|
1862
|
+
"Key": "LINKED_ACCOUNT",
|
|
1863
|
+
"Values": config['accounts']
|
|
1864
|
+
}
|
|
1865
|
+
},
|
|
1866
|
+
{
|
|
1867
|
+
"Not": {
|
|
1868
|
+
"Dimensions": {
|
|
1869
|
+
"Key": "RECORD_TYPE",
|
|
1870
|
+
"Values": ["Tax", "Support"]
|
|
1871
|
+
}
|
|
1872
|
+
}
|
|
1873
|
+
}
|
|
1874
|
+
]
|
|
1875
|
+
|
|
1876
|
+
# Add tag filter if value specified
|
|
1877
|
+
if tag_value:
|
|
1878
|
+
filter_parts.append({
|
|
1879
|
+
"Tags": {
|
|
1880
|
+
"Key": tag_key,
|
|
1881
|
+
"Values": [tag_value]
|
|
1882
|
+
}
|
|
1883
|
+
})
|
|
1884
|
+
|
|
1885
|
+
cost_filter = {"And": filter_parts}
|
|
1886
|
+
|
|
1887
|
+
# Get costs grouped by tag values
|
|
1888
|
+
response = ce_client.get_cost_and_usage(
|
|
1889
|
+
TimePeriod={
|
|
1890
|
+
'Start': start.strftime('%Y-%m-%d'),
|
|
1891
|
+
'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1892
|
+
},
|
|
1893
|
+
Granularity='MONTHLY',
|
|
1894
|
+
Metrics=['UnblendedCost'],
|
|
1895
|
+
GroupBy=[{
|
|
1896
|
+
'Type': 'TAG',
|
|
1897
|
+
'Key': tag_key
|
|
1898
|
+
}],
|
|
1899
|
+
Filter=cost_filter
|
|
1900
|
+
)
|
|
1901
|
+
|
|
1902
|
+
# Collect results
|
|
1903
|
+
tag_costs = {}
|
|
1904
|
+
for period in response['ResultsByTime']:
|
|
1905
|
+
for group in period['Groups']:
|
|
1906
|
+
tag_val = group['Keys'][0].split('$')[1] if '$' in group['Keys'][0] else group['Keys'][0]
|
|
1907
|
+
cost = float(group['Metrics']['UnblendedCost']['Amount'])
|
|
1908
|
+
tag_costs[tag_val] = tag_costs.get(tag_val, 0) + cost
|
|
1909
|
+
|
|
1910
|
+
# Sort by cost
|
|
1911
|
+
sorted_tags = sorted(tag_costs.items(), key=lambda x: x[1], reverse=True)
|
|
1912
|
+
|
|
1913
|
+
total = sum(tag_costs.values())
|
|
1914
|
+
num_days = (end - start).days
|
|
1915
|
+
daily_avg = total / num_days if num_days > 0 else 0
|
|
1916
|
+
|
|
1917
|
+
# Output results
|
|
1918
|
+
if output_json:
|
|
1919
|
+
import json
|
|
1920
|
+
result = {
|
|
1921
|
+
'period': {
|
|
1922
|
+
'start': start.strftime('%Y-%m-%d'),
|
|
1923
|
+
'end': end.strftime('%Y-%m-%d'),
|
|
1924
|
+
'days': num_days
|
|
1925
|
+
},
|
|
1926
|
+
'tag_key': tag_key,
|
|
1927
|
+
'tag_value_filter': tag_value,
|
|
1928
|
+
'tag_costs': [{'tag_value': k, 'cost': v, 'percentage': (v/total*100) if total > 0 else 0} for k, v in sorted_tags],
|
|
1929
|
+
'summary': {
|
|
1930
|
+
'total': total,
|
|
1931
|
+
'daily_avg': daily_avg,
|
|
1932
|
+
'annual_projection': daily_avg * 365
|
|
1933
|
+
}
|
|
1934
|
+
}
|
|
1935
|
+
click.echo(json.dumps(result, indent=2))
|
|
1936
|
+
else:
|
|
1937
|
+
click.echo(f"Tag Value{' '*(30-len('Tag Value'))} | Cost | %")
|
|
1938
|
+
click.echo("-" * 60)
|
|
1939
|
+
for tag_val, cost in sorted_tags:
|
|
1940
|
+
pct = (cost / total * 100) if total > 0 else 0
|
|
1941
|
+
tag_display = tag_val[:30].ljust(30)
|
|
1942
|
+
click.echo(f"{tag_display} | ${cost:>9,.2f} | {pct:>5.1f}%")
|
|
1943
|
+
click.echo("-" * 60)
|
|
1944
|
+
click.echo(f"{'Total'.ljust(30)} | ${total:>9,.2f} | 100.0%")
|
|
1945
|
+
click.echo("")
|
|
1946
|
+
click.echo(f"Daily Avg: ${daily_avg:,.2f}")
|
|
1947
|
+
click.echo(f"Annual Projection: ${daily_avg * 365:,.0f}")
|
|
1948
|
+
|
|
1949
|
+
except Exception as e:
|
|
1950
|
+
raise click.ClickException(f"Tag analysis failed: {e}")
|
|
1951
|
+
|
|
1952
|
+
|
|
1953
|
+
@cli.command()
|
|
1954
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1955
|
+
@click.option('--query', required=True, help='SQL query to execute')
|
|
1956
|
+
@click.option('--database', default='athenacurcfn_cloud_intelligence_dashboard', help='Athena database name')
|
|
1957
|
+
@click.option('--output-bucket', help='S3 bucket for query results (default: from profile)')
|
|
1958
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1959
|
+
def query(profile, query, database, output_bucket, sso):
|
|
1960
|
+
"""
|
|
1961
|
+
Execute custom Athena SQL query on CUR data
|
|
1962
|
+
|
|
1963
|
+
Example:
|
|
1964
|
+
cc query --profile khoros --query "SELECT line_item_usage_account_id, SUM(line_item_unblended_cost) as cost FROM cloud_intelligence_dashboard WHERE line_item_usage_start_date >= DATE '2025-11-01' GROUP BY 1 ORDER BY 2 DESC LIMIT 10"
|
|
1965
|
+
"""
|
|
1966
|
+
# Load profile
|
|
1967
|
+
config = load_profile(profile)
|
|
1968
|
+
|
|
1969
|
+
# Apply SSO if provided
|
|
1970
|
+
if sso:
|
|
1971
|
+
config['aws_profile'] = sso
|
|
1972
|
+
|
|
1973
|
+
# Get credentials
|
|
1974
|
+
try:
|
|
1975
|
+
if 'aws_profile' in config:
|
|
1976
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1977
|
+
else:
|
|
1978
|
+
creds = config['credentials']
|
|
1979
|
+
session = boto3.Session(
|
|
1980
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1981
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1982
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1983
|
+
)
|
|
1984
|
+
|
|
1985
|
+
athena_client = session.client('athena', region_name='us-east-1')
|
|
1986
|
+
|
|
1987
|
+
# Default output location
|
|
1988
|
+
if not output_bucket:
|
|
1989
|
+
output_bucket = 's3://khoros-finops-athena/athena/'
|
|
1990
|
+
|
|
1991
|
+
click.echo(f"Executing query on database: {database}")
|
|
1992
|
+
click.echo(f"Output location: {output_bucket}")
|
|
1993
|
+
click.echo("")
|
|
1994
|
+
|
|
1995
|
+
# Execute query
|
|
1996
|
+
response = athena_client.start_query_execution(
|
|
1997
|
+
QueryString=query,
|
|
1998
|
+
QueryExecutionContext={'Database': database},
|
|
1999
|
+
ResultConfiguration={'OutputLocation': output_bucket}
|
|
2000
|
+
)
|
|
2001
|
+
|
|
2002
|
+
query_id = response['QueryExecutionId']
|
|
2003
|
+
click.echo(f"Query ID: {query_id}")
|
|
2004
|
+
click.echo("Waiting for query to complete...")
|
|
2005
|
+
|
|
2006
|
+
# Wait for completion
|
|
2007
|
+
import time
|
|
2008
|
+
max_wait = 60
|
|
2009
|
+
waited = 0
|
|
2010
|
+
while waited < max_wait:
|
|
2011
|
+
status_response = athena_client.get_query_execution(QueryExecutionId=query_id)
|
|
2012
|
+
status = status_response['QueryExecution']['Status']['State']
|
|
2013
|
+
|
|
2014
|
+
if status == 'SUCCEEDED':
|
|
2015
|
+
click.echo("✓ Query completed successfully")
|
|
2016
|
+
break
|
|
2017
|
+
elif status in ['FAILED', 'CANCELLED']:
|
|
2018
|
+
reason = status_response['QueryExecution']['Status'].get('StateChangeReason', 'Unknown')
|
|
2019
|
+
raise click.ClickException(f"Query {status}: {reason}")
|
|
2020
|
+
|
|
2021
|
+
time.sleep(2)
|
|
2022
|
+
waited += 2
|
|
2023
|
+
|
|
2024
|
+
if waited >= max_wait:
|
|
2025
|
+
raise click.ClickException(f"Query timeout after {max_wait}s. Check query ID: {query_id}")
|
|
2026
|
+
|
|
2027
|
+
# Get results
|
|
2028
|
+
results = athena_client.get_query_results(QueryExecutionId=query_id)
|
|
2029
|
+
|
|
2030
|
+
# Display results
|
|
2031
|
+
rows = results['ResultSet']['Rows']
|
|
2032
|
+
if not rows:
|
|
2033
|
+
click.echo("No results returned")
|
|
2034
|
+
return
|
|
2035
|
+
|
|
2036
|
+
# Header
|
|
2037
|
+
headers = [col['VarCharValue'] for col in rows[0]['Data']]
|
|
2038
|
+
click.echo(" | ".join(headers))
|
|
2039
|
+
click.echo("-" * (len(" | ".join(headers))))
|
|
2040
|
+
|
|
2041
|
+
# Data rows
|
|
2042
|
+
for row in rows[1:]:
|
|
2043
|
+
values = [col.get('VarCharValue', '') for col in row['Data']]
|
|
2044
|
+
click.echo(" | ".join(values))
|
|
2045
|
+
|
|
2046
|
+
click.echo("")
|
|
2047
|
+
click.echo(f"Returned {len(rows)-1} rows")
|
|
2048
|
+
|
|
2049
|
+
except Exception as e:
|
|
2050
|
+
raise click.ClickException(f"Query failed: {e}")
|
|
2051
|
+
|
|
2052
|
+
|
|
2053
|
+
@cli.group()
|
|
2054
|
+
def exclusions():
|
|
2055
|
+
"""
|
|
2056
|
+
Manage cost exclusions (services/types to exclude from calculations).
|
|
2057
|
+
|
|
2058
|
+
Exclusions are stored in DynamoDB and apply globally or per-profile.
|
|
2059
|
+
Common exclusions: Tax, Support, OCBLateFee, Refunds, Credits
|
|
2060
|
+
"""
|
|
2061
|
+
pass
|
|
2062
|
+
|
|
2063
|
+
|
|
2064
|
+
@exclusions.command('show')
|
|
2065
|
+
@click.option('--profile', help='Show profile-specific exclusions (default: global)')
|
|
2066
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
2067
|
+
def show_exclusions(profile, output_json):
|
|
2068
|
+
"""Show current exclusions configuration"""
|
|
2069
|
+
import requests
|
|
2070
|
+
|
|
2071
|
+
api_secret = get_api_secret()
|
|
2072
|
+
if not api_secret:
|
|
2073
|
+
raise click.ClickException(
|
|
2074
|
+
"No API secret configured.\n"
|
|
2075
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2076
|
+
)
|
|
2077
|
+
|
|
2078
|
+
try:
|
|
2079
|
+
response = requests.post(
|
|
2080
|
+
PROFILES_API_URL,
|
|
2081
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2082
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2083
|
+
timeout=10
|
|
2084
|
+
)
|
|
2085
|
+
|
|
2086
|
+
if response.status_code == 200:
|
|
2087
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2088
|
+
|
|
2089
|
+
if output_json:
|
|
2090
|
+
click.echo(json.dumps(exclusions_data, indent=2))
|
|
2091
|
+
else:
|
|
2092
|
+
scope = profile if profile else "global"
|
|
2093
|
+
click.echo(f"Exclusions ({scope}):")
|
|
2094
|
+
click.echo("")
|
|
2095
|
+
|
|
2096
|
+
if exclusions_data.get('record_types'):
|
|
2097
|
+
click.echo("Record Types:")
|
|
2098
|
+
for rt in exclusions_data['record_types']:
|
|
2099
|
+
click.echo(f" - {rt}")
|
|
2100
|
+
click.echo("")
|
|
2101
|
+
|
|
2102
|
+
if exclusions_data.get('services'):
|
|
2103
|
+
click.echo("Services:")
|
|
2104
|
+
for svc in exclusions_data['services']:
|
|
2105
|
+
click.echo(f" - {svc}")
|
|
2106
|
+
click.echo("")
|
|
2107
|
+
|
|
2108
|
+
if exclusions_data.get('line_item_types'):
|
|
2109
|
+
click.echo("Line Item Types:")
|
|
2110
|
+
for lit in exclusions_data['line_item_types']:
|
|
2111
|
+
click.echo(f" - {lit}")
|
|
2112
|
+
click.echo("")
|
|
2113
|
+
|
|
2114
|
+
if exclusions_data.get('usage_types'):
|
|
2115
|
+
click.echo("Usage Types:")
|
|
2116
|
+
for ut in exclusions_data['usage_types']:
|
|
2117
|
+
click.echo(f" - {ut}")
|
|
2118
|
+
else:
|
|
2119
|
+
raise click.ClickException(f"Failed to fetch exclusions: {response.status_code}")
|
|
2120
|
+
|
|
2121
|
+
except requests.exceptions.RequestException as e:
|
|
2122
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2123
|
+
|
|
2124
|
+
|
|
2125
|
+
@exclusions.command('add')
|
|
2126
|
+
@click.option('--record-type', help='Add record type exclusion (e.g., Tax, Support)')
|
|
2127
|
+
@click.option('--service', help='Add service exclusion (e.g., OCBLateFee)')
|
|
2128
|
+
@click.option('--line-item-type', help='Add line item type exclusion (e.g., Refund, Credit)')
|
|
2129
|
+
@click.option('--usage-type', help='Add usage type exclusion')
|
|
2130
|
+
@click.option('--profile', help='Add to profile-specific exclusions (default: global)')
|
|
2131
|
+
def add_exclusion(record_type, service, line_item_type, usage_type, profile):
|
|
2132
|
+
"""Add an exclusion"""
|
|
2133
|
+
import requests
|
|
2134
|
+
|
|
2135
|
+
if not any([record_type, service, line_item_type, usage_type]):
|
|
2136
|
+
raise click.ClickException(
|
|
2137
|
+
"Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
|
|
2138
|
+
)
|
|
2139
|
+
|
|
2140
|
+
api_secret = get_api_secret()
|
|
2141
|
+
if not api_secret:
|
|
2142
|
+
raise click.ClickException(
|
|
2143
|
+
"No API secret configured.\n"
|
|
2144
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2145
|
+
)
|
|
2146
|
+
|
|
2147
|
+
# Get current exclusions
|
|
2148
|
+
try:
|
|
2149
|
+
response = requests.post(
|
|
2150
|
+
PROFILES_API_URL,
|
|
2151
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2152
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2153
|
+
timeout=10
|
|
2154
|
+
)
|
|
2155
|
+
|
|
2156
|
+
if response.status_code == 200:
|
|
2157
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2158
|
+
else:
|
|
2159
|
+
exclusions_data = {
|
|
2160
|
+
'record_types': [],
|
|
2161
|
+
'services': [],
|
|
2162
|
+
'line_item_types': [],
|
|
2163
|
+
'usage_types': []
|
|
2164
|
+
}
|
|
2165
|
+
|
|
2166
|
+
# Add new exclusions
|
|
2167
|
+
if record_type and record_type not in exclusions_data.get('record_types', []):
|
|
2168
|
+
exclusions_data.setdefault('record_types', []).append(record_type)
|
|
2169
|
+
|
|
2170
|
+
if service and service not in exclusions_data.get('services', []):
|
|
2171
|
+
exclusions_data.setdefault('services', []).append(service)
|
|
2172
|
+
|
|
2173
|
+
if line_item_type and line_item_type not in exclusions_data.get('line_item_types', []):
|
|
2174
|
+
exclusions_data.setdefault('line_item_types', []).append(line_item_type)
|
|
2175
|
+
|
|
2176
|
+
if usage_type and usage_type not in exclusions_data.get('usage_types', []):
|
|
2177
|
+
exclusions_data.setdefault('usage_types', []).append(usage_type)
|
|
2178
|
+
|
|
2179
|
+
# Update exclusions
|
|
2180
|
+
update_response = requests.post(
|
|
2181
|
+
PROFILES_API_URL,
|
|
2182
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2183
|
+
json={
|
|
2184
|
+
'operation': 'update_exclusions',
|
|
2185
|
+
'profile_name': profile,
|
|
2186
|
+
'record_types': exclusions_data.get('record_types', []),
|
|
2187
|
+
'services': exclusions_data.get('services', []),
|
|
2188
|
+
'line_item_types': exclusions_data.get('line_item_types', []),
|
|
2189
|
+
'usage_types': exclusions_data.get('usage_types', [])
|
|
2190
|
+
},
|
|
2191
|
+
timeout=10
|
|
2192
|
+
)
|
|
2193
|
+
|
|
2194
|
+
if update_response.status_code == 200:
|
|
2195
|
+
scope = profile if profile else "global"
|
|
2196
|
+
click.echo(f"✓ Exclusion added to {scope} config")
|
|
2197
|
+
if record_type:
|
|
2198
|
+
click.echo(f" Record Type: {record_type}")
|
|
2199
|
+
if service:
|
|
2200
|
+
click.echo(f" Service: {service}")
|
|
2201
|
+
if line_item_type:
|
|
2202
|
+
click.echo(f" Line Item Type: {line_item_type}")
|
|
2203
|
+
if usage_type:
|
|
2204
|
+
click.echo(f" Usage Type: {usage_type}")
|
|
2205
|
+
else:
|
|
2206
|
+
raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
|
|
2207
|
+
|
|
2208
|
+
except requests.exceptions.RequestException as e:
|
|
2209
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2210
|
+
|
|
2211
|
+
|
|
2212
|
+
@exclusions.command('remove')
|
|
2213
|
+
@click.option('--record-type', help='Remove record type exclusion')
|
|
2214
|
+
@click.option('--service', help='Remove service exclusion')
|
|
2215
|
+
@click.option('--line-item-type', help='Remove line item type exclusion')
|
|
2216
|
+
@click.option('--usage-type', help='Remove usage type exclusion')
|
|
2217
|
+
@click.option('--profile', help='Remove from profile-specific exclusions (default: global)')
|
|
2218
|
+
def remove_exclusion(record_type, service, line_item_type, usage_type, profile):
|
|
2219
|
+
"""Remove an exclusion"""
|
|
2220
|
+
import requests
|
|
2221
|
+
|
|
2222
|
+
if not any([record_type, service, line_item_type, usage_type]):
|
|
2223
|
+
raise click.ClickException(
|
|
2224
|
+
"Must specify at least one of: --record-type, --service, --line-item-type, --usage-type"
|
|
2225
|
+
)
|
|
2226
|
+
|
|
2227
|
+
api_secret = get_api_secret()
|
|
2228
|
+
if not api_secret:
|
|
2229
|
+
raise click.ClickException(
|
|
2230
|
+
"No API secret configured.\n"
|
|
2231
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2232
|
+
)
|
|
2233
|
+
|
|
2234
|
+
# Get current exclusions
|
|
2235
|
+
try:
|
|
2236
|
+
response = requests.post(
|
|
2237
|
+
PROFILES_API_URL,
|
|
2238
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2239
|
+
json={'operation': 'get_exclusions', 'profile_name': profile},
|
|
2240
|
+
timeout=10
|
|
2241
|
+
)
|
|
2242
|
+
|
|
2243
|
+
if response.status_code == 200:
|
|
2244
|
+
exclusions_data = response.json().get('exclusions', {})
|
|
2245
|
+
else:
|
|
2246
|
+
raise click.ClickException("No exclusions found")
|
|
2247
|
+
|
|
2248
|
+
# Remove exclusions
|
|
2249
|
+
if record_type and record_type in exclusions_data.get('record_types', []):
|
|
2250
|
+
exclusions_data['record_types'].remove(record_type)
|
|
2251
|
+
|
|
2252
|
+
if service and service in exclusions_data.get('services', []):
|
|
2253
|
+
exclusions_data['services'].remove(service)
|
|
2254
|
+
|
|
2255
|
+
if line_item_type and line_item_type in exclusions_data.get('line_item_types', []):
|
|
2256
|
+
exclusions_data['line_item_types'].remove(line_item_type)
|
|
2257
|
+
|
|
2258
|
+
if usage_type and usage_type in exclusions_data.get('usage_types', []):
|
|
2259
|
+
exclusions_data['usage_types'].remove(usage_type)
|
|
2260
|
+
|
|
2261
|
+
# Update exclusions
|
|
2262
|
+
update_response = requests.post(
|
|
2263
|
+
PROFILES_API_URL,
|
|
2264
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2265
|
+
json={
|
|
2266
|
+
'operation': 'update_exclusions',
|
|
2267
|
+
'profile_name': profile,
|
|
2268
|
+
'record_types': exclusions_data.get('record_types', []),
|
|
2269
|
+
'services': exclusions_data.get('services', []),
|
|
2270
|
+
'line_item_types': exclusions_data.get('line_item_types', []),
|
|
2271
|
+
'usage_types': exclusions_data.get('usage_types', [])
|
|
2272
|
+
},
|
|
2273
|
+
timeout=10
|
|
2274
|
+
)
|
|
2275
|
+
|
|
2276
|
+
if update_response.status_code == 200:
|
|
2277
|
+
scope = profile if profile else "global"
|
|
2278
|
+
click.echo(f"✓ Exclusion removed from {scope} config")
|
|
2279
|
+
else:
|
|
2280
|
+
raise click.ClickException(f"Failed to update exclusions: {update_response.status_code}")
|
|
2281
|
+
|
|
2282
|
+
except requests.exceptions.RequestException as e:
|
|
2283
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2284
|
+
|
|
2285
|
+
|
|
2286
|
+
@cli.command('trends-detailed')
|
|
2287
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
2288
|
+
@click.option('--accounts', help='Comma-separated account IDs (optional, defaults to all profile accounts)')
|
|
2289
|
+
@click.option('--services', help='Comma-separated service names (optional, defaults to top N)')
|
|
2290
|
+
@click.option('--start-date', help='Start date (YYYY-MM-DD, defaults to 30 days before end-date)')
|
|
2291
|
+
@click.option('--end-date', help='End date (YYYY-MM-DD, defaults to T-2)')
|
|
2292
|
+
@click.option('--granularity', type=click.Choice(['DAILY', 'HOURLY'], case_sensitive=False), default='DAILY', help='Time granularity')
|
|
2293
|
+
@click.option('--top-n', type=int, default=20, help='Number of top services to return')
|
|
2294
|
+
@click.option('--filter-spikes/--no-filter-spikes', default=True, help='Filter out anomalous spikes (>100% day-over-day)')
|
|
2295
|
+
@click.option('--spike-threshold', type=float, default=2.0, help='Spike threshold multiplier (2.0 = 100% increase)')
|
|
2296
|
+
@click.option('--trendline', is_flag=True, help='Fit smooth trendline over spiky data (removes outliers)')
|
|
2297
|
+
@click.option('--outlier-threshold', type=float, default=2.5, help='Standard deviations from mean to consider outlier')
|
|
2298
|
+
@click.option('--output', default='trends_detailed.csv', help='Output CSV file')
|
|
2299
|
+
@click.option('--chart', is_flag=True, help='Generate trend chart (PNG)')
|
|
2300
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON instead of CSV')
|
|
2301
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
2302
|
+
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
2303
|
+
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
2304
|
+
@click.option('--session-token', help='AWS Session Token')
|
|
2305
|
+
def trends_detailed(profile, accounts, services, start_date, end_date, granularity,
|
|
2306
|
+
top_n, filter_spikes, spike_threshold, trendline, outlier_threshold,
|
|
2307
|
+
output, chart, json_output,
|
|
2308
|
+
sso, access_key_id, secret_access_key, session_token):
|
|
2309
|
+
"""
|
|
2310
|
+
Detailed cost trends with flexible granularity and filtering.
|
|
2311
|
+
|
|
2312
|
+
Supports:
|
|
2313
|
+
- Daily or Hourly granularity
|
|
2314
|
+
- Account filtering (specific accounts or all)
|
|
2315
|
+
- Service filtering (specific services or top N)
|
|
2316
|
+
- Spike detection and removal (one-time charges)
|
|
2317
|
+
- Automatic exclusions (Tax, Support, etc.)
|
|
2318
|
+
|
|
2319
|
+
Examples:
|
|
2320
|
+
|
|
2321
|
+
# Top 20 services for 2 specific accounts, daily, last 30 days
|
|
2322
|
+
cc trends-detailed --profile khoros --accounts "820054669588,180770971501" --sso khoros_umbrella
|
|
2323
|
+
|
|
2324
|
+
# Hourly granularity for specific date range
|
|
2325
|
+
cc trends-detailed --profile khoros --granularity HOURLY --start-date 2025-11-01 --end-date 2025-11-05 --sso khoros_umbrella
|
|
2326
|
+
|
|
2327
|
+
# Top 10 services with chart
|
|
2328
|
+
cc trends-detailed --profile khoros --top-n 10 --chart --sso khoros_umbrella
|
|
2329
|
+
|
|
2330
|
+
# Specific services only
|
|
2331
|
+
cc trends-detailed --profile khoros --services "EC2,RDS,CloudWatch" --sso khoros_umbrella
|
|
2332
|
+
"""
|
|
2333
|
+
import requests
|
|
2334
|
+
from datetime import datetime, timedelta
|
|
2335
|
+
|
|
2336
|
+
# Load profile configuration
|
|
2337
|
+
config = load_profile(profile)
|
|
2338
|
+
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
2339
|
+
|
|
2340
|
+
# Get credentials
|
|
2341
|
+
from cost_calculator.executor import get_credentials_dict
|
|
2342
|
+
credentials = get_credentials_dict(config)
|
|
2343
|
+
if not credentials:
|
|
2344
|
+
raise click.ClickException("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
2345
|
+
|
|
2346
|
+
# Parse accounts and services
|
|
2347
|
+
account_list = [acc.strip() for acc in accounts.split(',')] if accounts else None
|
|
2348
|
+
service_list = [svc.strip() for svc in services.split(',')] if services else None
|
|
2349
|
+
|
|
2350
|
+
# Build request
|
|
2351
|
+
request_data = {
|
|
2352
|
+
'profile': profile,
|
|
2353
|
+
'credentials': credentials,
|
|
2354
|
+
'granularity': granularity.upper(),
|
|
2355
|
+
'top_n': top_n,
|
|
2356
|
+
'filter_spikes': filter_spikes,
|
|
2357
|
+
'spike_threshold': spike_threshold,
|
|
2358
|
+
'fit_trendline': trendline,
|
|
2359
|
+
'outlier_threshold': outlier_threshold
|
|
2360
|
+
}
|
|
2361
|
+
|
|
2362
|
+
if start_date:
|
|
2363
|
+
request_data['start_date'] = start_date
|
|
2364
|
+
if end_date:
|
|
2365
|
+
request_data['end_date'] = end_date
|
|
2366
|
+
if account_list:
|
|
2367
|
+
request_data['accounts'] = account_list
|
|
2368
|
+
if service_list:
|
|
2369
|
+
request_data['services'] = service_list
|
|
2370
|
+
|
|
2371
|
+
# Get API secret
|
|
2372
|
+
api_secret = get_api_secret()
|
|
2373
|
+
if not api_secret:
|
|
2374
|
+
raise click.ClickException(
|
|
2375
|
+
"No API secret configured.\n"
|
|
2376
|
+
"Run: cc configure --api-secret YOUR_SECRET"
|
|
2377
|
+
)
|
|
2378
|
+
|
|
2379
|
+
# Call API
|
|
2380
|
+
click.echo(f"Fetching {granularity.lower()} cost trends...")
|
|
2381
|
+
if account_list:
|
|
2382
|
+
click.echo(f" Accounts: {', '.join(account_list)}")
|
|
2383
|
+
if service_list:
|
|
2384
|
+
click.echo(f" Services: {', '.join(service_list)}")
|
|
2385
|
+
else:
|
|
2386
|
+
click.echo(f" Top {top_n} services")
|
|
2387
|
+
click.echo(f" Spike filtering: {'enabled' if filter_spikes else 'disabled'}")
|
|
2388
|
+
click.echo("")
|
|
2389
|
+
|
|
2390
|
+
try:
|
|
2391
|
+
response = requests.post(
|
|
2392
|
+
f"{API_BASE_URL}/trends-detailed",
|
|
2393
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
2394
|
+
json=request_data,
|
|
2395
|
+
timeout=300 # 5 minutes for large queries
|
|
2396
|
+
)
|
|
2397
|
+
|
|
2398
|
+
if response.status_code != 200:
|
|
2399
|
+
raise click.ClickException(f"API call failed: {response.status_code} - {response.text}")
|
|
2400
|
+
|
|
2401
|
+
result = response.json()
|
|
2402
|
+
|
|
2403
|
+
if json_output:
|
|
2404
|
+
# Output as JSON
|
|
2405
|
+
click.echo(json.dumps(result, indent=2))
|
|
2406
|
+
else:
|
|
2407
|
+
# Generate CSV
|
|
2408
|
+
import csv
|
|
2409
|
+
|
|
2410
|
+
with open(output, 'w', newline='') as f:
|
|
2411
|
+
writer = csv.writer(f)
|
|
2412
|
+
|
|
2413
|
+
# Get all dates from first service
|
|
2414
|
+
if result['services']:
|
|
2415
|
+
dates = sorted(result['services'][0]['daily_costs'].keys())
|
|
2416
|
+
|
|
2417
|
+
# Header
|
|
2418
|
+
writer.writerow(['Service', 'Total', 'Daily Avg', 'Min', 'Max'] + dates)
|
|
2419
|
+
|
|
2420
|
+
# Data rows
|
|
2421
|
+
for service in result['services']:
|
|
2422
|
+
row = [
|
|
2423
|
+
service['name'],
|
|
2424
|
+
service['total'],
|
|
2425
|
+
service['daily_average'],
|
|
2426
|
+
service['min'],
|
|
2427
|
+
service['max']
|
|
2428
|
+
]
|
|
2429
|
+
row.extend([service['daily_costs'].get(date, 0.0) for date in dates])
|
|
2430
|
+
writer.writerow(row)
|
|
2431
|
+
|
|
2432
|
+
click.echo(f"✓ Trends data saved to {output}")
|
|
2433
|
+
|
|
2434
|
+
# Show summary
|
|
2435
|
+
summary = result['summary']
|
|
2436
|
+
click.echo("")
|
|
2437
|
+
click.echo(f"Total Cost: ${summary['total_cost']:,.2f}")
|
|
2438
|
+
click.echo(f"Daily Average: ${summary['daily_average']:,.2f}")
|
|
2439
|
+
click.echo(f"Services: {summary['num_services']}")
|
|
2440
|
+
click.echo(f"Periods: {summary['num_periods']}")
|
|
2441
|
+
|
|
2442
|
+
# Generate chart if requested
|
|
2443
|
+
if chart:
|
|
2444
|
+
try:
|
|
2445
|
+
import matplotlib.pyplot as plt
|
|
2446
|
+
import matplotlib.dates as mdates
|
|
2447
|
+
from datetime import datetime as dt
|
|
2448
|
+
|
|
2449
|
+
chart_file = output.replace('.csv', '.png')
|
|
2450
|
+
|
|
2451
|
+
# Prepare data
|
|
2452
|
+
dates = sorted(result['services'][0]['daily_costs'].keys())
|
|
2453
|
+
date_objects = [dt.fromisoformat(d) for d in dates]
|
|
2454
|
+
|
|
2455
|
+
# Create plot
|
|
2456
|
+
fig, ax = plt.subplots(figsize=(16, 10))
|
|
2457
|
+
|
|
2458
|
+
for service in result['services'][:10]: # Top 10 only
|
|
2459
|
+
costs = [service['daily_costs'].get(date, 0.0) for date in dates]
|
|
2460
|
+
|
|
2461
|
+
if trendline and 'trendline' in service:
|
|
2462
|
+
# Plot raw data with lighter color and smaller markers
|
|
2463
|
+
line = ax.plot(date_objects, costs, marker='o', alpha=0.3,
|
|
2464
|
+
linewidth=1, markersize=3, linestyle='--')[0]
|
|
2465
|
+
# Plot trendline with same color but bolder
|
|
2466
|
+
trendline_costs = [service['trendline'].get(date, 0.0) for date in dates]
|
|
2467
|
+
ax.plot(date_objects, trendline_costs, color=line.get_color(),
|
|
2468
|
+
label=service['name'], linewidth=3, markersize=0)
|
|
2469
|
+
else:
|
|
2470
|
+
# Plot raw data only
|
|
2471
|
+
ax.plot(date_objects, costs, marker='o', label=service['name'],
|
|
2472
|
+
linewidth=2, markersize=4)
|
|
2473
|
+
|
|
2474
|
+
title = f'Top 10 Services - {granularity.title()} Cost Trends'
|
|
2475
|
+
if trendline:
|
|
2476
|
+
title += ' (with Fitted Trendlines)'
|
|
2477
|
+
title += f"\nAccounts: {', '.join(account_list) if account_list else 'All'}"
|
|
2478
|
+
ax.set_title(title, fontsize=14, fontweight='bold')
|
|
2479
|
+
ax.set_xlabel('Date', fontsize=12)
|
|
2480
|
+
ax.set_ylabel(f'{granularity.title()} Cost ($)', fontsize=12)
|
|
2481
|
+
ax.legend(loc='upper left', bbox_to_anchor=(1.02, 1), fontsize=9)
|
|
2482
|
+
ax.grid(True, alpha=0.3)
|
|
2483
|
+
|
|
2484
|
+
if granularity == 'DAILY':
|
|
2485
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d'))
|
|
2486
|
+
ax.xaxis.set_major_locator(mdates.DayLocator(interval=max(1, len(dates)//15)))
|
|
2487
|
+
else:
|
|
2488
|
+
ax.xaxis.set_major_formatter(mdates.DateFormatter('%m/%d %H:%M'))
|
|
2489
|
+
ax.xaxis.set_major_locator(mdates.HourLocator(interval=max(1, len(dates)//15)))
|
|
2490
|
+
|
|
2491
|
+
plt.setp(ax.xaxis.get_majorticklabels(), rotation=45, ha='right')
|
|
2492
|
+
plt.tight_layout()
|
|
2493
|
+
plt.savefig(chart_file, dpi=150, bbox_inches='tight')
|
|
2494
|
+
|
|
2495
|
+
click.echo(f"✓ Chart saved to {chart_file}")
|
|
2496
|
+
except ImportError:
|
|
2497
|
+
click.echo("⚠ matplotlib not installed, skipping chart generation")
|
|
2498
|
+
|
|
2499
|
+
except requests.exceptions.RequestException as e:
|
|
2500
|
+
raise click.ClickException(f"API request failed: {e}")
|
|
2501
|
+
|
|
2502
|
+
|
|
874
2503
|
if __name__ == '__main__':
|
|
875
2504
|
cli()
|