aws-cost-calculator-cli 1.2.0__py3-none-any.whl → 1.11.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.
Potentially problematic release.
This version of aws-cost-calculator-cli might be problematic. Click here for more details.
- aws_cost_calculator_cli-1.11.0.dist-info/METADATA +437 -0
- aws_cost_calculator_cli-1.11.0.dist-info/RECORD +15 -0
- cost_calculator/api_client.py +85 -0
- cost_calculator/cli.py +1348 -108
- cost_calculator/cur.py +244 -0
- cost_calculator/drill.py +323 -0
- cost_calculator/executor.py +251 -0
- cost_calculator/forensics.py +323 -0
- aws_cost_calculator_cli-1.2.0.dist-info/METADATA +0 -246
- aws_cost_calculator_cli-1.2.0.dist-info/RECORD +0 -10
- {aws_cost_calculator_cli-1.2.0.dist-info → aws_cost_calculator_cli-1.11.0.dist-info}/WHEEL +0 -0
- {aws_cost_calculator_cli-1.2.0.dist-info → aws_cost_calculator_cli-1.11.0.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.2.0.dist-info → aws_cost_calculator_cli-1.11.0.dist-info}/licenses/LICENSE +0 -0
- {aws_cost_calculator_cli-1.2.0.dist-info → aws_cost_calculator_cli-1.11.0.dist-info}/top_level.txt +0 -0
cost_calculator/cli.py
CHANGED
|
@@ -11,55 +11,183 @@ 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
|
-
from cost_calculator.trends import
|
|
17
|
-
from cost_calculator.monthly import
|
|
18
|
+
from cost_calculator.trends import format_trends_markdown
|
|
19
|
+
from cost_calculator.monthly import format_monthly_markdown
|
|
20
|
+
from cost_calculator.drill import format_drill_down_markdown
|
|
21
|
+
from cost_calculator.executor import execute_trends, execute_monthly, execute_drill
|
|
18
22
|
|
|
19
23
|
|
|
20
|
-
def
|
|
21
|
-
"""
|
|
22
|
-
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
23
|
-
config_file = config_dir / 'profiles.json'
|
|
24
|
-
creds_file = config_dir / 'credentials.json'
|
|
24
|
+
def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=None, session_token=None):
|
|
25
|
+
"""Apply authentication options to profile config
|
|
25
26
|
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
27
|
+
Args:
|
|
28
|
+
config: Profile configuration dict
|
|
29
|
+
sso: AWS SSO profile name
|
|
30
|
+
access_key_id: AWS Access Key ID
|
|
31
|
+
secret_access_key: AWS Secret Access Key
|
|
32
|
+
session_token: AWS Session Token
|
|
31
33
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
+
Returns:
|
|
35
|
+
Updated config dict
|
|
36
|
+
"""
|
|
37
|
+
import subprocess
|
|
34
38
|
|
|
35
|
-
if
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
39
|
+
if sso:
|
|
40
|
+
# SSO authentication - trigger login if needed
|
|
41
|
+
try:
|
|
42
|
+
# Test if SSO session is valid
|
|
43
|
+
result = subprocess.run(
|
|
44
|
+
['aws', 'sts', 'get-caller-identity', '--profile', sso],
|
|
45
|
+
capture_output=True,
|
|
46
|
+
text=True,
|
|
47
|
+
timeout=5
|
|
48
|
+
)
|
|
49
|
+
if result.returncode != 0:
|
|
50
|
+
if 'expired' in result.stderr.lower() or 'token' in result.stderr.lower():
|
|
51
|
+
click.echo(f"SSO session expired or not initialized. Logging in...")
|
|
52
|
+
subprocess.run(['aws', 'sso', 'login', '--profile', sso], check=True)
|
|
53
|
+
except Exception as e:
|
|
54
|
+
click.echo(f"Warning: Could not verify SSO session: {e}")
|
|
55
|
+
|
|
56
|
+
config['aws_profile'] = sso
|
|
57
|
+
elif access_key_id and secret_access_key:
|
|
58
|
+
# Static credentials provided via CLI
|
|
59
|
+
config['credentials'] = {
|
|
60
|
+
'aws_access_key_id': access_key_id,
|
|
61
|
+
'aws_secret_access_key': secret_access_key,
|
|
62
|
+
'region': 'us-east-1'
|
|
63
|
+
}
|
|
64
|
+
if session_token:
|
|
65
|
+
config['credentials']['aws_session_token'] = session_token
|
|
40
66
|
|
|
41
|
-
|
|
67
|
+
return config
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def load_profile(profile_name):
|
|
71
|
+
"""Load profile configuration from DynamoDB API or local file as fallback"""
|
|
72
|
+
import os
|
|
73
|
+
import requests
|
|
42
74
|
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
75
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
76
|
+
config_file = config_dir / 'profiles.json'
|
|
77
|
+
creds_file = config_dir / 'credentials.json'
|
|
78
|
+
|
|
79
|
+
# Try DynamoDB API first if COST_API_SECRET is set
|
|
80
|
+
api_secret = os.environ.get('COST_API_SECRET')
|
|
81
|
+
if api_secret:
|
|
82
|
+
try:
|
|
83
|
+
response = requests.post(
|
|
84
|
+
'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
|
|
85
|
+
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
86
|
+
json={'operation': 'get', 'profile_name': profile_name},
|
|
87
|
+
timeout=10
|
|
49
88
|
)
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
89
|
+
|
|
90
|
+
if response.status_code == 200:
|
|
91
|
+
response_data = response.json()
|
|
92
|
+
# API returns {"profile": {...}} wrapper
|
|
93
|
+
profile_data = response_data.get('profile', response_data)
|
|
94
|
+
profile = {'accounts': profile_data['accounts']}
|
|
95
|
+
|
|
96
|
+
# If profile has aws_profile field, use it
|
|
97
|
+
if 'aws_profile' in profile_data:
|
|
98
|
+
profile['aws_profile'] = profile_data['aws_profile']
|
|
99
|
+
# Check for AWS_PROFILE environment variable (SSO support)
|
|
100
|
+
elif os.environ.get('AWS_PROFILE'):
|
|
101
|
+
profile['aws_profile'] = os.environ['AWS_PROFILE']
|
|
102
|
+
# Use environment credentials
|
|
103
|
+
elif os.environ.get('AWS_ACCESS_KEY_ID'):
|
|
104
|
+
profile['credentials'] = {
|
|
105
|
+
'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
|
|
106
|
+
'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
107
|
+
'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
|
|
108
|
+
}
|
|
109
|
+
else:
|
|
110
|
+
# Try to find a matching AWS profile by name
|
|
111
|
+
# This allows "khoros" profile to work with "khoros_umbrella" AWS profile
|
|
112
|
+
import subprocess
|
|
113
|
+
try:
|
|
114
|
+
result = subprocess.run(
|
|
115
|
+
['aws', 'configure', 'list-profiles'],
|
|
116
|
+
capture_output=True,
|
|
117
|
+
text=True,
|
|
118
|
+
timeout=5
|
|
119
|
+
)
|
|
120
|
+
if result.returncode == 0:
|
|
121
|
+
available_profiles = result.stdout.strip().split('\n')
|
|
122
|
+
# Try exact match first
|
|
123
|
+
if profile_name in available_profiles:
|
|
124
|
+
profile['aws_profile'] = profile_name
|
|
125
|
+
# Try with common suffixes
|
|
126
|
+
elif f"{profile_name}_umbrella" in available_profiles:
|
|
127
|
+
profile['aws_profile'] = f"{profile_name}_umbrella"
|
|
128
|
+
elif f"{profile_name}-umbrella" in available_profiles:
|
|
129
|
+
profile['aws_profile'] = f"{profile_name}-umbrella"
|
|
130
|
+
elif f"{profile_name}_prod" in available_profiles:
|
|
131
|
+
profile['aws_profile'] = f"{profile_name}_prod"
|
|
132
|
+
# If no match found, leave it unset - user must provide --sso
|
|
133
|
+
except:
|
|
134
|
+
# If we can't list profiles, leave it unset - user must provide --sso
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
return profile
|
|
138
|
+
else:
|
|
139
|
+
raise click.ClickException(
|
|
140
|
+
f"Profile '{profile_name}' not found in DynamoDB.\n"
|
|
141
|
+
f"Run: cc profile create --name {profile_name} --accounts \"...\""
|
|
142
|
+
)
|
|
143
|
+
except requests.exceptions.RequestException as e:
|
|
55
144
|
raise click.ClickException(
|
|
56
|
-
f"
|
|
57
|
-
f"Run: cc configure --profile {profile_name}"
|
|
145
|
+
f"Failed to fetch profile from API: {e}\n"
|
|
58
146
|
)
|
|
147
|
+
|
|
148
|
+
# Fallback to local file if no API secret
|
|
149
|
+
if config_file.exists():
|
|
150
|
+
with open(config_file) as f:
|
|
151
|
+
profiles = json.load(f)
|
|
59
152
|
|
|
60
|
-
|
|
153
|
+
if profile_name in profiles:
|
|
154
|
+
profile = profiles[profile_name]
|
|
155
|
+
|
|
156
|
+
# Load credentials if using static credentials (not SSO)
|
|
157
|
+
if 'aws_profile' not in profile:
|
|
158
|
+
if not creds_file.exists():
|
|
159
|
+
# Try environment variables
|
|
160
|
+
if os.environ.get('AWS_ACCESS_KEY_ID'):
|
|
161
|
+
profile['credentials'] = {
|
|
162
|
+
'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
|
|
163
|
+
'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
164
|
+
'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
|
|
165
|
+
}
|
|
166
|
+
return profile
|
|
167
|
+
|
|
168
|
+
raise click.ClickException(
|
|
169
|
+
f"No credentials found for profile '{profile_name}'.\n"
|
|
170
|
+
f"Run: cc configure --profile {profile_name}"
|
|
171
|
+
)
|
|
172
|
+
|
|
173
|
+
with open(creds_file) as f:
|
|
174
|
+
creds = json.load(f)
|
|
175
|
+
|
|
176
|
+
if profile_name not in creds:
|
|
177
|
+
raise click.ClickException(
|
|
178
|
+
f"No credentials found for profile '{profile_name}'.\n"
|
|
179
|
+
f"Run: cc configure --profile {profile_name}"
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
profile['credentials'] = creds[profile_name]
|
|
183
|
+
|
|
184
|
+
return profile
|
|
61
185
|
|
|
62
|
-
|
|
186
|
+
# Profile not found anywhere
|
|
187
|
+
raise click.ClickException(
|
|
188
|
+
f"Profile '{profile_name}' not found.\n"
|
|
189
|
+
f"Run: cc profile create --name {profile_name} --accounts \"...\""
|
|
190
|
+
)
|
|
63
191
|
|
|
64
192
|
|
|
65
193
|
def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
@@ -227,9 +355,10 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
227
355
|
# Calculate days in the month that the support covers
|
|
228
356
|
# Support on Nov 1 covers October (31 days)
|
|
229
357
|
support_month = support_month_date - timedelta(days=1) # Go back to previous month
|
|
230
|
-
|
|
358
|
+
import calendar
|
|
359
|
+
days_in_support_month = calendar.monthrange(support_month.year, support_month.month)[1]
|
|
231
360
|
|
|
232
|
-
# Support allocation: divide by 2 (
|
|
361
|
+
# Support allocation: divide by 2 (50% allocation), then by days in month
|
|
233
362
|
support_per_day = (support_cost / 2) / days_in_support_month
|
|
234
363
|
|
|
235
364
|
# Calculate daily rate
|
|
@@ -288,18 +417,157 @@ def cli():
|
|
|
288
417
|
pass
|
|
289
418
|
|
|
290
419
|
|
|
420
|
+
@cli.command('setup-cur')
|
|
421
|
+
@click.option('--database', required=True, prompt='CUR Athena Database', help='Athena database name for CUR')
|
|
422
|
+
@click.option('--table', required=True, prompt='CUR Table Name', help='CUR table name')
|
|
423
|
+
@click.option('--s3-output', required=True, prompt='S3 Output Location', help='S3 bucket for Athena query results')
|
|
424
|
+
def setup_cur(database, table, s3_output):
|
|
425
|
+
"""
|
|
426
|
+
Configure CUR (Cost and Usage Report) settings for resource-level queries
|
|
427
|
+
|
|
428
|
+
Saves CUR configuration to ~/.config/cost-calculator/cur_config.json
|
|
429
|
+
|
|
430
|
+
Example:
|
|
431
|
+
cc setup-cur --database my_cur_db --table cur_table --s3-output s3://my-bucket/
|
|
432
|
+
"""
|
|
433
|
+
import json
|
|
434
|
+
|
|
435
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
436
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
437
|
+
|
|
438
|
+
config_file = config_dir / 'cur_config.json'
|
|
439
|
+
|
|
440
|
+
config = {
|
|
441
|
+
'database': database,
|
|
442
|
+
'table': table,
|
|
443
|
+
's3_output': s3_output
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
with open(config_file, 'w') as f:
|
|
447
|
+
json.dump(config, f, indent=2)
|
|
448
|
+
|
|
449
|
+
click.echo(f"✓ CUR configuration saved to {config_file}")
|
|
450
|
+
click.echo(f" Database: {database}")
|
|
451
|
+
click.echo(f" Table: {table}")
|
|
452
|
+
click.echo(f" S3 Output: {s3_output}")
|
|
453
|
+
click.echo("")
|
|
454
|
+
click.echo("You can now use: cc drill --service 'EC2 - Other' --resources")
|
|
455
|
+
|
|
456
|
+
|
|
457
|
+
@cli.command('setup-api')
|
|
458
|
+
@click.option('--api-secret', required=True, prompt=True, hide_input=True, help='COST_API_SECRET value')
|
|
459
|
+
def setup_api(api_secret):
|
|
460
|
+
"""
|
|
461
|
+
Configure COST_API_SECRET for backend API access
|
|
462
|
+
|
|
463
|
+
Saves the API secret to the appropriate location based on your OS:
|
|
464
|
+
- Mac/Linux: ~/.zshrc or ~/.bashrc
|
|
465
|
+
- Windows: User environment variables
|
|
466
|
+
|
|
467
|
+
Example:
|
|
468
|
+
cc setup-api --api-secret your-secret-here
|
|
469
|
+
|
|
470
|
+
Or let it prompt you (input will be hidden):
|
|
471
|
+
cc setup-api
|
|
472
|
+
"""
|
|
473
|
+
system = platform.system()
|
|
474
|
+
|
|
475
|
+
if system == "Windows":
|
|
476
|
+
# Windows: Set user environment variable
|
|
477
|
+
try:
|
|
478
|
+
import winreg
|
|
479
|
+
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, 'Environment', 0, winreg.KEY_SET_VALUE)
|
|
480
|
+
winreg.SetValueEx(key, 'COST_API_SECRET', 0, winreg.REG_SZ, api_secret)
|
|
481
|
+
winreg.CloseKey(key)
|
|
482
|
+
click.echo("✓ COST_API_SECRET saved to Windows user environment variables")
|
|
483
|
+
click.echo(" Please restart your terminal for changes to take effect")
|
|
484
|
+
except Exception as e:
|
|
485
|
+
click.echo(f"✗ Error setting Windows environment variable: {e}", err=True)
|
|
486
|
+
click.echo("\nManual setup:")
|
|
487
|
+
click.echo("1. Open System Properties > Environment Variables")
|
|
488
|
+
click.echo("2. Add new User variable:")
|
|
489
|
+
click.echo(" Name: COST_API_SECRET")
|
|
490
|
+
click.echo(f" Value: {api_secret}")
|
|
491
|
+
return
|
|
492
|
+
else:
|
|
493
|
+
# Mac/Linux: Add to shell profile
|
|
494
|
+
shell = os.environ.get('SHELL', '/bin/bash')
|
|
495
|
+
|
|
496
|
+
if 'zsh' in shell:
|
|
497
|
+
profile_file = Path.home() / '.zshrc'
|
|
498
|
+
else:
|
|
499
|
+
profile_file = Path.home() / '.bashrc'
|
|
500
|
+
|
|
501
|
+
# Check if already exists
|
|
502
|
+
export_line = f'export COST_API_SECRET="{api_secret}"'
|
|
503
|
+
|
|
504
|
+
try:
|
|
505
|
+
if profile_file.exists():
|
|
506
|
+
content = profile_file.read_text()
|
|
507
|
+
if 'COST_API_SECRET' in content:
|
|
508
|
+
# Replace existing
|
|
509
|
+
lines = content.split('\n')
|
|
510
|
+
new_lines = []
|
|
511
|
+
for line in lines:
|
|
512
|
+
if 'COST_API_SECRET' in line and line.strip().startswith('export'):
|
|
513
|
+
new_lines.append(export_line)
|
|
514
|
+
else:
|
|
515
|
+
new_lines.append(line)
|
|
516
|
+
profile_file.write_text('\n'.join(new_lines))
|
|
517
|
+
click.echo(f"✓ Updated COST_API_SECRET in {profile_file}")
|
|
518
|
+
else:
|
|
519
|
+
# Append
|
|
520
|
+
with profile_file.open('a') as f:
|
|
521
|
+
f.write(f'\n# AWS Cost Calculator API Secret\n{export_line}\n')
|
|
522
|
+
click.echo(f"✓ Added COST_API_SECRET to {profile_file}")
|
|
523
|
+
else:
|
|
524
|
+
# Create new file
|
|
525
|
+
profile_file.write_text(f'# AWS Cost Calculator API Secret\n{export_line}\n')
|
|
526
|
+
click.echo(f"✓ Created {profile_file} with COST_API_SECRET")
|
|
527
|
+
|
|
528
|
+
# Also set for current session
|
|
529
|
+
os.environ['COST_API_SECRET'] = api_secret
|
|
530
|
+
click.echo(f"✓ Set COST_API_SECRET for current session")
|
|
531
|
+
click.echo(f"\nTo use in new terminals, run: source {profile_file}")
|
|
532
|
+
|
|
533
|
+
except Exception as e:
|
|
534
|
+
click.echo(f"✗ Error writing to {profile_file}: {e}", err=True)
|
|
535
|
+
click.echo(f"\nManual setup: Add this line to {profile_file}:")
|
|
536
|
+
click.echo(f" {export_line}")
|
|
537
|
+
return
|
|
538
|
+
|
|
539
|
+
|
|
291
540
|
@cli.command()
|
|
292
541
|
@click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
|
|
293
542
|
@click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
|
|
294
543
|
@click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
|
|
295
544
|
@click.option('--window', default=30, help='Number of days to analyze (default: 30)')
|
|
296
545
|
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
297
|
-
|
|
298
|
-
|
|
546
|
+
@click.option('--sso', help='AWS SSO profile name (e.g., my_sso_profile)')
|
|
547
|
+
@click.option('--access-key-id', help='AWS Access Key ID (for static credentials)')
|
|
548
|
+
@click.option('--secret-access-key', help='AWS Secret Access Key (for static credentials)')
|
|
549
|
+
@click.option('--session-token', help='AWS Session Token (for static credentials)')
|
|
550
|
+
def calculate(profile, start_date, offset, window, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
551
|
+
"""
|
|
552
|
+
Calculate AWS costs for the specified period
|
|
553
|
+
|
|
554
|
+
\b
|
|
555
|
+
Authentication Options:
|
|
556
|
+
1. SSO: --sso <profile_name>
|
|
557
|
+
Example: cc calculate --profile myprofile --sso my_sso_profile
|
|
558
|
+
|
|
559
|
+
2. Static Credentials: --access-key-id, --secret-access-key, --session-token
|
|
560
|
+
Example: cc calculate --profile myprofile --access-key-id ASIA... --secret-access-key ... --session-token ...
|
|
561
|
+
|
|
562
|
+
3. Environment Variables: AWS_PROFILE or AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY
|
|
563
|
+
"""
|
|
299
564
|
|
|
300
565
|
# Load profile configuration
|
|
301
566
|
config = load_profile(profile)
|
|
302
567
|
|
|
568
|
+
# Apply authentication options
|
|
569
|
+
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
570
|
+
|
|
303
571
|
# Calculate costs
|
|
304
572
|
result = calculate_costs(
|
|
305
573
|
profile_config=config,
|
|
@@ -545,55 +813,23 @@ def configure(profile, access_key_id, secret_access_key, session_token, region):
|
|
|
545
813
|
@click.option('--profile', required=True, help='Profile name')
|
|
546
814
|
@click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
|
|
547
815
|
@click.option('--output', default='cost_trends.md', help='Output markdown file (default: cost_trends.md)')
|
|
548
|
-
@click.option('--json-output', is_flag=True, help='Output as JSON
|
|
549
|
-
|
|
816
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
817
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
818
|
+
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
819
|
+
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
820
|
+
@click.option('--session-token', help='AWS Session Token')
|
|
821
|
+
def trends(profile, weeks, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
550
822
|
"""Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
|
|
551
823
|
|
|
552
824
|
# Load profile configuration
|
|
553
825
|
config = load_profile(profile)
|
|
554
|
-
|
|
555
|
-
# Initialize boto3 client
|
|
556
|
-
try:
|
|
557
|
-
if 'aws_profile' in config:
|
|
558
|
-
aws_profile = config['aws_profile']
|
|
559
|
-
click.echo(f"AWS Profile: {aws_profile} (SSO)")
|
|
560
|
-
session = boto3.Session(profile_name=aws_profile)
|
|
561
|
-
ce_client = session.client('ce', region_name='us-east-1')
|
|
562
|
-
else:
|
|
563
|
-
creds = config['credentials']
|
|
564
|
-
click.echo(f"AWS Credentials: Static")
|
|
565
|
-
|
|
566
|
-
session_kwargs = {
|
|
567
|
-
'aws_access_key_id': creds['aws_access_key_id'],
|
|
568
|
-
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
569
|
-
'region_name': creds.get('region', 'us-east-1')
|
|
570
|
-
}
|
|
571
|
-
|
|
572
|
-
if 'aws_session_token' in creds:
|
|
573
|
-
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
574
|
-
|
|
575
|
-
session = boto3.Session(**session_kwargs)
|
|
576
|
-
ce_client = session.client('ce')
|
|
577
|
-
|
|
578
|
-
except Exception as e:
|
|
579
|
-
if 'Token has expired' in str(e) or 'sso' in str(e).lower():
|
|
580
|
-
if 'aws_profile' in config:
|
|
581
|
-
raise click.ClickException(
|
|
582
|
-
f"AWS SSO session expired or not initialized.\n"
|
|
583
|
-
f"Run: aws sso login --profile {config['aws_profile']}"
|
|
584
|
-
)
|
|
585
|
-
else:
|
|
586
|
-
raise click.ClickException(
|
|
587
|
-
f"AWS credentials expired.\n"
|
|
588
|
-
f"Run: cc configure --profile {profile}"
|
|
589
|
-
)
|
|
590
|
-
raise
|
|
826
|
+
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
591
827
|
|
|
592
828
|
click.echo(f"Analyzing last {weeks} weeks...")
|
|
593
829
|
click.echo("")
|
|
594
830
|
|
|
595
|
-
#
|
|
596
|
-
trends_data =
|
|
831
|
+
# Execute via API or locally
|
|
832
|
+
trends_data = execute_trends(config, weeks)
|
|
597
833
|
|
|
598
834
|
if json_output:
|
|
599
835
|
# Output as JSON
|
|
@@ -648,41 +884,23 @@ def trends(profile, weeks, output, json_output):
|
|
|
648
884
|
@click.option('--profile', required=True, help='Profile name')
|
|
649
885
|
@click.option('--months', default=6, help='Number of months to analyze (default: 6)')
|
|
650
886
|
@click.option('--output', default='monthly_trends.md', help='Output markdown file (default: monthly_trends.md)')
|
|
651
|
-
@click.option('--json-output', is_flag=True, help='Output as JSON
|
|
652
|
-
|
|
887
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
888
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
889
|
+
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
890
|
+
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
891
|
+
@click.option('--session-token', help='AWS Session Token')
|
|
892
|
+
def monthly(profile, months, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
653
893
|
"""Analyze month-over-month cost trends at service level"""
|
|
654
894
|
|
|
655
|
-
# Load profile
|
|
895
|
+
# Load profile
|
|
656
896
|
config = load_profile(profile)
|
|
657
|
-
|
|
658
|
-
# Initialize boto3 client
|
|
659
|
-
try:
|
|
660
|
-
if 'aws_profile' in config:
|
|
661
|
-
aws_profile = config['aws_profile']
|
|
662
|
-
click.echo(f"AWS Profile: {aws_profile} (SSO)")
|
|
663
|
-
session = boto3.Session(profile_name=aws_profile)
|
|
664
|
-
else:
|
|
665
|
-
# Use static credentials
|
|
666
|
-
creds = config['credentials']
|
|
667
|
-
click.echo("AWS Credentials: Static")
|
|
668
|
-
session = boto3.Session(
|
|
669
|
-
aws_access_key_id=creds['aws_access_key_id'],
|
|
670
|
-
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
671
|
-
aws_session_token=creds.get('aws_session_token')
|
|
672
|
-
)
|
|
673
|
-
|
|
674
|
-
ce_client = session.client('ce', region_name='us-east-1')
|
|
675
|
-
except Exception as e:
|
|
676
|
-
raise click.ClickException(f"Failed to initialize AWS session: {str(e)}")
|
|
677
|
-
|
|
678
|
-
# Get account list
|
|
679
|
-
accounts = config['accounts']
|
|
897
|
+
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
680
898
|
|
|
681
899
|
click.echo(f"Analyzing last {months} months...")
|
|
682
900
|
click.echo("")
|
|
683
901
|
|
|
684
|
-
#
|
|
685
|
-
monthly_data =
|
|
902
|
+
# Execute via API or locally
|
|
903
|
+
monthly_data = execute_monthly(config, months)
|
|
686
904
|
|
|
687
905
|
if json_output:
|
|
688
906
|
# Output as JSON
|
|
@@ -731,5 +949,1027 @@ def monthly(profile, months, output, json_output):
|
|
|
731
949
|
click.echo("")
|
|
732
950
|
|
|
733
951
|
|
|
952
|
+
@cli.command()
|
|
953
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
954
|
+
@click.option('--weeks', default=4, help='Number of weeks to analyze (default: 4)')
|
|
955
|
+
@click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
|
|
956
|
+
@click.option('--account', help='Filter by account ID')
|
|
957
|
+
@click.option('--usage-type', help='Filter by usage type')
|
|
958
|
+
@click.option('--resources', is_flag=True, help='Show individual resource IDs (requires CUR, uses Athena)')
|
|
959
|
+
@click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
|
|
960
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
961
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
962
|
+
@click.option('--access-key-id', help='AWS Access Key ID')
|
|
963
|
+
@click.option('--secret-access-key', help='AWS Secret Access Key')
|
|
964
|
+
@click.option('--session-token', help='AWS Session Token')
|
|
965
|
+
def drill(profile, weeks, service, account, usage_type, resources, output, json_output, sso, access_key_id, secret_access_key, session_token):
|
|
966
|
+
"""
|
|
967
|
+
Drill down into cost changes by service, account, or usage type
|
|
968
|
+
|
|
969
|
+
Add --resources flag to see individual resource IDs and costs (requires CUR data via Athena)
|
|
970
|
+
"""
|
|
971
|
+
|
|
972
|
+
# Load profile
|
|
973
|
+
config = load_profile(profile)
|
|
974
|
+
config = apply_auth_options(config, sso, access_key_id, secret_access_key, session_token)
|
|
975
|
+
|
|
976
|
+
# Show filters
|
|
977
|
+
click.echo(f"Analyzing last {weeks} weeks...")
|
|
978
|
+
if service:
|
|
979
|
+
click.echo(f" Service filter: {service}")
|
|
980
|
+
if account:
|
|
981
|
+
click.echo(f" Account filter: {account}")
|
|
982
|
+
if usage_type:
|
|
983
|
+
click.echo(f" Usage type filter: {usage_type}")
|
|
984
|
+
if resources:
|
|
985
|
+
click.echo(f" Mode: Resource-level (CUR via Athena)")
|
|
986
|
+
click.echo("")
|
|
987
|
+
|
|
988
|
+
# Execute via API or locally
|
|
989
|
+
drill_data = execute_drill(config, weeks, service, account, usage_type, resources)
|
|
990
|
+
|
|
991
|
+
# Handle resource-level output differently
|
|
992
|
+
if resources:
|
|
993
|
+
from cost_calculator.cur import format_resource_output
|
|
994
|
+
output_text = format_resource_output(drill_data)
|
|
995
|
+
click.echo(output_text)
|
|
996
|
+
return
|
|
997
|
+
|
|
998
|
+
if json_output:
|
|
999
|
+
# Output as JSON
|
|
1000
|
+
output_data = {
|
|
1001
|
+
'generated': datetime.now().isoformat(),
|
|
1002
|
+
'weeks': weeks,
|
|
1003
|
+
'filters': drill_data['filters'],
|
|
1004
|
+
'group_by': drill_data['group_by'],
|
|
1005
|
+
'comparisons': []
|
|
1006
|
+
}
|
|
1007
|
+
|
|
1008
|
+
for comparison in drill_data['comparisons']:
|
|
1009
|
+
output_data['comparisons'].append({
|
|
1010
|
+
'prev_week': comparison['prev_week']['label'],
|
|
1011
|
+
'curr_week': comparison['curr_week']['label'],
|
|
1012
|
+
'increases': comparison['increases'],
|
|
1013
|
+
'decreases': comparison['decreases'],
|
|
1014
|
+
'total_increase': comparison['total_increase'],
|
|
1015
|
+
'total_decrease': comparison['total_decrease']
|
|
1016
|
+
})
|
|
1017
|
+
|
|
1018
|
+
click.echo(json.dumps(output_data, indent=2))
|
|
1019
|
+
else:
|
|
1020
|
+
# Generate markdown report
|
|
1021
|
+
markdown = format_drill_down_markdown(drill_data)
|
|
1022
|
+
|
|
1023
|
+
# Save to file
|
|
1024
|
+
with open(output, 'w') as f:
|
|
1025
|
+
f.write(markdown)
|
|
1026
|
+
|
|
1027
|
+
click.echo(f"✓ Drill-down report saved to {output}")
|
|
1028
|
+
click.echo("")
|
|
1029
|
+
|
|
1030
|
+
# Show summary
|
|
1031
|
+
group_by_label = {
|
|
1032
|
+
'SERVICE': 'services',
|
|
1033
|
+
'LINKED_ACCOUNT': 'accounts',
|
|
1034
|
+
'USAGE_TYPE': 'usage types',
|
|
1035
|
+
'REGION': 'regions'
|
|
1036
|
+
}.get(drill_data['group_by'], 'items')
|
|
1037
|
+
|
|
1038
|
+
click.echo(f"Showing top {group_by_label}:")
|
|
1039
|
+
for comparison in drill_data['comparisons']:
|
|
1040
|
+
prev_week = comparison['prev_week']['label']
|
|
1041
|
+
curr_week = comparison['curr_week']['label']
|
|
1042
|
+
num_increases = len(comparison['increases'])
|
|
1043
|
+
num_decreases = len(comparison['decreases'])
|
|
1044
|
+
|
|
1045
|
+
click.echo(f"{prev_week} → {curr_week}")
|
|
1046
|
+
click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
|
|
1047
|
+
|
|
1048
|
+
if comparison['increases']:
|
|
1049
|
+
top = comparison['increases'][0]
|
|
1050
|
+
click.echo(f" Top: {top['dimension'][:50]} (+${top['change']:,.2f})")
|
|
1051
|
+
|
|
1052
|
+
click.echo("")
|
|
1053
|
+
|
|
1054
|
+
|
|
1055
|
+
@cli.command()
|
|
1056
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1057
|
+
@click.option('--type', 'analysis_type', default='summary',
|
|
1058
|
+
type=click.Choice(['summary', 'volatility', 'trends', 'search']),
|
|
1059
|
+
help='Analysis type')
|
|
1060
|
+
@click.option('--weeks', default=12, help='Number of weeks (default: 12)')
|
|
1061
|
+
@click.option('--pattern', help='Service search pattern (for search type)')
|
|
1062
|
+
@click.option('--min-cost', type=float, help='Minimum cost filter (for search type)')
|
|
1063
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
1064
|
+
def analyze(profile, analysis_type, weeks, pattern, min_cost, json_output):
|
|
1065
|
+
"""Perform pandas-based analysis (aggregations, volatility, trends, search)"""
|
|
1066
|
+
|
|
1067
|
+
config = load_profile(profile)
|
|
1068
|
+
|
|
1069
|
+
if not json_output:
|
|
1070
|
+
click.echo(f"Running {analysis_type} analysis for {weeks} weeks...")
|
|
1071
|
+
|
|
1072
|
+
from cost_calculator.executor import execute_analyze
|
|
1073
|
+
result = execute_analyze(config, weeks, analysis_type, pattern, min_cost)
|
|
1074
|
+
|
|
1075
|
+
if json_output:
|
|
1076
|
+
import json
|
|
1077
|
+
click.echo(json.dumps(result, indent=2, default=str))
|
|
1078
|
+
else:
|
|
1079
|
+
# Format output based on type
|
|
1080
|
+
if analysis_type == 'summary':
|
|
1081
|
+
click.echo(f"\n📊 Summary ({result.get('total_services', 0)} services)")
|
|
1082
|
+
click.echo(f"Weeks analyzed: {result.get('weeks_analyzed', 0)}")
|
|
1083
|
+
click.echo(f"\nTop 10 Services (by total change):")
|
|
1084
|
+
for svc in result.get('services', [])[:10]:
|
|
1085
|
+
click.echo(f" {svc['service']}")
|
|
1086
|
+
click.echo(f" Total: ${svc['change_sum']:,.2f}")
|
|
1087
|
+
click.echo(f" Average: ${svc['change_mean']:,.2f}")
|
|
1088
|
+
click.echo(f" Volatility: {svc['volatility']:.3f}")
|
|
1089
|
+
|
|
1090
|
+
elif analysis_type == 'volatility':
|
|
1091
|
+
click.echo(f"\n📈 High Volatility Services:")
|
|
1092
|
+
for svc in result.get('high_volatility_services', [])[:10]:
|
|
1093
|
+
click.echo(f" {svc['service']}: CV={svc['coefficient_of_variation']:.3f}")
|
|
1094
|
+
|
|
1095
|
+
outliers = result.get('outliers', [])
|
|
1096
|
+
if outliers:
|
|
1097
|
+
click.echo(f"\n⚠️ Outliers ({len(outliers)}):")
|
|
1098
|
+
for o in outliers[:5]:
|
|
1099
|
+
click.echo(f" {o['service']} ({o['week']}): ${o['change']:,.2f} (z={o['z_score']:.2f})")
|
|
1100
|
+
|
|
1101
|
+
elif analysis_type == 'trends':
|
|
1102
|
+
inc = result.get('increasing_trends', [])
|
|
1103
|
+
dec = result.get('decreasing_trends', [])
|
|
1104
|
+
|
|
1105
|
+
click.echo(f"\n📈 Increasing Trends ({len(inc)}):")
|
|
1106
|
+
for t in inc[:5]:
|
|
1107
|
+
click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
|
|
1108
|
+
|
|
1109
|
+
click.echo(f"\n📉 Decreasing Trends ({len(dec)}):")
|
|
1110
|
+
for t in dec[:5]:
|
|
1111
|
+
click.echo(f" {t['service']}: ${t['avg_change']:,.2f}/week")
|
|
1112
|
+
|
|
1113
|
+
elif analysis_type == 'search':
|
|
1114
|
+
matches = result.get('matches', [])
|
|
1115
|
+
click.echo(f"\n🔍 Search Results ({len(matches)} matches)")
|
|
1116
|
+
if pattern:
|
|
1117
|
+
click.echo(f"Pattern: {pattern}")
|
|
1118
|
+
if min_cost:
|
|
1119
|
+
click.echo(f"Min cost: ${min_cost:,.2f}")
|
|
1120
|
+
|
|
1121
|
+
for m in matches[:20]:
|
|
1122
|
+
click.echo(f" {m['service']}: ${m['curr_cost']:,.2f}")
|
|
1123
|
+
|
|
1124
|
+
|
|
1125
|
+
@cli.command()
|
|
1126
|
+
@click.argument('operation', type=click.Choice(['list', 'get', 'create', 'update', 'delete']))
|
|
1127
|
+
@click.option('--name', help='Profile name')
|
|
1128
|
+
@click.option('--accounts', help='Comma-separated account IDs')
|
|
1129
|
+
@click.option('--description', help='Profile description')
|
|
1130
|
+
def profile(operation, name, accounts, description):
|
|
1131
|
+
"""Manage profiles (CRUD operations)"""
|
|
1132
|
+
|
|
1133
|
+
from cost_calculator.executor import execute_profile_operation
|
|
1134
|
+
|
|
1135
|
+
# Parse accounts if provided
|
|
1136
|
+
account_list = None
|
|
1137
|
+
if accounts:
|
|
1138
|
+
account_list = [a.strip() for a in accounts.split(',')]
|
|
1139
|
+
|
|
1140
|
+
result = execute_profile_operation(
|
|
1141
|
+
operation=operation,
|
|
1142
|
+
profile_name=name,
|
|
1143
|
+
accounts=account_list,
|
|
1144
|
+
description=description
|
|
1145
|
+
)
|
|
1146
|
+
|
|
1147
|
+
if operation == 'list':
|
|
1148
|
+
profiles = result.get('profiles', [])
|
|
1149
|
+
click.echo(f"\n📋 Profiles ({len(profiles)}):")
|
|
1150
|
+
for p in profiles:
|
|
1151
|
+
click.echo(f" {p['profile_name']}: {len(p.get('accounts', []))} accounts")
|
|
1152
|
+
if p.get('description'):
|
|
1153
|
+
click.echo(f" {p['description']}")
|
|
1154
|
+
|
|
1155
|
+
elif operation == 'get':
|
|
1156
|
+
profile_data = result.get('profile', {})
|
|
1157
|
+
click.echo(f"\n📋 Profile: {profile_data.get('profile_name')}")
|
|
1158
|
+
click.echo(f"Accounts: {len(profile_data.get('accounts', []))}")
|
|
1159
|
+
if profile_data.get('description'):
|
|
1160
|
+
click.echo(f"Description: {profile_data['description']}")
|
|
1161
|
+
click.echo(f"\nAccounts:")
|
|
1162
|
+
for acc in profile_data.get('accounts', []):
|
|
1163
|
+
click.echo(f" {acc}")
|
|
1164
|
+
|
|
1165
|
+
else:
|
|
1166
|
+
click.echo(result.get('message', 'Operation completed'))
|
|
1167
|
+
|
|
1168
|
+
|
|
1169
|
+
@cli.command()
|
|
1170
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1171
|
+
@click.option('--sso', help='AWS SSO profile to use')
|
|
1172
|
+
@click.option('--weeks', default=8, help='Number of weeks to analyze')
|
|
1173
|
+
@click.option('--account', help='Focus on specific account ID')
|
|
1174
|
+
@click.option('--service', help='Focus on specific service')
|
|
1175
|
+
@click.option('--no-cloudtrail', is_flag=True, help='Skip CloudTrail analysis (faster)')
|
|
1176
|
+
@click.option('--output', default='investigation_report.md', help='Output file path')
|
|
1177
|
+
def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
|
|
1178
|
+
"""
|
|
1179
|
+
Multi-stage cost investigation:
|
|
1180
|
+
1. Analyze cost trends and drill-downs
|
|
1181
|
+
2. Inventory actual resources in problem accounts
|
|
1182
|
+
3. Analyze CloudTrail events (optional)
|
|
1183
|
+
4. Generate comprehensive report
|
|
1184
|
+
"""
|
|
1185
|
+
from cost_calculator.executor import execute_trends, execute_drill, get_credentials_dict
|
|
1186
|
+
from cost_calculator.api_client import call_lambda_api, is_api_configured
|
|
1187
|
+
from cost_calculator.forensics import format_investigation_report
|
|
1188
|
+
from datetime import datetime, timedelta
|
|
1189
|
+
|
|
1190
|
+
click.echo("=" * 80)
|
|
1191
|
+
click.echo("COST INVESTIGATION")
|
|
1192
|
+
click.echo("=" * 80)
|
|
1193
|
+
click.echo(f"Profile: {profile}")
|
|
1194
|
+
click.echo(f"Weeks: {weeks}")
|
|
1195
|
+
if account:
|
|
1196
|
+
click.echo(f"Account: {account}")
|
|
1197
|
+
if service:
|
|
1198
|
+
click.echo(f"Service: {service}")
|
|
1199
|
+
click.echo("")
|
|
1200
|
+
|
|
1201
|
+
# Load profile
|
|
1202
|
+
config = load_profile(profile)
|
|
1203
|
+
|
|
1204
|
+
# Override with SSO if provided
|
|
1205
|
+
if sso:
|
|
1206
|
+
config['aws_profile'] = sso
|
|
1207
|
+
|
|
1208
|
+
# Validate that we have a way to get credentials
|
|
1209
|
+
if 'aws_profile' not in config and 'credentials' not in config:
|
|
1210
|
+
import subprocess
|
|
1211
|
+
try:
|
|
1212
|
+
result = subprocess.run(
|
|
1213
|
+
['aws', 'configure', 'list-profiles'],
|
|
1214
|
+
capture_output=True,
|
|
1215
|
+
text=True,
|
|
1216
|
+
timeout=5
|
|
1217
|
+
)
|
|
1218
|
+
available = result.stdout.strip().split('\n') if result.returncode == 0 else []
|
|
1219
|
+
suggestion = f"\nAvailable AWS profiles: {', '.join(available[:5])}" if available else ""
|
|
1220
|
+
except:
|
|
1221
|
+
suggestion = ""
|
|
1222
|
+
|
|
1223
|
+
raise click.ClickException(
|
|
1224
|
+
f"Profile '{profile}' has no AWS authentication configured.\n"
|
|
1225
|
+
f"Use --sso flag to specify your AWS SSO profile:\n"
|
|
1226
|
+
f" cc investigate --profile {profile} --sso YOUR_AWS_PROFILE{suggestion}"
|
|
1227
|
+
)
|
|
1228
|
+
|
|
1229
|
+
# Step 1: Cost Analysis
|
|
1230
|
+
click.echo("Step 1/3: Analyzing cost trends...")
|
|
1231
|
+
try:
|
|
1232
|
+
trends_data = execute_trends(config, weeks)
|
|
1233
|
+
click.echo(f"✓ Found cost data for {weeks} weeks")
|
|
1234
|
+
except Exception as e:
|
|
1235
|
+
click.echo(f"✗ Error analyzing trends: {str(e)}")
|
|
1236
|
+
trends_data = None
|
|
1237
|
+
|
|
1238
|
+
# Step 2: Drill-down
|
|
1239
|
+
click.echo("\nStep 2/3: Drilling down into costs...")
|
|
1240
|
+
drill_data = None
|
|
1241
|
+
if service or account:
|
|
1242
|
+
try:
|
|
1243
|
+
drill_data = execute_drill(config, weeks, service, account, None, False)
|
|
1244
|
+
click.echo(f"✓ Drill-down complete")
|
|
1245
|
+
except Exception as e:
|
|
1246
|
+
click.echo(f"✗ Error in drill-down: {str(e)}")
|
|
1247
|
+
|
|
1248
|
+
# Step 3: Resource Inventory
|
|
1249
|
+
click.echo("\nStep 3/3: Inventorying resources...")
|
|
1250
|
+
inventories = []
|
|
1251
|
+
cloudtrail_analyses = []
|
|
1252
|
+
|
|
1253
|
+
# Determine which accounts to investigate
|
|
1254
|
+
accounts_to_investigate = []
|
|
1255
|
+
if account:
|
|
1256
|
+
accounts_to_investigate = [account]
|
|
1257
|
+
else:
|
|
1258
|
+
# Extract top cost accounts from trends/drill data
|
|
1259
|
+
# For now, we'll need the user to specify
|
|
1260
|
+
click.echo("⚠️ No account specified. Use --account to inventory resources.")
|
|
1261
|
+
|
|
1262
|
+
# For each account, do inventory and CloudTrail via backend API
|
|
1263
|
+
for acc_id in accounts_to_investigate:
|
|
1264
|
+
click.echo(f"\n Investigating account {acc_id}...")
|
|
1265
|
+
|
|
1266
|
+
# Get credentials (SSO or static)
|
|
1267
|
+
account_creds = get_credentials_dict(config)
|
|
1268
|
+
if not account_creds:
|
|
1269
|
+
click.echo(f" ⚠️ No credentials available for account")
|
|
1270
|
+
continue
|
|
1271
|
+
|
|
1272
|
+
# Inventory resources via backend API only
|
|
1273
|
+
if not is_api_configured():
|
|
1274
|
+
click.echo(f" ✗ API not configured. Set COST_API_SECRET environment variable.")
|
|
1275
|
+
continue
|
|
1276
|
+
|
|
1277
|
+
try:
|
|
1278
|
+
regions = ['us-west-2', 'us-east-1', 'eu-west-1']
|
|
1279
|
+
for region in regions:
|
|
1280
|
+
try:
|
|
1281
|
+
inv = call_lambda_api(
|
|
1282
|
+
'forensics',
|
|
1283
|
+
account_creds,
|
|
1284
|
+
[], # accounts not needed for forensics
|
|
1285
|
+
operation='inventory',
|
|
1286
|
+
account_id=acc_id,
|
|
1287
|
+
region=region
|
|
1288
|
+
)
|
|
1289
|
+
|
|
1290
|
+
if not inv.get('error'):
|
|
1291
|
+
inventories.append(inv)
|
|
1292
|
+
click.echo(f" ✓ Inventory complete for {region}")
|
|
1293
|
+
click.echo(f" - EC2: {len(inv['ec2_instances'])} instances")
|
|
1294
|
+
click.echo(f" - EFS: {len(inv['efs_file_systems'])} file systems ({inv.get('total_efs_size_gb', 0):,.0f} GB)")
|
|
1295
|
+
click.echo(f" - ELB: {len(inv['load_balancers'])} load balancers")
|
|
1296
|
+
break
|
|
1297
|
+
except Exception as e:
|
|
1298
|
+
continue
|
|
1299
|
+
except Exception as e:
|
|
1300
|
+
click.echo(f" ✗ Inventory error: {str(e)}")
|
|
1301
|
+
|
|
1302
|
+
# CloudTrail analysis via backend API only
|
|
1303
|
+
if not no_cloudtrail:
|
|
1304
|
+
if not is_api_configured():
|
|
1305
|
+
click.echo(f" ✗ CloudTrail skipped: API not configured")
|
|
1306
|
+
else:
|
|
1307
|
+
try:
|
|
1308
|
+
start_date = (datetime.now() - timedelta(days=weeks * 7)).isoformat() + 'Z'
|
|
1309
|
+
end_date = datetime.now().isoformat() + 'Z'
|
|
1310
|
+
|
|
1311
|
+
ct_analysis = call_lambda_api(
|
|
1312
|
+
'forensics',
|
|
1313
|
+
account_creds,
|
|
1314
|
+
[],
|
|
1315
|
+
operation='cloudtrail',
|
|
1316
|
+
account_id=acc_id,
|
|
1317
|
+
start_date=start_date,
|
|
1318
|
+
end_date=end_date,
|
|
1319
|
+
region='us-west-2'
|
|
1320
|
+
)
|
|
1321
|
+
|
|
1322
|
+
cloudtrail_analyses.append(ct_analysis)
|
|
1323
|
+
|
|
1324
|
+
if ct_analysis.get('error'):
|
|
1325
|
+
click.echo(f" ⚠️ CloudTrail: {ct_analysis['error']}")
|
|
1326
|
+
else:
|
|
1327
|
+
click.echo(f" ✓ CloudTrail analysis complete")
|
|
1328
|
+
click.echo(f" - {len(ct_analysis['event_summary'])} event types")
|
|
1329
|
+
click.echo(f" - {len(ct_analysis['write_events'])} resource changes")
|
|
1330
|
+
except Exception as e:
|
|
1331
|
+
click.echo(f" ✗ CloudTrail error: {str(e)}")
|
|
1332
|
+
|
|
1333
|
+
# Generate report
|
|
1334
|
+
click.echo(f"\nGenerating report...")
|
|
1335
|
+
report = format_investigation_report(trends_data, inventories, cloudtrail_analyses if not no_cloudtrail else None)
|
|
1336
|
+
|
|
1337
|
+
# Write to file
|
|
1338
|
+
with open(output, 'w') as f:
|
|
1339
|
+
f.write(report)
|
|
1340
|
+
|
|
1341
|
+
click.echo(f"\n✓ Investigation complete!")
|
|
1342
|
+
click.echo(f"✓ Report saved to: {output}")
|
|
1343
|
+
click.echo("")
|
|
1344
|
+
|
|
1345
|
+
|
|
1346
|
+
def find_account_profile(account_id):
|
|
1347
|
+
"""
|
|
1348
|
+
Find the SSO profile name for a given account ID
|
|
1349
|
+
Returns profile name or None
|
|
1350
|
+
"""
|
|
1351
|
+
import subprocess
|
|
1352
|
+
|
|
1353
|
+
try:
|
|
1354
|
+
# Get list of profiles
|
|
1355
|
+
result = subprocess.run(
|
|
1356
|
+
['aws', 'configure', 'list-profiles'],
|
|
1357
|
+
capture_output=True,
|
|
1358
|
+
text=True
|
|
1359
|
+
)
|
|
1360
|
+
|
|
1361
|
+
profiles = result.stdout.strip().split('\n')
|
|
1362
|
+
|
|
1363
|
+
# Check each profile
|
|
1364
|
+
for profile in profiles:
|
|
1365
|
+
try:
|
|
1366
|
+
result = subprocess.run(
|
|
1367
|
+
['aws', 'sts', 'get-caller-identity', '--profile', profile],
|
|
1368
|
+
capture_output=True,
|
|
1369
|
+
text=True,
|
|
1370
|
+
timeout=5
|
|
1371
|
+
)
|
|
1372
|
+
|
|
1373
|
+
if account_id in result.stdout:
|
|
1374
|
+
return profile
|
|
1375
|
+
except:
|
|
1376
|
+
continue
|
|
1377
|
+
|
|
1378
|
+
return None
|
|
1379
|
+
except:
|
|
1380
|
+
return None
|
|
1381
|
+
|
|
1382
|
+
|
|
1383
|
+
@cli.command()
|
|
1384
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1385
|
+
@click.option('--start-date', help='Start date (YYYY-MM-DD)')
|
|
1386
|
+
@click.option('--end-date', help='End date (YYYY-MM-DD)')
|
|
1387
|
+
@click.option('--days', type=int, default=10, help='Number of days to analyze (default: 10)')
|
|
1388
|
+
@click.option('--service', help='Filter by service name')
|
|
1389
|
+
@click.option('--account', help='Filter by account ID')
|
|
1390
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1391
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
1392
|
+
def daily(profile, start_date, end_date, days, service, account, sso, output_json):
|
|
1393
|
+
"""
|
|
1394
|
+
Get daily cost breakdown with granular detail
|
|
1395
|
+
|
|
1396
|
+
Example:
|
|
1397
|
+
cc daily --profile khoros --days 10 --service AmazonCloudWatch --account 820054669588
|
|
1398
|
+
"""
|
|
1399
|
+
# Load profile
|
|
1400
|
+
config = load_profile(profile)
|
|
1401
|
+
|
|
1402
|
+
# Apply SSO if provided
|
|
1403
|
+
if sso:
|
|
1404
|
+
config['aws_profile'] = sso
|
|
1405
|
+
|
|
1406
|
+
# Calculate date range
|
|
1407
|
+
if end_date:
|
|
1408
|
+
end = datetime.strptime(end_date, '%Y-%m-%d')
|
|
1409
|
+
else:
|
|
1410
|
+
end = datetime.now()
|
|
1411
|
+
|
|
1412
|
+
if start_date:
|
|
1413
|
+
start = datetime.strptime(start_date, '%Y-%m-%d')
|
|
1414
|
+
else:
|
|
1415
|
+
start = end - timedelta(days=days)
|
|
1416
|
+
|
|
1417
|
+
click.echo(f"Daily breakdown: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
|
|
1418
|
+
if service:
|
|
1419
|
+
click.echo(f"Service filter: {service}")
|
|
1420
|
+
if account:
|
|
1421
|
+
click.echo(f"Account filter: {account}")
|
|
1422
|
+
click.echo("")
|
|
1423
|
+
|
|
1424
|
+
# Get credentials
|
|
1425
|
+
try:
|
|
1426
|
+
if 'aws_profile' in config:
|
|
1427
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1428
|
+
else:
|
|
1429
|
+
creds = config['credentials']
|
|
1430
|
+
session = boto3.Session(
|
|
1431
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1432
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1433
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1434
|
+
)
|
|
1435
|
+
|
|
1436
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
1437
|
+
|
|
1438
|
+
# Build filter
|
|
1439
|
+
filter_parts = []
|
|
1440
|
+
|
|
1441
|
+
# Account filter
|
|
1442
|
+
if account:
|
|
1443
|
+
filter_parts.append({
|
|
1444
|
+
"Dimensions": {
|
|
1445
|
+
"Key": "LINKED_ACCOUNT",
|
|
1446
|
+
"Values": [account]
|
|
1447
|
+
}
|
|
1448
|
+
})
|
|
1449
|
+
else:
|
|
1450
|
+
filter_parts.append({
|
|
1451
|
+
"Dimensions": {
|
|
1452
|
+
"Key": "LINKED_ACCOUNT",
|
|
1453
|
+
"Values": config['accounts']
|
|
1454
|
+
}
|
|
1455
|
+
})
|
|
1456
|
+
|
|
1457
|
+
# Service filter
|
|
1458
|
+
if service:
|
|
1459
|
+
filter_parts.append({
|
|
1460
|
+
"Dimensions": {
|
|
1461
|
+
"Key": "SERVICE",
|
|
1462
|
+
"Values": [service]
|
|
1463
|
+
}
|
|
1464
|
+
})
|
|
1465
|
+
|
|
1466
|
+
# Exclude support and tax
|
|
1467
|
+
filter_parts.append({
|
|
1468
|
+
"Not": {
|
|
1469
|
+
"Dimensions": {
|
|
1470
|
+
"Key": "RECORD_TYPE",
|
|
1471
|
+
"Values": ["Tax", "Support"]
|
|
1472
|
+
}
|
|
1473
|
+
}
|
|
1474
|
+
})
|
|
1475
|
+
|
|
1476
|
+
cost_filter = {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
|
|
1477
|
+
|
|
1478
|
+
# Get daily costs
|
|
1479
|
+
response = ce_client.get_cost_and_usage(
|
|
1480
|
+
TimePeriod={
|
|
1481
|
+
'Start': start.strftime('%Y-%m-%d'),
|
|
1482
|
+
'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1483
|
+
},
|
|
1484
|
+
Granularity='DAILY',
|
|
1485
|
+
Metrics=['UnblendedCost'],
|
|
1486
|
+
Filter=cost_filter
|
|
1487
|
+
)
|
|
1488
|
+
|
|
1489
|
+
# Collect results
|
|
1490
|
+
daily_costs = []
|
|
1491
|
+
total = 0
|
|
1492
|
+
for day in response['ResultsByTime']:
|
|
1493
|
+
date = day['TimePeriod']['Start']
|
|
1494
|
+
cost = float(day['Total']['UnblendedCost']['Amount'])
|
|
1495
|
+
total += cost
|
|
1496
|
+
daily_costs.append({'date': date, 'cost': cost})
|
|
1497
|
+
|
|
1498
|
+
num_days = len(response['ResultsByTime'])
|
|
1499
|
+
daily_avg = total / num_days if num_days > 0 else 0
|
|
1500
|
+
annual = daily_avg * 365
|
|
1501
|
+
|
|
1502
|
+
# Output results
|
|
1503
|
+
if output_json:
|
|
1504
|
+
import json
|
|
1505
|
+
result = {
|
|
1506
|
+
'period': {
|
|
1507
|
+
'start': start.strftime('%Y-%m-%d'),
|
|
1508
|
+
'end': end.strftime('%Y-%m-%d'),
|
|
1509
|
+
'days': num_days
|
|
1510
|
+
},
|
|
1511
|
+
'filters': {
|
|
1512
|
+
'service': service,
|
|
1513
|
+
'account': account
|
|
1514
|
+
},
|
|
1515
|
+
'daily_costs': daily_costs,
|
|
1516
|
+
'summary': {
|
|
1517
|
+
'total': total,
|
|
1518
|
+
'daily_avg': daily_avg,
|
|
1519
|
+
'annual_projection': annual
|
|
1520
|
+
}
|
|
1521
|
+
}
|
|
1522
|
+
click.echo(json.dumps(result, indent=2))
|
|
1523
|
+
else:
|
|
1524
|
+
click.echo("Date | Cost")
|
|
1525
|
+
click.echo("-----------|-----------")
|
|
1526
|
+
for item in daily_costs:
|
|
1527
|
+
click.echo(f"{item['date']} | ${item['cost']:,.2f}")
|
|
1528
|
+
click.echo("-----------|-----------")
|
|
1529
|
+
click.echo(f"Total | ${total:,.2f}")
|
|
1530
|
+
click.echo(f"Daily Avg | ${daily_avg:,.2f}")
|
|
1531
|
+
click.echo(f"Annual | ${annual:,.0f}")
|
|
1532
|
+
|
|
1533
|
+
except Exception as e:
|
|
1534
|
+
raise click.ClickException(f"Failed to get daily costs: {e}")
|
|
1535
|
+
|
|
1536
|
+
|
|
1537
|
+
@cli.command()
|
|
1538
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1539
|
+
@click.option('--account', help='Account ID to compare')
|
|
1540
|
+
@click.option('--service', help='Service to compare')
|
|
1541
|
+
@click.option('--before', required=True, help='Before period (YYYY-MM-DD:YYYY-MM-DD)')
|
|
1542
|
+
@click.option('--after', required=True, help='After period (YYYY-MM-DD:YYYY-MM-DD)')
|
|
1543
|
+
@click.option('--expected-reduction', type=float, help='Expected reduction percentage')
|
|
1544
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1545
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
1546
|
+
def compare(profile, account, service, before, after, expected_reduction, sso, output_json):
|
|
1547
|
+
"""
|
|
1548
|
+
Compare costs between two periods
|
|
1549
|
+
|
|
1550
|
+
Example:
|
|
1551
|
+
cc compare --profile khoros --account 180770971501 --service AmazonCloudWatch \
|
|
1552
|
+
--before "2025-10-28:2025-11-06" --after "2025-11-17:2025-11-26" --expected-reduction 50
|
|
1553
|
+
"""
|
|
1554
|
+
# Load profile
|
|
1555
|
+
config = load_profile(profile)
|
|
1556
|
+
|
|
1557
|
+
# Apply SSO if provided
|
|
1558
|
+
if sso:
|
|
1559
|
+
config['aws_profile'] = sso
|
|
1560
|
+
|
|
1561
|
+
# Parse periods
|
|
1562
|
+
try:
|
|
1563
|
+
before_start, before_end = before.split(':')
|
|
1564
|
+
after_start, after_end = after.split(':')
|
|
1565
|
+
except ValueError:
|
|
1566
|
+
raise click.ClickException("Period format must be 'YYYY-MM-DD:YYYY-MM-DD'")
|
|
1567
|
+
|
|
1568
|
+
if not output_json:
|
|
1569
|
+
click.echo(f"Comparing periods:")
|
|
1570
|
+
click.echo(f" Before: {before_start} to {before_end}")
|
|
1571
|
+
click.echo(f" After: {after_start} to {after_end}")
|
|
1572
|
+
if service:
|
|
1573
|
+
click.echo(f" Service: {service}")
|
|
1574
|
+
if account:
|
|
1575
|
+
click.echo(f" Account: {account}")
|
|
1576
|
+
click.echo("")
|
|
1577
|
+
|
|
1578
|
+
# Get credentials
|
|
1579
|
+
try:
|
|
1580
|
+
if 'aws_profile' in config:
|
|
1581
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1582
|
+
else:
|
|
1583
|
+
creds = config['credentials']
|
|
1584
|
+
session = boto3.Session(
|
|
1585
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1586
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1587
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1588
|
+
)
|
|
1589
|
+
|
|
1590
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
1591
|
+
|
|
1592
|
+
# Build filter
|
|
1593
|
+
def build_filter():
|
|
1594
|
+
filter_parts = []
|
|
1595
|
+
|
|
1596
|
+
if account:
|
|
1597
|
+
filter_parts.append({
|
|
1598
|
+
"Dimensions": {
|
|
1599
|
+
"Key": "LINKED_ACCOUNT",
|
|
1600
|
+
"Values": [account]
|
|
1601
|
+
}
|
|
1602
|
+
})
|
|
1603
|
+
else:
|
|
1604
|
+
filter_parts.append({
|
|
1605
|
+
"Dimensions": {
|
|
1606
|
+
"Key": "LINKED_ACCOUNT",
|
|
1607
|
+
"Values": config['accounts']
|
|
1608
|
+
}
|
|
1609
|
+
})
|
|
1610
|
+
|
|
1611
|
+
if service:
|
|
1612
|
+
filter_parts.append({
|
|
1613
|
+
"Dimensions": {
|
|
1614
|
+
"Key": "SERVICE",
|
|
1615
|
+
"Values": [service]
|
|
1616
|
+
}
|
|
1617
|
+
})
|
|
1618
|
+
|
|
1619
|
+
filter_parts.append({
|
|
1620
|
+
"Not": {
|
|
1621
|
+
"Dimensions": {
|
|
1622
|
+
"Key": "RECORD_TYPE",
|
|
1623
|
+
"Values": ["Tax", "Support"]
|
|
1624
|
+
}
|
|
1625
|
+
}
|
|
1626
|
+
})
|
|
1627
|
+
|
|
1628
|
+
return {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
|
|
1629
|
+
|
|
1630
|
+
cost_filter = build_filter()
|
|
1631
|
+
|
|
1632
|
+
# Get before period costs
|
|
1633
|
+
before_response = ce_client.get_cost_and_usage(
|
|
1634
|
+
TimePeriod={
|
|
1635
|
+
'Start': before_start,
|
|
1636
|
+
'End': (datetime.strptime(before_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1637
|
+
},
|
|
1638
|
+
Granularity='DAILY',
|
|
1639
|
+
Metrics=['UnblendedCost'],
|
|
1640
|
+
Filter=cost_filter
|
|
1641
|
+
)
|
|
1642
|
+
|
|
1643
|
+
# Get after period costs
|
|
1644
|
+
after_response = ce_client.get_cost_and_usage(
|
|
1645
|
+
TimePeriod={
|
|
1646
|
+
'Start': after_start,
|
|
1647
|
+
'End': (datetime.strptime(after_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1648
|
+
},
|
|
1649
|
+
Granularity='DAILY',
|
|
1650
|
+
Metrics=['UnblendedCost'],
|
|
1651
|
+
Filter=cost_filter
|
|
1652
|
+
)
|
|
1653
|
+
|
|
1654
|
+
# Calculate totals
|
|
1655
|
+
before_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in before_response['ResultsByTime'])
|
|
1656
|
+
after_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in after_response['ResultsByTime'])
|
|
1657
|
+
|
|
1658
|
+
before_days = len(before_response['ResultsByTime'])
|
|
1659
|
+
after_days = len(after_response['ResultsByTime'])
|
|
1660
|
+
|
|
1661
|
+
before_daily = before_total / before_days if before_days > 0 else 0
|
|
1662
|
+
after_daily = after_total / after_days if after_days > 0 else 0
|
|
1663
|
+
|
|
1664
|
+
reduction = before_daily - after_daily
|
|
1665
|
+
reduction_pct = (reduction / before_daily * 100) if before_daily > 0 else 0
|
|
1666
|
+
annual_savings = reduction * 365
|
|
1667
|
+
|
|
1668
|
+
# Output results
|
|
1669
|
+
if output_json:
|
|
1670
|
+
import json
|
|
1671
|
+
result = {
|
|
1672
|
+
'before': {
|
|
1673
|
+
'period': {'start': before_start, 'end': before_end},
|
|
1674
|
+
'total': before_total,
|
|
1675
|
+
'daily_avg': before_daily,
|
|
1676
|
+
'days': before_days
|
|
1677
|
+
},
|
|
1678
|
+
'after': {
|
|
1679
|
+
'period': {'start': after_start, 'end': after_end},
|
|
1680
|
+
'total': after_total,
|
|
1681
|
+
'daily_avg': after_daily,
|
|
1682
|
+
'days': after_days
|
|
1683
|
+
},
|
|
1684
|
+
'comparison': {
|
|
1685
|
+
'daily_reduction': reduction,
|
|
1686
|
+
'reduction_pct': reduction_pct,
|
|
1687
|
+
'annual_savings': annual_savings
|
|
1688
|
+
}
|
|
1689
|
+
}
|
|
1690
|
+
|
|
1691
|
+
if expected_reduction is not None:
|
|
1692
|
+
result['comparison']['expected_reduction_pct'] = expected_reduction
|
|
1693
|
+
result['comparison']['met_expectation'] = reduction_pct >= expected_reduction
|
|
1694
|
+
|
|
1695
|
+
click.echo(json.dumps(result, indent=2))
|
|
1696
|
+
else:
|
|
1697
|
+
click.echo("Before Period:")
|
|
1698
|
+
click.echo(f" Total: ${before_total:,.2f}")
|
|
1699
|
+
click.echo(f" Daily Avg: ${before_daily:,.2f}")
|
|
1700
|
+
click.echo(f" Days: {before_days}")
|
|
1701
|
+
click.echo("")
|
|
1702
|
+
click.echo("After Period:")
|
|
1703
|
+
click.echo(f" Total: ${after_total:,.2f}")
|
|
1704
|
+
click.echo(f" Daily Avg: ${after_daily:,.2f}")
|
|
1705
|
+
click.echo(f" Days: {after_days}")
|
|
1706
|
+
click.echo("")
|
|
1707
|
+
click.echo("Comparison:")
|
|
1708
|
+
click.echo(f" Daily Reduction: ${reduction:,.2f}")
|
|
1709
|
+
click.echo(f" Reduction %: {reduction_pct:.1f}%")
|
|
1710
|
+
click.echo(f" Annual Savings: ${annual_savings:,.0f}")
|
|
1711
|
+
|
|
1712
|
+
if expected_reduction is not None:
|
|
1713
|
+
click.echo("")
|
|
1714
|
+
if reduction_pct >= expected_reduction:
|
|
1715
|
+
click.echo(f"✅ Savings achieved: {reduction_pct:.1f}% (expected {expected_reduction}%)")
|
|
1716
|
+
else:
|
|
1717
|
+
click.echo(f"⚠️ Below target: {reduction_pct:.1f}% (expected {expected_reduction}%)")
|
|
1718
|
+
|
|
1719
|
+
except Exception as e:
|
|
1720
|
+
raise click.ClickException(f"Comparison failed: {e}")
|
|
1721
|
+
|
|
1722
|
+
|
|
1723
|
+
@cli.command()
|
|
1724
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1725
|
+
@click.option('--tag-key', required=True, help='Tag key to filter by')
|
|
1726
|
+
@click.option('--tag-value', help='Tag value to filter by (optional)')
|
|
1727
|
+
@click.option('--start-date', help='Start date (YYYY-MM-DD)')
|
|
1728
|
+
@click.option('--end-date', help='End date (YYYY-MM-DD)')
|
|
1729
|
+
@click.option('--days', type=int, default=30, help='Number of days to analyze (default: 30)')
|
|
1730
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1731
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
1732
|
+
def tags(profile, tag_key, tag_value, start_date, end_date, days, sso, output_json):
|
|
1733
|
+
"""
|
|
1734
|
+
Analyze costs by resource tags
|
|
1735
|
+
|
|
1736
|
+
Example:
|
|
1737
|
+
cc tags --profile khoros --tag-key "datadog:org" --days 30
|
|
1738
|
+
cc tags --profile khoros --tag-key "Environment" --tag-value "Production"
|
|
1739
|
+
"""
|
|
1740
|
+
# Load profile
|
|
1741
|
+
config = load_profile(profile)
|
|
1742
|
+
|
|
1743
|
+
# Apply SSO if provided
|
|
1744
|
+
if sso:
|
|
1745
|
+
config['aws_profile'] = sso
|
|
1746
|
+
|
|
1747
|
+
# Calculate date range
|
|
1748
|
+
if end_date:
|
|
1749
|
+
end = datetime.strptime(end_date, '%Y-%m-%d')
|
|
1750
|
+
else:
|
|
1751
|
+
end = datetime.now()
|
|
1752
|
+
|
|
1753
|
+
if start_date:
|
|
1754
|
+
start = datetime.strptime(start_date, '%Y-%m-%d')
|
|
1755
|
+
else:
|
|
1756
|
+
start = end - timedelta(days=days)
|
|
1757
|
+
|
|
1758
|
+
if not output_json:
|
|
1759
|
+
click.echo(f"Tag-based cost analysis: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
|
|
1760
|
+
click.echo(f"Tag key: {tag_key}")
|
|
1761
|
+
if tag_value:
|
|
1762
|
+
click.echo(f"Tag value: {tag_value}")
|
|
1763
|
+
click.echo("")
|
|
1764
|
+
|
|
1765
|
+
# Get credentials
|
|
1766
|
+
try:
|
|
1767
|
+
if 'aws_profile' in config:
|
|
1768
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1769
|
+
else:
|
|
1770
|
+
creds = config['credentials']
|
|
1771
|
+
session = boto3.Session(
|
|
1772
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1773
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1774
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1775
|
+
)
|
|
1776
|
+
|
|
1777
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
1778
|
+
|
|
1779
|
+
# Build filter
|
|
1780
|
+
filter_parts = [
|
|
1781
|
+
{
|
|
1782
|
+
"Dimensions": {
|
|
1783
|
+
"Key": "LINKED_ACCOUNT",
|
|
1784
|
+
"Values": config['accounts']
|
|
1785
|
+
}
|
|
1786
|
+
},
|
|
1787
|
+
{
|
|
1788
|
+
"Not": {
|
|
1789
|
+
"Dimensions": {
|
|
1790
|
+
"Key": "RECORD_TYPE",
|
|
1791
|
+
"Values": ["Tax", "Support"]
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
}
|
|
1795
|
+
]
|
|
1796
|
+
|
|
1797
|
+
# Add tag filter if value specified
|
|
1798
|
+
if tag_value:
|
|
1799
|
+
filter_parts.append({
|
|
1800
|
+
"Tags": {
|
|
1801
|
+
"Key": tag_key,
|
|
1802
|
+
"Values": [tag_value]
|
|
1803
|
+
}
|
|
1804
|
+
})
|
|
1805
|
+
|
|
1806
|
+
cost_filter = {"And": filter_parts}
|
|
1807
|
+
|
|
1808
|
+
# Get costs grouped by tag values
|
|
1809
|
+
response = ce_client.get_cost_and_usage(
|
|
1810
|
+
TimePeriod={
|
|
1811
|
+
'Start': start.strftime('%Y-%m-%d'),
|
|
1812
|
+
'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1813
|
+
},
|
|
1814
|
+
Granularity='MONTHLY',
|
|
1815
|
+
Metrics=['UnblendedCost'],
|
|
1816
|
+
GroupBy=[{
|
|
1817
|
+
'Type': 'TAG',
|
|
1818
|
+
'Key': tag_key
|
|
1819
|
+
}],
|
|
1820
|
+
Filter=cost_filter
|
|
1821
|
+
)
|
|
1822
|
+
|
|
1823
|
+
# Collect results
|
|
1824
|
+
tag_costs = {}
|
|
1825
|
+
for period in response['ResultsByTime']:
|
|
1826
|
+
for group in period['Groups']:
|
|
1827
|
+
tag_val = group['Keys'][0].split('$')[1] if '$' in group['Keys'][0] else group['Keys'][0]
|
|
1828
|
+
cost = float(group['Metrics']['UnblendedCost']['Amount'])
|
|
1829
|
+
tag_costs[tag_val] = tag_costs.get(tag_val, 0) + cost
|
|
1830
|
+
|
|
1831
|
+
# Sort by cost
|
|
1832
|
+
sorted_tags = sorted(tag_costs.items(), key=lambda x: x[1], reverse=True)
|
|
1833
|
+
|
|
1834
|
+
total = sum(tag_costs.values())
|
|
1835
|
+
num_days = (end - start).days
|
|
1836
|
+
daily_avg = total / num_days if num_days > 0 else 0
|
|
1837
|
+
|
|
1838
|
+
# Output results
|
|
1839
|
+
if output_json:
|
|
1840
|
+
import json
|
|
1841
|
+
result = {
|
|
1842
|
+
'period': {
|
|
1843
|
+
'start': start.strftime('%Y-%m-%d'),
|
|
1844
|
+
'end': end.strftime('%Y-%m-%d'),
|
|
1845
|
+
'days': num_days
|
|
1846
|
+
},
|
|
1847
|
+
'tag_key': tag_key,
|
|
1848
|
+
'tag_value_filter': tag_value,
|
|
1849
|
+
'tag_costs': [{'tag_value': k, 'cost': v, 'percentage': (v/total*100) if total > 0 else 0} for k, v in sorted_tags],
|
|
1850
|
+
'summary': {
|
|
1851
|
+
'total': total,
|
|
1852
|
+
'daily_avg': daily_avg,
|
|
1853
|
+
'annual_projection': daily_avg * 365
|
|
1854
|
+
}
|
|
1855
|
+
}
|
|
1856
|
+
click.echo(json.dumps(result, indent=2))
|
|
1857
|
+
else:
|
|
1858
|
+
click.echo(f"Tag Value{' '*(30-len('Tag Value'))} | Cost | %")
|
|
1859
|
+
click.echo("-" * 60)
|
|
1860
|
+
for tag_val, cost in sorted_tags:
|
|
1861
|
+
pct = (cost / total * 100) if total > 0 else 0
|
|
1862
|
+
tag_display = tag_val[:30].ljust(30)
|
|
1863
|
+
click.echo(f"{tag_display} | ${cost:>9,.2f} | {pct:>5.1f}%")
|
|
1864
|
+
click.echo("-" * 60)
|
|
1865
|
+
click.echo(f"{'Total'.ljust(30)} | ${total:>9,.2f} | 100.0%")
|
|
1866
|
+
click.echo("")
|
|
1867
|
+
click.echo(f"Daily Avg: ${daily_avg:,.2f}")
|
|
1868
|
+
click.echo(f"Annual Projection: ${daily_avg * 365:,.0f}")
|
|
1869
|
+
|
|
1870
|
+
except Exception as e:
|
|
1871
|
+
raise click.ClickException(f"Tag analysis failed: {e}")
|
|
1872
|
+
|
|
1873
|
+
|
|
1874
|
+
@cli.command()
|
|
1875
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1876
|
+
@click.option('--query', required=True, help='SQL query to execute')
|
|
1877
|
+
@click.option('--database', default='athenacurcfn_cloud_intelligence_dashboard', help='Athena database name')
|
|
1878
|
+
@click.option('--output-bucket', help='S3 bucket for query results (default: from profile)')
|
|
1879
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1880
|
+
def query(profile, query, database, output_bucket, sso):
|
|
1881
|
+
"""
|
|
1882
|
+
Execute custom Athena SQL query on CUR data
|
|
1883
|
+
|
|
1884
|
+
Example:
|
|
1885
|
+
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"
|
|
1886
|
+
"""
|
|
1887
|
+
# Load profile
|
|
1888
|
+
config = load_profile(profile)
|
|
1889
|
+
|
|
1890
|
+
# Apply SSO if provided
|
|
1891
|
+
if sso:
|
|
1892
|
+
config['aws_profile'] = sso
|
|
1893
|
+
|
|
1894
|
+
# Get credentials
|
|
1895
|
+
try:
|
|
1896
|
+
if 'aws_profile' in config:
|
|
1897
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1898
|
+
else:
|
|
1899
|
+
creds = config['credentials']
|
|
1900
|
+
session = boto3.Session(
|
|
1901
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1902
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1903
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1904
|
+
)
|
|
1905
|
+
|
|
1906
|
+
athena_client = session.client('athena', region_name='us-east-1')
|
|
1907
|
+
|
|
1908
|
+
# Default output location
|
|
1909
|
+
if not output_bucket:
|
|
1910
|
+
output_bucket = 's3://khoros-finops-athena/athena/'
|
|
1911
|
+
|
|
1912
|
+
click.echo(f"Executing query on database: {database}")
|
|
1913
|
+
click.echo(f"Output location: {output_bucket}")
|
|
1914
|
+
click.echo("")
|
|
1915
|
+
|
|
1916
|
+
# Execute query
|
|
1917
|
+
response = athena_client.start_query_execution(
|
|
1918
|
+
QueryString=query,
|
|
1919
|
+
QueryExecutionContext={'Database': database},
|
|
1920
|
+
ResultConfiguration={'OutputLocation': output_bucket}
|
|
1921
|
+
)
|
|
1922
|
+
|
|
1923
|
+
query_id = response['QueryExecutionId']
|
|
1924
|
+
click.echo(f"Query ID: {query_id}")
|
|
1925
|
+
click.echo("Waiting for query to complete...")
|
|
1926
|
+
|
|
1927
|
+
# Wait for completion
|
|
1928
|
+
import time
|
|
1929
|
+
max_wait = 60
|
|
1930
|
+
waited = 0
|
|
1931
|
+
while waited < max_wait:
|
|
1932
|
+
status_response = athena_client.get_query_execution(QueryExecutionId=query_id)
|
|
1933
|
+
status = status_response['QueryExecution']['Status']['State']
|
|
1934
|
+
|
|
1935
|
+
if status == 'SUCCEEDED':
|
|
1936
|
+
click.echo("✓ Query completed successfully")
|
|
1937
|
+
break
|
|
1938
|
+
elif status in ['FAILED', 'CANCELLED']:
|
|
1939
|
+
reason = status_response['QueryExecution']['Status'].get('StateChangeReason', 'Unknown')
|
|
1940
|
+
raise click.ClickException(f"Query {status}: {reason}")
|
|
1941
|
+
|
|
1942
|
+
time.sleep(2)
|
|
1943
|
+
waited += 2
|
|
1944
|
+
|
|
1945
|
+
if waited >= max_wait:
|
|
1946
|
+
raise click.ClickException(f"Query timeout after {max_wait}s. Check query ID: {query_id}")
|
|
1947
|
+
|
|
1948
|
+
# Get results
|
|
1949
|
+
results = athena_client.get_query_results(QueryExecutionId=query_id)
|
|
1950
|
+
|
|
1951
|
+
# Display results
|
|
1952
|
+
rows = results['ResultSet']['Rows']
|
|
1953
|
+
if not rows:
|
|
1954
|
+
click.echo("No results returned")
|
|
1955
|
+
return
|
|
1956
|
+
|
|
1957
|
+
# Header
|
|
1958
|
+
headers = [col['VarCharValue'] for col in rows[0]['Data']]
|
|
1959
|
+
click.echo(" | ".join(headers))
|
|
1960
|
+
click.echo("-" * (len(" | ".join(headers))))
|
|
1961
|
+
|
|
1962
|
+
# Data rows
|
|
1963
|
+
for row in rows[1:]:
|
|
1964
|
+
values = [col.get('VarCharValue', '') for col in row['Data']]
|
|
1965
|
+
click.echo(" | ".join(values))
|
|
1966
|
+
|
|
1967
|
+
click.echo("")
|
|
1968
|
+
click.echo(f"Returned {len(rows)-1} rows")
|
|
1969
|
+
|
|
1970
|
+
except Exception as e:
|
|
1971
|
+
raise click.ClickException(f"Query failed: {e}")
|
|
1972
|
+
|
|
1973
|
+
|
|
734
1974
|
if __name__ == '__main__':
|
|
735
1975
|
cli()
|