aws-cost-calculator-cli 1.3.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.3.0.dist-info/METADATA +287 -0
- aws_cost_calculator_cli-1.3.0.dist-info/RECORD +11 -0
- aws_cost_calculator_cli-1.3.0.dist-info/WHEEL +5 -0
- aws_cost_calculator_cli-1.3.0.dist-info/entry_points.txt +2 -0
- aws_cost_calculator_cli-1.3.0.dist-info/licenses/LICENSE +21 -0
- aws_cost_calculator_cli-1.3.0.dist-info/top_level.txt +1 -0
- cost_calculator/__init__.py +2 -0
- cost_calculator/cli.py +848 -0
- cost_calculator/drill.py +323 -0
- cost_calculator/monthly.py +242 -0
- cost_calculator/trends.py +353 -0
cost_calculator/cli.py
ADDED
|
@@ -0,0 +1,848 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
AWS Cost Calculator CLI
|
|
4
|
+
|
|
5
|
+
Usage:
|
|
6
|
+
cc --profile myprofile
|
|
7
|
+
cc --profile myprofile --start-date 2025-11-04
|
|
8
|
+
cc --profile myprofile --offset 2 --window 30
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
import click
|
|
12
|
+
import boto3
|
|
13
|
+
import json
|
|
14
|
+
from datetime import datetime, timedelta
|
|
15
|
+
from pathlib import Path
|
|
16
|
+
from cost_calculator.trends import analyze_trends, format_trends_markdown
|
|
17
|
+
from cost_calculator.monthly import analyze_monthly_trends, format_monthly_markdown
|
|
18
|
+
from cost_calculator.drill import analyze_drill_down, format_drill_down_markdown
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def load_profile(profile_name):
|
|
22
|
+
"""Load profile configuration from ~/.config/cost-calculator/profiles.json"""
|
|
23
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
24
|
+
config_file = config_dir / 'profiles.json'
|
|
25
|
+
creds_file = config_dir / 'credentials.json'
|
|
26
|
+
|
|
27
|
+
if not config_file.exists():
|
|
28
|
+
raise click.ClickException(
|
|
29
|
+
f"Profile configuration not found at {config_file}\n"
|
|
30
|
+
f"Run: cc init --profile {profile_name}"
|
|
31
|
+
)
|
|
32
|
+
|
|
33
|
+
with open(config_file) as f:
|
|
34
|
+
profiles = json.load(f)
|
|
35
|
+
|
|
36
|
+
if profile_name not in profiles:
|
|
37
|
+
raise click.ClickException(
|
|
38
|
+
f"Profile '{profile_name}' not found in {config_file}\n"
|
|
39
|
+
f"Available profiles: {', '.join(profiles.keys())}"
|
|
40
|
+
)
|
|
41
|
+
|
|
42
|
+
profile = profiles[profile_name]
|
|
43
|
+
|
|
44
|
+
# Load credentials if using static credentials (not SSO)
|
|
45
|
+
if 'aws_profile' not in profile:
|
|
46
|
+
if not creds_file.exists():
|
|
47
|
+
raise click.ClickException(
|
|
48
|
+
f"No credentials found for profile '{profile_name}'.\n"
|
|
49
|
+
f"Run: cc configure --profile {profile_name}"
|
|
50
|
+
)
|
|
51
|
+
|
|
52
|
+
with open(creds_file) as f:
|
|
53
|
+
creds = json.load(f)
|
|
54
|
+
|
|
55
|
+
if profile_name not in creds:
|
|
56
|
+
raise click.ClickException(
|
|
57
|
+
f"No credentials found for profile '{profile_name}'.\n"
|
|
58
|
+
f"Run: cc configure --profile {profile_name}"
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
profile['credentials'] = creds[profile_name]
|
|
62
|
+
|
|
63
|
+
return profile
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
67
|
+
"""
|
|
68
|
+
Calculate AWS costs for the specified period.
|
|
69
|
+
|
|
70
|
+
Args:
|
|
71
|
+
profile_config: Profile configuration (with aws_profile or credentials)
|
|
72
|
+
accounts: List of AWS account IDs
|
|
73
|
+
start_date: Start date (defaults to today)
|
|
74
|
+
offset: Days to go back from start_date (default: 2)
|
|
75
|
+
window: Number of days to analyze (default: 30)
|
|
76
|
+
|
|
77
|
+
Returns:
|
|
78
|
+
dict with cost breakdown
|
|
79
|
+
"""
|
|
80
|
+
# Calculate date range
|
|
81
|
+
if start_date:
|
|
82
|
+
end_date = datetime.strptime(start_date, '%Y-%m-%d')
|
|
83
|
+
else:
|
|
84
|
+
end_date = datetime.now()
|
|
85
|
+
|
|
86
|
+
# Go back by offset days
|
|
87
|
+
end_date = end_date - timedelta(days=offset)
|
|
88
|
+
|
|
89
|
+
# Start date is window days before end_date
|
|
90
|
+
start_date_calc = end_date - timedelta(days=window)
|
|
91
|
+
|
|
92
|
+
# Format for API (end date is exclusive, so add 1 day)
|
|
93
|
+
api_start = start_date_calc.strftime('%Y-%m-%d')
|
|
94
|
+
api_end = (end_date + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
95
|
+
|
|
96
|
+
click.echo(f"Analyzing: {api_start} to {end_date.strftime('%Y-%m-%d')} ({window} days)")
|
|
97
|
+
|
|
98
|
+
# Initialize boto3 client
|
|
99
|
+
try:
|
|
100
|
+
if 'aws_profile' in profile_config:
|
|
101
|
+
# SSO-based authentication
|
|
102
|
+
aws_profile = profile_config['aws_profile']
|
|
103
|
+
click.echo(f"AWS Profile: {aws_profile} (SSO)")
|
|
104
|
+
click.echo(f"Accounts: {len(accounts)}")
|
|
105
|
+
click.echo("")
|
|
106
|
+
session = boto3.Session(profile_name=aws_profile)
|
|
107
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
108
|
+
else:
|
|
109
|
+
# Static credentials
|
|
110
|
+
creds = profile_config['credentials']
|
|
111
|
+
click.echo(f"AWS Credentials: Static")
|
|
112
|
+
click.echo(f"Accounts: {len(accounts)}")
|
|
113
|
+
click.echo("")
|
|
114
|
+
|
|
115
|
+
session_kwargs = {
|
|
116
|
+
'aws_access_key_id': creds['aws_access_key_id'],
|
|
117
|
+
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
118
|
+
'region_name': creds.get('region', 'us-east-1')
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if 'aws_session_token' in creds:
|
|
122
|
+
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
123
|
+
|
|
124
|
+
session = boto3.Session(**session_kwargs)
|
|
125
|
+
ce_client = session.client('ce')
|
|
126
|
+
|
|
127
|
+
except Exception as e:
|
|
128
|
+
if 'Token has expired' in str(e) or 'sso' in str(e).lower():
|
|
129
|
+
if 'aws_profile' in profile_config:
|
|
130
|
+
raise click.ClickException(
|
|
131
|
+
f"AWS SSO session expired or not initialized.\n"
|
|
132
|
+
f"Run: aws sso login --profile {profile_config['aws_profile']}"
|
|
133
|
+
)
|
|
134
|
+
else:
|
|
135
|
+
raise click.ClickException(
|
|
136
|
+
f"AWS credentials expired.\n"
|
|
137
|
+
f"Run: cc configure --profile <profile_name>"
|
|
138
|
+
)
|
|
139
|
+
raise
|
|
140
|
+
|
|
141
|
+
# Build filter
|
|
142
|
+
cost_filter = {
|
|
143
|
+
"And": [
|
|
144
|
+
{
|
|
145
|
+
"Dimensions": {
|
|
146
|
+
"Key": "LINKED_ACCOUNT",
|
|
147
|
+
"Values": accounts
|
|
148
|
+
}
|
|
149
|
+
},
|
|
150
|
+
{
|
|
151
|
+
"Dimensions": {
|
|
152
|
+
"Key": "BILLING_ENTITY",
|
|
153
|
+
"Values": ["AWS"]
|
|
154
|
+
}
|
|
155
|
+
},
|
|
156
|
+
{
|
|
157
|
+
"Not": {
|
|
158
|
+
"Dimensions": {
|
|
159
|
+
"Key": "RECORD_TYPE",
|
|
160
|
+
"Values": ["Tax", "Support"]
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
}
|
|
164
|
+
]
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
# Get daily costs
|
|
168
|
+
click.echo("Fetching cost data...")
|
|
169
|
+
try:
|
|
170
|
+
response = ce_client.get_cost_and_usage(
|
|
171
|
+
TimePeriod={
|
|
172
|
+
'Start': api_start,
|
|
173
|
+
'End': api_end
|
|
174
|
+
},
|
|
175
|
+
Granularity='DAILY',
|
|
176
|
+
Metrics=['NetAmortizedCost'],
|
|
177
|
+
Filter=cost_filter
|
|
178
|
+
)
|
|
179
|
+
except Exception as e:
|
|
180
|
+
if 'Token has expired' in str(e) or 'expired' in str(e).lower():
|
|
181
|
+
raise click.ClickException(
|
|
182
|
+
f"AWS SSO session expired.\n"
|
|
183
|
+
f"Run: aws sso login --profile {aws_profile}"
|
|
184
|
+
)
|
|
185
|
+
raise
|
|
186
|
+
|
|
187
|
+
# Calculate total
|
|
188
|
+
total_cost = sum(
|
|
189
|
+
float(day['Total']['NetAmortizedCost']['Amount'])
|
|
190
|
+
for day in response['ResultsByTime']
|
|
191
|
+
)
|
|
192
|
+
|
|
193
|
+
# Get support cost from the 1st of the month containing the end date
|
|
194
|
+
# Support is charged on the 1st of each month for the previous month's usage
|
|
195
|
+
# For Oct 3-Nov 2 analysis, we get support from Nov 1 (which is October's support)
|
|
196
|
+
support_month_date = end_date.replace(day=1)
|
|
197
|
+
support_date_str = support_month_date.strftime('%Y-%m-%d')
|
|
198
|
+
support_date_end = (support_month_date + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
199
|
+
|
|
200
|
+
click.echo("Fetching support costs...")
|
|
201
|
+
support_response = ce_client.get_cost_and_usage(
|
|
202
|
+
TimePeriod={
|
|
203
|
+
'Start': support_date_str,
|
|
204
|
+
'End': support_date_end
|
|
205
|
+
},
|
|
206
|
+
Granularity='DAILY',
|
|
207
|
+
Metrics=['NetAmortizedCost'],
|
|
208
|
+
Filter={
|
|
209
|
+
"And": [
|
|
210
|
+
{
|
|
211
|
+
"Dimensions": {
|
|
212
|
+
"Key": "LINKED_ACCOUNT",
|
|
213
|
+
"Values": accounts
|
|
214
|
+
}
|
|
215
|
+
},
|
|
216
|
+
{
|
|
217
|
+
"Dimensions": {
|
|
218
|
+
"Key": "RECORD_TYPE",
|
|
219
|
+
"Values": ["Support"]
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
]
|
|
223
|
+
}
|
|
224
|
+
)
|
|
225
|
+
|
|
226
|
+
support_cost = float(support_response['ResultsByTime'][0]['Total']['NetAmortizedCost']['Amount'])
|
|
227
|
+
|
|
228
|
+
# Calculate days in the month that the support covers
|
|
229
|
+
# Support on Nov 1 covers October (31 days)
|
|
230
|
+
support_month = support_month_date - timedelta(days=1) # Go back to previous month
|
|
231
|
+
days_in_support_month = support_month.day # This gives us the last day of the month
|
|
232
|
+
|
|
233
|
+
# Support allocation: divide by 2 (half to Khoros), then by days in month
|
|
234
|
+
support_per_day = (support_cost / 2) / days_in_support_month
|
|
235
|
+
|
|
236
|
+
# Calculate daily rate
|
|
237
|
+
# NOTE: We divide operational by window, but support by days_in_support_month
|
|
238
|
+
# This matches the console's calculation method
|
|
239
|
+
daily_operational = total_cost / days_in_support_month # Use 31 for October, not 30
|
|
240
|
+
daily_total = daily_operational + support_per_day
|
|
241
|
+
|
|
242
|
+
# Annual projection
|
|
243
|
+
annual = daily_total * 365
|
|
244
|
+
|
|
245
|
+
return {
|
|
246
|
+
'period': {
|
|
247
|
+
'start': api_start,
|
|
248
|
+
'end': end_date.strftime('%Y-%m-%d'),
|
|
249
|
+
'days': window
|
|
250
|
+
},
|
|
251
|
+
'costs': {
|
|
252
|
+
'total_operational': total_cost,
|
|
253
|
+
'daily_operational': daily_operational,
|
|
254
|
+
'support_month': support_cost,
|
|
255
|
+
'support_per_day': support_per_day,
|
|
256
|
+
'daily_total': daily_total,
|
|
257
|
+
'annual_projection': annual
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
|
|
262
|
+
@click.group()
|
|
263
|
+
def cli():
|
|
264
|
+
"""
|
|
265
|
+
AWS Cost Calculator - Calculate daily and annual AWS costs
|
|
266
|
+
|
|
267
|
+
\b
|
|
268
|
+
Two authentication methods:
|
|
269
|
+
1. AWS SSO (recommended for interactive use)
|
|
270
|
+
2. Static credentials (for automation/CI)
|
|
271
|
+
|
|
272
|
+
\b
|
|
273
|
+
Quick Start:
|
|
274
|
+
# SSO Method
|
|
275
|
+
aws sso login --profile my_aws_profile
|
|
276
|
+
cc init --profile myprofile --aws-profile my_aws_profile --accounts "123,456,789"
|
|
277
|
+
cc calculate --profile myprofile
|
|
278
|
+
|
|
279
|
+
# Static Credentials Method
|
|
280
|
+
cc init --profile myprofile --aws-profile dummy --accounts "123,456,789"
|
|
281
|
+
cc configure --profile myprofile
|
|
282
|
+
cc calculate --profile myprofile
|
|
283
|
+
|
|
284
|
+
\b
|
|
285
|
+
For detailed documentation, see:
|
|
286
|
+
- COST_CALCULATION_METHODOLOGY.md
|
|
287
|
+
- README.md
|
|
288
|
+
"""
|
|
289
|
+
pass
|
|
290
|
+
|
|
291
|
+
|
|
292
|
+
@cli.command()
|
|
293
|
+
@click.option('--profile', required=True, help='Profile name (e.g., myprofile)')
|
|
294
|
+
@click.option('--start-date', help='Start date (YYYY-MM-DD, default: today)')
|
|
295
|
+
@click.option('--offset', default=2, help='Days to go back from start date (default: 2)')
|
|
296
|
+
@click.option('--window', default=30, help='Number of days to analyze (default: 30)')
|
|
297
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON')
|
|
298
|
+
def calculate(profile, start_date, offset, window, json_output):
|
|
299
|
+
"""Calculate AWS costs for the specified period"""
|
|
300
|
+
|
|
301
|
+
# Load profile configuration
|
|
302
|
+
config = load_profile(profile)
|
|
303
|
+
|
|
304
|
+
# Calculate costs
|
|
305
|
+
result = calculate_costs(
|
|
306
|
+
profile_config=config,
|
|
307
|
+
accounts=config['accounts'],
|
|
308
|
+
start_date=start_date,
|
|
309
|
+
offset=offset,
|
|
310
|
+
window=window
|
|
311
|
+
)
|
|
312
|
+
|
|
313
|
+
if json_output:
|
|
314
|
+
click.echo(json.dumps(result, indent=2))
|
|
315
|
+
else:
|
|
316
|
+
# Pretty print results
|
|
317
|
+
click.echo("=" * 60)
|
|
318
|
+
click.echo(f"Period: {result['period']['start']} to {result['period']['end']}")
|
|
319
|
+
click.echo(f"Days analyzed: {result['period']['days']}")
|
|
320
|
+
click.echo("=" * 60)
|
|
321
|
+
click.echo(f"Total operational cost: ${result['costs']['total_operational']:,.2f}")
|
|
322
|
+
click.echo(f"Daily operational: ${result['costs']['daily_operational']:,.2f}")
|
|
323
|
+
click.echo(f"Support (month): ${result['costs']['support_month']:,.2f}")
|
|
324
|
+
click.echo(f"Support per day (÷2÷days): ${result['costs']['support_per_day']:,.2f}")
|
|
325
|
+
click.echo("=" * 60)
|
|
326
|
+
click.echo(f"DAILY RATE: ${result['costs']['daily_total']:,.2f}")
|
|
327
|
+
click.echo(f"ANNUAL PROJECTION: ${result['costs']['annual_projection']:,.0f}")
|
|
328
|
+
click.echo("=" * 60)
|
|
329
|
+
|
|
330
|
+
|
|
331
|
+
@cli.command()
|
|
332
|
+
@click.option('--profile', required=True, help='Profile name to create')
|
|
333
|
+
@click.option('--aws-profile', required=True, help='AWS CLI profile name')
|
|
334
|
+
@click.option('--accounts', required=True, help='Comma-separated list of account IDs')
|
|
335
|
+
def init(profile, aws_profile, accounts):
|
|
336
|
+
"""Initialize a new profile configuration"""
|
|
337
|
+
|
|
338
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
339
|
+
config_file = config_dir / 'profiles.json'
|
|
340
|
+
|
|
341
|
+
# Create config directory if it doesn't exist
|
|
342
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
343
|
+
|
|
344
|
+
# Load existing profiles or create new
|
|
345
|
+
if config_file.exists() and config_file.stat().st_size > 0:
|
|
346
|
+
try:
|
|
347
|
+
with open(config_file) as f:
|
|
348
|
+
profiles = json.load(f)
|
|
349
|
+
except json.JSONDecodeError:
|
|
350
|
+
profiles = {}
|
|
351
|
+
else:
|
|
352
|
+
profiles = {}
|
|
353
|
+
|
|
354
|
+
# Parse accounts
|
|
355
|
+
account_list = [acc.strip() for acc in accounts.split(',')]
|
|
356
|
+
|
|
357
|
+
# Add new profile
|
|
358
|
+
profiles[profile] = {
|
|
359
|
+
'aws_profile': aws_profile,
|
|
360
|
+
'accounts': account_list
|
|
361
|
+
}
|
|
362
|
+
|
|
363
|
+
# Save
|
|
364
|
+
with open(config_file, 'w') as f:
|
|
365
|
+
json.dump(profiles, f, indent=2)
|
|
366
|
+
|
|
367
|
+
click.echo(f"✓ Profile '{profile}' created with {len(account_list)} accounts")
|
|
368
|
+
click.echo(f"✓ Configuration saved to {config_file}")
|
|
369
|
+
click.echo(f"\nUsage: cc calculate --profile {profile}")
|
|
370
|
+
|
|
371
|
+
|
|
372
|
+
@cli.command()
|
|
373
|
+
def list_profiles():
|
|
374
|
+
"""List all configured profiles"""
|
|
375
|
+
|
|
376
|
+
config_file = Path.home() / '.config' / 'cost-calculator' / 'profiles.json'
|
|
377
|
+
|
|
378
|
+
if not config_file.exists():
|
|
379
|
+
click.echo("No profiles configured. Run: cc init --profile <name>")
|
|
380
|
+
return
|
|
381
|
+
|
|
382
|
+
with open(config_file) as f:
|
|
383
|
+
profiles = json.load(f)
|
|
384
|
+
|
|
385
|
+
if not profiles:
|
|
386
|
+
click.echo("No profiles configured.")
|
|
387
|
+
return
|
|
388
|
+
|
|
389
|
+
click.echo("Configured profiles:")
|
|
390
|
+
click.echo("")
|
|
391
|
+
for name, config in profiles.items():
|
|
392
|
+
click.echo(f" {name}")
|
|
393
|
+
if 'aws_profile' in config:
|
|
394
|
+
click.echo(f" AWS Profile: {config['aws_profile']} (SSO)")
|
|
395
|
+
else:
|
|
396
|
+
click.echo(f" AWS Credentials: Configured (Static)")
|
|
397
|
+
click.echo(f" Accounts: {len(config['accounts'])}")
|
|
398
|
+
click.echo("")
|
|
399
|
+
|
|
400
|
+
|
|
401
|
+
@cli.command()
|
|
402
|
+
def setup():
|
|
403
|
+
"""Show setup instructions for manual profile configuration"""
|
|
404
|
+
import platform
|
|
405
|
+
|
|
406
|
+
system = platform.system()
|
|
407
|
+
|
|
408
|
+
if system == "Windows":
|
|
409
|
+
config_path = "%USERPROFILE%\\.config\\cost-calculator\\profiles.json"
|
|
410
|
+
config_path_example = "C:\\Users\\YourName\\.config\\cost-calculator\\profiles.json"
|
|
411
|
+
mkdir_cmd = "mkdir %USERPROFILE%\\.config\\cost-calculator"
|
|
412
|
+
edit_cmd = "notepad %USERPROFILE%\\.config\\cost-calculator\\profiles.json"
|
|
413
|
+
else: # macOS/Linux
|
|
414
|
+
config_path = "~/.config/cost-calculator/profiles.json"
|
|
415
|
+
config_path_example = "/Users/yourname/.config/cost-calculator/profiles.json"
|
|
416
|
+
mkdir_cmd = "mkdir -p ~/.config/cost-calculator"
|
|
417
|
+
edit_cmd = "nano ~/.config/cost-calculator/profiles.json"
|
|
418
|
+
|
|
419
|
+
click.echo("=" * 70)
|
|
420
|
+
click.echo("AWS Cost Calculator - Manual Profile Setup")
|
|
421
|
+
click.echo("=" * 70)
|
|
422
|
+
click.echo("")
|
|
423
|
+
click.echo(f"Platform: {system}")
|
|
424
|
+
click.echo(f"Config location: {config_path}")
|
|
425
|
+
click.echo("")
|
|
426
|
+
click.echo("Step 1: Create the config directory")
|
|
427
|
+
click.echo(f" {mkdir_cmd}")
|
|
428
|
+
click.echo("")
|
|
429
|
+
click.echo("Step 2: Create the profiles.json file")
|
|
430
|
+
click.echo(f" {edit_cmd}")
|
|
431
|
+
click.echo("")
|
|
432
|
+
click.echo("Step 3: Add your profile configuration (JSON format):")
|
|
433
|
+
click.echo("")
|
|
434
|
+
click.echo(' {')
|
|
435
|
+
click.echo(' "myprofile": {')
|
|
436
|
+
click.echo(' "aws_profile": "my_aws_profile",')
|
|
437
|
+
click.echo(' "accounts": [')
|
|
438
|
+
click.echo(' "123456789012",')
|
|
439
|
+
click.echo(' "234567890123",')
|
|
440
|
+
click.echo(' "345678901234"')
|
|
441
|
+
click.echo(' ]')
|
|
442
|
+
click.echo(' }')
|
|
443
|
+
click.echo(' }')
|
|
444
|
+
click.echo("")
|
|
445
|
+
click.echo("Step 4: Save the file")
|
|
446
|
+
click.echo("")
|
|
447
|
+
click.echo("Step 5: Verify it works")
|
|
448
|
+
click.echo(" cc list-profiles")
|
|
449
|
+
click.echo("")
|
|
450
|
+
click.echo("Step 6: Configure AWS credentials")
|
|
451
|
+
click.echo(" Option A (SSO):")
|
|
452
|
+
click.echo(" aws sso login --profile my_aws_profile")
|
|
453
|
+
click.echo(" cc calculate --profile myprofile")
|
|
454
|
+
click.echo("")
|
|
455
|
+
click.echo(" Option B (Static credentials):")
|
|
456
|
+
click.echo(" cc configure --profile myprofile")
|
|
457
|
+
click.echo(" cc calculate --profile myprofile")
|
|
458
|
+
click.echo("")
|
|
459
|
+
click.echo("=" * 70)
|
|
460
|
+
click.echo("")
|
|
461
|
+
click.echo("For multiple profiles, add more entries to the JSON:")
|
|
462
|
+
click.echo("")
|
|
463
|
+
click.echo(' {')
|
|
464
|
+
click.echo(' "profile1": { ... },')
|
|
465
|
+
click.echo(' "profile2": { ... }')
|
|
466
|
+
click.echo(' }')
|
|
467
|
+
click.echo("")
|
|
468
|
+
click.echo(f"Full path example: {config_path_example}")
|
|
469
|
+
click.echo("=" * 70)
|
|
470
|
+
|
|
471
|
+
|
|
472
|
+
@cli.command()
|
|
473
|
+
@click.option('--profile', required=True, help='Profile name to configure')
|
|
474
|
+
@click.option('--access-key-id', prompt=True, hide_input=False, help='AWS Access Key ID')
|
|
475
|
+
@click.option('--secret-access-key', prompt=True, hide_input=True, help='AWS Secret Access Key')
|
|
476
|
+
@click.option('--session-token', default='', help='AWS Session Token (optional, for temporary credentials)')
|
|
477
|
+
@click.option('--region', default='us-east-1', help='AWS Region (default: us-east-1)')
|
|
478
|
+
def configure(profile, access_key_id, secret_access_key, session_token, region):
|
|
479
|
+
"""Configure AWS credentials for a profile (alternative to SSO)"""
|
|
480
|
+
|
|
481
|
+
config_dir = Path.home() / '.config' / 'cost-calculator'
|
|
482
|
+
config_file = config_dir / 'profiles.json'
|
|
483
|
+
creds_file = config_dir / 'credentials.json'
|
|
484
|
+
|
|
485
|
+
# Create config directory if it doesn't exist
|
|
486
|
+
config_dir.mkdir(parents=True, exist_ok=True)
|
|
487
|
+
|
|
488
|
+
# Load existing profiles
|
|
489
|
+
if config_file.exists() and config_file.stat().st_size > 0:
|
|
490
|
+
try:
|
|
491
|
+
with open(config_file) as f:
|
|
492
|
+
profiles = json.load(f)
|
|
493
|
+
except json.JSONDecodeError:
|
|
494
|
+
profiles = {}
|
|
495
|
+
else:
|
|
496
|
+
profiles = {}
|
|
497
|
+
|
|
498
|
+
# Check if profile exists
|
|
499
|
+
if profile not in profiles:
|
|
500
|
+
click.echo(f"Error: Profile '{profile}' not found. Create it first with: cc init --profile {profile}")
|
|
501
|
+
return
|
|
502
|
+
|
|
503
|
+
# Remove aws_profile if it exists (switching from SSO to static creds)
|
|
504
|
+
if 'aws_profile' in profiles[profile]:
|
|
505
|
+
del profiles[profile]['aws_profile']
|
|
506
|
+
|
|
507
|
+
# Save updated profile
|
|
508
|
+
with open(config_file, 'w') as f:
|
|
509
|
+
json.dump(profiles, f, indent=2)
|
|
510
|
+
|
|
511
|
+
# Load or create credentials file
|
|
512
|
+
if creds_file.exists() and creds_file.stat().st_size > 0:
|
|
513
|
+
try:
|
|
514
|
+
with open(creds_file) as f:
|
|
515
|
+
creds = json.load(f)
|
|
516
|
+
except json.JSONDecodeError:
|
|
517
|
+
creds = {}
|
|
518
|
+
else:
|
|
519
|
+
creds = {}
|
|
520
|
+
|
|
521
|
+
# Store credentials (encrypted would be better, but for now just file permissions)
|
|
522
|
+
creds[profile] = {
|
|
523
|
+
'aws_access_key_id': access_key_id,
|
|
524
|
+
'aws_secret_access_key': secret_access_key,
|
|
525
|
+
'region': region
|
|
526
|
+
}
|
|
527
|
+
|
|
528
|
+
if session_token:
|
|
529
|
+
creds[profile]['aws_session_token'] = session_token
|
|
530
|
+
|
|
531
|
+
# Save credentials with restricted permissions
|
|
532
|
+
with open(creds_file, 'w') as f:
|
|
533
|
+
json.dump(creds, f, indent=2)
|
|
534
|
+
|
|
535
|
+
# Set file permissions to 600 (owner read/write only)
|
|
536
|
+
creds_file.chmod(0o600)
|
|
537
|
+
|
|
538
|
+
click.echo(f"✓ AWS credentials configured for profile '{profile}'")
|
|
539
|
+
click.echo(f"✓ Credentials saved to {creds_file} (permissions: 600)")
|
|
540
|
+
click.echo(f"\nUsage: cc calculate --profile {profile}")
|
|
541
|
+
click.echo("\nNote: Credentials are stored locally. For temporary credentials,")
|
|
542
|
+
click.echo(" you'll need to reconfigure when they expire.")
|
|
543
|
+
|
|
544
|
+
|
|
545
|
+
@cli.command()
|
|
546
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
547
|
+
@click.option('--weeks', default=3, help='Number of weeks to analyze (default: 3)')
|
|
548
|
+
@click.option('--output', default='cost_trends.md', help='Output markdown file (default: cost_trends.md)')
|
|
549
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
|
|
550
|
+
def trends(profile, weeks, output, json_output):
|
|
551
|
+
"""Analyze cost trends with Week-over-Week and Trailing 30-Day comparisons"""
|
|
552
|
+
|
|
553
|
+
# Load profile configuration
|
|
554
|
+
config = load_profile(profile)
|
|
555
|
+
|
|
556
|
+
# Initialize boto3 client
|
|
557
|
+
try:
|
|
558
|
+
if 'aws_profile' in config:
|
|
559
|
+
aws_profile = config['aws_profile']
|
|
560
|
+
click.echo(f"AWS Profile: {aws_profile} (SSO)")
|
|
561
|
+
session = boto3.Session(profile_name=aws_profile)
|
|
562
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
563
|
+
else:
|
|
564
|
+
creds = config['credentials']
|
|
565
|
+
click.echo(f"AWS Credentials: Static")
|
|
566
|
+
|
|
567
|
+
session_kwargs = {
|
|
568
|
+
'aws_access_key_id': creds['aws_access_key_id'],
|
|
569
|
+
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
570
|
+
'region_name': creds.get('region', 'us-east-1')
|
|
571
|
+
}
|
|
572
|
+
|
|
573
|
+
if 'aws_session_token' in creds:
|
|
574
|
+
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
575
|
+
|
|
576
|
+
session = boto3.Session(**session_kwargs)
|
|
577
|
+
ce_client = session.client('ce')
|
|
578
|
+
|
|
579
|
+
except Exception as e:
|
|
580
|
+
if 'Token has expired' in str(e) or 'sso' in str(e).lower():
|
|
581
|
+
if 'aws_profile' in config:
|
|
582
|
+
raise click.ClickException(
|
|
583
|
+
f"AWS SSO session expired or not initialized.\n"
|
|
584
|
+
f"Run: aws sso login --profile {config['aws_profile']}"
|
|
585
|
+
)
|
|
586
|
+
else:
|
|
587
|
+
raise click.ClickException(
|
|
588
|
+
f"AWS credentials expired.\n"
|
|
589
|
+
f"Run: cc configure --profile {profile}"
|
|
590
|
+
)
|
|
591
|
+
raise
|
|
592
|
+
|
|
593
|
+
click.echo(f"Analyzing last {weeks} weeks...")
|
|
594
|
+
click.echo("")
|
|
595
|
+
|
|
596
|
+
# Analyze trends
|
|
597
|
+
trends_data = analyze_trends(ce_client, config['accounts'], num_weeks=weeks)
|
|
598
|
+
|
|
599
|
+
if json_output:
|
|
600
|
+
# Output as JSON
|
|
601
|
+
import json
|
|
602
|
+
click.echo(json.dumps(trends_data, indent=2, default=str))
|
|
603
|
+
else:
|
|
604
|
+
# Generate markdown report
|
|
605
|
+
markdown = format_trends_markdown(trends_data)
|
|
606
|
+
|
|
607
|
+
# Save to file
|
|
608
|
+
with open(output, 'w') as f:
|
|
609
|
+
f.write(markdown)
|
|
610
|
+
|
|
611
|
+
click.echo(f"✓ Trends report saved to {output}")
|
|
612
|
+
click.echo("")
|
|
613
|
+
|
|
614
|
+
# Show summary
|
|
615
|
+
click.echo("WEEK-OVER-WEEK:")
|
|
616
|
+
for comparison in trends_data['wow_comparisons']:
|
|
617
|
+
prev_week = comparison['prev_week']['label']
|
|
618
|
+
curr_week = comparison['curr_week']['label']
|
|
619
|
+
num_increases = len(comparison['increases'])
|
|
620
|
+
num_decreases = len(comparison['decreases'])
|
|
621
|
+
|
|
622
|
+
click.echo(f" {prev_week} → {curr_week}")
|
|
623
|
+
click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
|
|
624
|
+
|
|
625
|
+
if comparison['increases']:
|
|
626
|
+
top = comparison['increases'][0]
|
|
627
|
+
click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
|
|
628
|
+
|
|
629
|
+
click.echo("")
|
|
630
|
+
|
|
631
|
+
click.echo("TRAILING 30-DAY (T-30):")
|
|
632
|
+
for comparison in trends_data['t30_comparisons']:
|
|
633
|
+
baseline_week = comparison['baseline_week']['label']
|
|
634
|
+
curr_week = comparison['curr_week']['label']
|
|
635
|
+
num_increases = len(comparison['increases'])
|
|
636
|
+
num_decreases = len(comparison['decreases'])
|
|
637
|
+
|
|
638
|
+
click.echo(f" {curr_week} vs {baseline_week}")
|
|
639
|
+
click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
|
|
640
|
+
|
|
641
|
+
if comparison['increases']:
|
|
642
|
+
top = comparison['increases'][0]
|
|
643
|
+
click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
|
|
644
|
+
|
|
645
|
+
click.echo("")
|
|
646
|
+
|
|
647
|
+
|
|
648
|
+
@cli.command()
|
|
649
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
650
|
+
@click.option('--months', default=6, help='Number of months to analyze (default: 6)')
|
|
651
|
+
@click.option('--output', default='monthly_trends.md', help='Output markdown file (default: monthly_trends.md)')
|
|
652
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
|
|
653
|
+
def monthly(profile, months, output, json_output):
|
|
654
|
+
"""Analyze month-over-month cost trends at service level"""
|
|
655
|
+
|
|
656
|
+
# Load profile configuration
|
|
657
|
+
config = load_profile(profile)
|
|
658
|
+
|
|
659
|
+
# Initialize boto3 client
|
|
660
|
+
try:
|
|
661
|
+
if 'aws_profile' in config:
|
|
662
|
+
aws_profile = config['aws_profile']
|
|
663
|
+
click.echo(f"AWS Profile: {aws_profile} (SSO)")
|
|
664
|
+
session = boto3.Session(profile_name=aws_profile)
|
|
665
|
+
else:
|
|
666
|
+
# Use static credentials
|
|
667
|
+
creds = config['credentials']
|
|
668
|
+
click.echo("AWS Credentials: Static")
|
|
669
|
+
session = boto3.Session(
|
|
670
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
671
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
672
|
+
aws_session_token=creds.get('aws_session_token')
|
|
673
|
+
)
|
|
674
|
+
|
|
675
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
676
|
+
except Exception as e:
|
|
677
|
+
raise click.ClickException(f"Failed to initialize AWS session: {str(e)}")
|
|
678
|
+
|
|
679
|
+
# Get account list
|
|
680
|
+
accounts = config['accounts']
|
|
681
|
+
|
|
682
|
+
click.echo(f"Analyzing last {months} months...")
|
|
683
|
+
click.echo("")
|
|
684
|
+
|
|
685
|
+
# Analyze monthly trends
|
|
686
|
+
monthly_data = analyze_monthly_trends(ce_client, accounts, months)
|
|
687
|
+
|
|
688
|
+
if json_output:
|
|
689
|
+
# Output as JSON
|
|
690
|
+
output_data = {
|
|
691
|
+
'generated': datetime.now().isoformat(),
|
|
692
|
+
'months': months,
|
|
693
|
+
'comparisons': []
|
|
694
|
+
}
|
|
695
|
+
|
|
696
|
+
for comparison in monthly_data['comparisons']:
|
|
697
|
+
output_data['comparisons'].append({
|
|
698
|
+
'prev_month': comparison['prev_month']['label'],
|
|
699
|
+
'curr_month': comparison['curr_month']['label'],
|
|
700
|
+
'increases': comparison['increases'],
|
|
701
|
+
'decreases': comparison['decreases'],
|
|
702
|
+
'total_increase': comparison['total_increase'],
|
|
703
|
+
'total_decrease': comparison['total_decrease']
|
|
704
|
+
})
|
|
705
|
+
|
|
706
|
+
click.echo(json.dumps(output_data, indent=2))
|
|
707
|
+
else:
|
|
708
|
+
# Generate markdown report
|
|
709
|
+
markdown = format_monthly_markdown(monthly_data)
|
|
710
|
+
|
|
711
|
+
# Save to file
|
|
712
|
+
with open(output, 'w') as f:
|
|
713
|
+
f.write(markdown)
|
|
714
|
+
|
|
715
|
+
click.echo(f"✓ Monthly trends report saved to {output}")
|
|
716
|
+
click.echo("")
|
|
717
|
+
|
|
718
|
+
# Show summary
|
|
719
|
+
for comparison in monthly_data['comparisons']:
|
|
720
|
+
prev_month = comparison['prev_month']['label']
|
|
721
|
+
curr_month = comparison['curr_month']['label']
|
|
722
|
+
num_increases = len(comparison['increases'])
|
|
723
|
+
num_decreases = len(comparison['decreases'])
|
|
724
|
+
|
|
725
|
+
click.echo(f"{prev_month} → {curr_month}")
|
|
726
|
+
click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
|
|
727
|
+
|
|
728
|
+
if comparison['increases']:
|
|
729
|
+
top = comparison['increases'][0]
|
|
730
|
+
click.echo(f" Top: {top['service']} (+${top['change']:,.2f})")
|
|
731
|
+
|
|
732
|
+
click.echo("")
|
|
733
|
+
|
|
734
|
+
|
|
735
|
+
@cli.command()
|
|
736
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
737
|
+
@click.option('--weeks', default=4, help='Number of weeks to analyze (default: 4)')
|
|
738
|
+
@click.option('--service', help='Filter by service name (e.g., "EC2 - Other")')
|
|
739
|
+
@click.option('--account', help='Filter by account ID')
|
|
740
|
+
@click.option('--usage-type', help='Filter by usage type')
|
|
741
|
+
@click.option('--output', default='drill_down.md', help='Output markdown file (default: drill_down.md)')
|
|
742
|
+
@click.option('--json-output', is_flag=True, help='Output as JSON instead of markdown')
|
|
743
|
+
def drill(profile, weeks, service, account, usage_type, output, json_output):
|
|
744
|
+
"""Drill down into cost changes by service, account, or usage type"""
|
|
745
|
+
|
|
746
|
+
# Load profile configuration
|
|
747
|
+
config = load_profile(profile)
|
|
748
|
+
|
|
749
|
+
# Initialize boto3 client
|
|
750
|
+
try:
|
|
751
|
+
if 'aws_profile' in config:
|
|
752
|
+
aws_profile = config['aws_profile']
|
|
753
|
+
click.echo(f"AWS Profile: {aws_profile} (SSO)")
|
|
754
|
+
session = boto3.Session(profile_name=aws_profile)
|
|
755
|
+
else:
|
|
756
|
+
# Use static credentials
|
|
757
|
+
creds = config['credentials']
|
|
758
|
+
click.echo("AWS Credentials: Static")
|
|
759
|
+
session = boto3.Session(
|
|
760
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
761
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
762
|
+
aws_session_token=creds.get('aws_session_token')
|
|
763
|
+
)
|
|
764
|
+
|
|
765
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
766
|
+
except Exception as e:
|
|
767
|
+
raise click.ClickException(f"Failed to initialize AWS session: {str(e)}")
|
|
768
|
+
|
|
769
|
+
# Get account list
|
|
770
|
+
accounts = config['accounts']
|
|
771
|
+
|
|
772
|
+
# Show filters
|
|
773
|
+
click.echo(f"Analyzing last {weeks} weeks...")
|
|
774
|
+
if service:
|
|
775
|
+
click.echo(f" Service filter: {service}")
|
|
776
|
+
if account:
|
|
777
|
+
click.echo(f" Account filter: {account}")
|
|
778
|
+
if usage_type:
|
|
779
|
+
click.echo(f" Usage type filter: {usage_type}")
|
|
780
|
+
click.echo("")
|
|
781
|
+
|
|
782
|
+
# Analyze with drill-down
|
|
783
|
+
drill_data = analyze_drill_down(
|
|
784
|
+
ce_client, accounts, weeks,
|
|
785
|
+
service_filter=service,
|
|
786
|
+
account_filter=account,
|
|
787
|
+
usage_type_filter=usage_type
|
|
788
|
+
)
|
|
789
|
+
|
|
790
|
+
if json_output:
|
|
791
|
+
# Output as JSON
|
|
792
|
+
output_data = {
|
|
793
|
+
'generated': datetime.now().isoformat(),
|
|
794
|
+
'weeks': weeks,
|
|
795
|
+
'filters': drill_data['filters'],
|
|
796
|
+
'group_by': drill_data['group_by'],
|
|
797
|
+
'comparisons': []
|
|
798
|
+
}
|
|
799
|
+
|
|
800
|
+
for comparison in drill_data['comparisons']:
|
|
801
|
+
output_data['comparisons'].append({
|
|
802
|
+
'prev_week': comparison['prev_week']['label'],
|
|
803
|
+
'curr_week': comparison['curr_week']['label'],
|
|
804
|
+
'increases': comparison['increases'],
|
|
805
|
+
'decreases': comparison['decreases'],
|
|
806
|
+
'total_increase': comparison['total_increase'],
|
|
807
|
+
'total_decrease': comparison['total_decrease']
|
|
808
|
+
})
|
|
809
|
+
|
|
810
|
+
click.echo(json.dumps(output_data, indent=2))
|
|
811
|
+
else:
|
|
812
|
+
# Generate markdown report
|
|
813
|
+
markdown = format_drill_down_markdown(drill_data)
|
|
814
|
+
|
|
815
|
+
# Save to file
|
|
816
|
+
with open(output, 'w') as f:
|
|
817
|
+
f.write(markdown)
|
|
818
|
+
|
|
819
|
+
click.echo(f"✓ Drill-down report saved to {output}")
|
|
820
|
+
click.echo("")
|
|
821
|
+
|
|
822
|
+
# Show summary
|
|
823
|
+
group_by_label = {
|
|
824
|
+
'SERVICE': 'services',
|
|
825
|
+
'LINKED_ACCOUNT': 'accounts',
|
|
826
|
+
'USAGE_TYPE': 'usage types',
|
|
827
|
+
'REGION': 'regions'
|
|
828
|
+
}.get(drill_data['group_by'], 'items')
|
|
829
|
+
|
|
830
|
+
click.echo(f"Showing top {group_by_label}:")
|
|
831
|
+
for comparison in drill_data['comparisons']:
|
|
832
|
+
prev_week = comparison['prev_week']['label']
|
|
833
|
+
curr_week = comparison['curr_week']['label']
|
|
834
|
+
num_increases = len(comparison['increases'])
|
|
835
|
+
num_decreases = len(comparison['decreases'])
|
|
836
|
+
|
|
837
|
+
click.echo(f"{prev_week} → {curr_week}")
|
|
838
|
+
click.echo(f" Increases: {num_increases}, Decreases: {num_decreases}")
|
|
839
|
+
|
|
840
|
+
if comparison['increases']:
|
|
841
|
+
top = comparison['increases'][0]
|
|
842
|
+
click.echo(f" Top: {top['dimension'][:50]} (+${top['change']:,.2f})")
|
|
843
|
+
|
|
844
|
+
click.echo("")
|
|
845
|
+
|
|
846
|
+
|
|
847
|
+
if __name__ == '__main__':
|
|
848
|
+
cli()
|