aws-cost-calculator-cli 1.8.2__py3-none-any.whl → 1.11.1__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of aws-cost-calculator-cli might be problematic. Click here for more details.
- {aws_cost_calculator_cli-1.8.2.dist-info → aws_cost_calculator_cli-1.11.1.dist-info}/METADATA +1 -1
- aws_cost_calculator_cli-1.11.1.dist-info/RECORD +15 -0
- cost_calculator/cli.py +762 -89
- cost_calculator/executor.py +93 -133
- cost_calculator/forensics.py +4 -2
- aws_cost_calculator_cli-1.8.2.dist-info/RECORD +0 -15
- {aws_cost_calculator_cli-1.8.2.dist-info → aws_cost_calculator_cli-1.11.1.dist-info}/WHEEL +0 -0
- {aws_cost_calculator_cli-1.8.2.dist-info → aws_cost_calculator_cli-1.11.1.dist-info}/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.8.2.dist-info → aws_cost_calculator_cli-1.11.1.dist-info}/licenses/LICENSE +0 -0
- {aws_cost_calculator_cli-1.8.2.dist-info → aws_cost_calculator_cli-1.11.1.dist-info}/top_level.txt +0 -0
{aws_cost_calculator_cli-1.8.2.dist-info → aws_cost_calculator_cli-1.11.1.dist-info}/METADATA
RENAMED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aws-cost-calculator-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.11.1
|
|
4
4
|
Summary: AWS Cost Calculator CLI - Calculate daily and annual AWS costs across multiple accounts
|
|
5
5
|
Home-page: https://github.com/trilogy-group/aws-cost-calculator
|
|
6
6
|
Author: Cost Optimization Team
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
aws_cost_calculator_cli-1.11.1.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
|
|
2
|
+
cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
|
|
3
|
+
cost_calculator/api_client.py,sha256=4ZI2XcGIN3FBeQqb7xOxQ91kCoeM43-rExiOELXoKBQ,2485
|
|
4
|
+
cost_calculator/cli.py,sha256=OudMcAitmJfWgZKxjlHtfXnx2SlKrYh6FzPTkHabJlI,77975
|
|
5
|
+
cost_calculator/cur.py,sha256=QaZ_nyDSw5_cti-h5Ho6eYLbqzY5TWoub24DpyzIiSs,9502
|
|
6
|
+
cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
|
|
7
|
+
cost_calculator/executor.py,sha256=yZTCUgJc1OpB892O3mq9ZA0Yekc7N-HvaW8xLFyrXjo,8681
|
|
8
|
+
cost_calculator/forensics.py,sha256=uhRo3I_zOeMEaBENHfgq65URga31W0Z4vzS2UN6VmTY,12819
|
|
9
|
+
cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
|
|
10
|
+
cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
|
|
11
|
+
aws_cost_calculator_cli-1.11.1.dist-info/METADATA,sha256=d-AHFprwq0-lD-D3ilWa7IGBxOszOdMqP7nJl5J0gyA,11979
|
|
12
|
+
aws_cost_calculator_cli-1.11.1.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
|
|
13
|
+
aws_cost_calculator_cli-1.11.1.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
|
|
14
|
+
aws_cost_calculator_cli-1.11.1.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
|
|
15
|
+
aws_cost_calculator_cli-1.11.1.dist-info/RECORD,,
|
cost_calculator/cli.py
CHANGED
|
@@ -68,7 +68,7 @@ def apply_auth_options(config, sso=None, access_key_id=None, secret_access_key=N
|
|
|
68
68
|
|
|
69
69
|
|
|
70
70
|
def load_profile(profile_name):
|
|
71
|
-
"""Load profile configuration from local file
|
|
71
|
+
"""Load profile configuration from DynamoDB API or local file as fallback"""
|
|
72
72
|
import os
|
|
73
73
|
import requests
|
|
74
74
|
|
|
@@ -76,7 +76,76 @@ def load_profile(profile_name):
|
|
|
76
76
|
config_file = config_dir / 'profiles.json'
|
|
77
77
|
creds_file = config_dir / 'credentials.json'
|
|
78
78
|
|
|
79
|
-
# Try
|
|
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
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
if response.status_code == 200:
|
|
91
|
+
response_data = response.json()
|
|
92
|
+
# API returns {"profile": {...}} wrapper
|
|
93
|
+
profile_data = response_data.get('profile', response_data)
|
|
94
|
+
profile = {'accounts': profile_data['accounts']}
|
|
95
|
+
|
|
96
|
+
# If profile has aws_profile field, use it
|
|
97
|
+
if 'aws_profile' in profile_data:
|
|
98
|
+
profile['aws_profile'] = profile_data['aws_profile']
|
|
99
|
+
# Check for AWS_PROFILE environment variable (SSO support)
|
|
100
|
+
elif os.environ.get('AWS_PROFILE'):
|
|
101
|
+
profile['aws_profile'] = os.environ['AWS_PROFILE']
|
|
102
|
+
# Use environment credentials
|
|
103
|
+
elif os.environ.get('AWS_ACCESS_KEY_ID'):
|
|
104
|
+
profile['credentials'] = {
|
|
105
|
+
'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
|
|
106
|
+
'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
107
|
+
'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
|
|
108
|
+
}
|
|
109
|
+
else:
|
|
110
|
+
# Try to find a matching AWS profile by name
|
|
111
|
+
# This allows "khoros" profile to work with "khoros_umbrella" AWS profile
|
|
112
|
+
import subprocess
|
|
113
|
+
try:
|
|
114
|
+
result = subprocess.run(
|
|
115
|
+
['aws', 'configure', 'list-profiles'],
|
|
116
|
+
capture_output=True,
|
|
117
|
+
text=True,
|
|
118
|
+
timeout=5
|
|
119
|
+
)
|
|
120
|
+
if result.returncode == 0:
|
|
121
|
+
available_profiles = result.stdout.strip().split('\n')
|
|
122
|
+
# Try exact match first
|
|
123
|
+
if profile_name in available_profiles:
|
|
124
|
+
profile['aws_profile'] = profile_name
|
|
125
|
+
# Try with common suffixes
|
|
126
|
+
elif f"{profile_name}_umbrella" in available_profiles:
|
|
127
|
+
profile['aws_profile'] = f"{profile_name}_umbrella"
|
|
128
|
+
elif f"{profile_name}-umbrella" in available_profiles:
|
|
129
|
+
profile['aws_profile'] = f"{profile_name}-umbrella"
|
|
130
|
+
elif f"{profile_name}_prod" in available_profiles:
|
|
131
|
+
profile['aws_profile'] = f"{profile_name}_prod"
|
|
132
|
+
# If no match found, leave it unset - user must provide --sso
|
|
133
|
+
except:
|
|
134
|
+
# If we can't list profiles, leave it unset - user must provide --sso
|
|
135
|
+
pass
|
|
136
|
+
|
|
137
|
+
return profile
|
|
138
|
+
else:
|
|
139
|
+
raise click.ClickException(
|
|
140
|
+
f"Profile '{profile_name}' not found in DynamoDB.\n"
|
|
141
|
+
f"Run: cc profile create --name {profile_name} --accounts \"...\""
|
|
142
|
+
)
|
|
143
|
+
except requests.exceptions.RequestException as e:
|
|
144
|
+
raise click.ClickException(
|
|
145
|
+
f"Failed to fetch profile from API: {e}\n"
|
|
146
|
+
)
|
|
147
|
+
|
|
148
|
+
# Fallback to local file if no API secret
|
|
80
149
|
if config_file.exists():
|
|
81
150
|
with open(config_file) as f:
|
|
82
151
|
profiles = json.load(f)
|
|
@@ -114,50 +183,11 @@ def load_profile(profile_name):
|
|
|
114
183
|
|
|
115
184
|
return profile
|
|
116
185
|
|
|
117
|
-
# Profile not found
|
|
118
|
-
|
|
119
|
-
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
f"Run: cc init --profile {profile_name}"
|
|
123
|
-
)
|
|
124
|
-
|
|
125
|
-
try:
|
|
126
|
-
response = requests.post(
|
|
127
|
-
'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
|
|
128
|
-
headers={'X-API-Secret': api_secret, 'Content-Type': 'application/json'},
|
|
129
|
-
json={'operation': 'get', 'profile_name': profile_name},
|
|
130
|
-
timeout=10
|
|
131
|
-
)
|
|
132
|
-
|
|
133
|
-
if response.status_code == 200:
|
|
134
|
-
response_data = response.json()
|
|
135
|
-
# API returns {"profile": {...}} wrapper
|
|
136
|
-
profile_data = response_data.get('profile', response_data)
|
|
137
|
-
profile = {'accounts': profile_data['accounts']}
|
|
138
|
-
|
|
139
|
-
# Check for AWS_PROFILE environment variable (SSO support)
|
|
140
|
-
if os.environ.get('AWS_PROFILE'):
|
|
141
|
-
profile['aws_profile'] = os.environ['AWS_PROFILE']
|
|
142
|
-
# Use environment credentials
|
|
143
|
-
elif os.environ.get('AWS_ACCESS_KEY_ID'):
|
|
144
|
-
profile['credentials'] = {
|
|
145
|
-
'aws_access_key_id': os.environ['AWS_ACCESS_KEY_ID'],
|
|
146
|
-
'aws_secret_access_key': os.environ['AWS_SECRET_ACCESS_KEY'],
|
|
147
|
-
'aws_session_token': os.environ.get('AWS_SESSION_TOKEN')
|
|
148
|
-
}
|
|
149
|
-
|
|
150
|
-
return profile
|
|
151
|
-
else:
|
|
152
|
-
raise click.ClickException(
|
|
153
|
-
f"Profile '{profile_name}' not found in DynamoDB.\n"
|
|
154
|
-
f"Run: cc profile create --name {profile_name} --accounts \"...\""
|
|
155
|
-
)
|
|
156
|
-
except requests.exceptions.RequestException as e:
|
|
157
|
-
raise click.ClickException(
|
|
158
|
-
f"Failed to fetch profile from API: {e}\n"
|
|
159
|
-
f"Run: cc init --profile {profile_name}"
|
|
160
|
-
)
|
|
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
|
+
)
|
|
161
191
|
|
|
162
192
|
|
|
163
193
|
def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
@@ -1175,6 +1205,27 @@ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
|
|
|
1175
1205
|
if sso:
|
|
1176
1206
|
config['aws_profile'] = sso
|
|
1177
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
|
+
|
|
1178
1229
|
# Step 1: Cost Analysis
|
|
1179
1230
|
click.echo("Step 1/3: Analyzing cost trends...")
|
|
1180
1231
|
try:
|
|
@@ -1218,27 +1269,23 @@ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
|
|
|
1218
1269
|
click.echo(f" ⚠️ No credentials available for account")
|
|
1219
1270
|
continue
|
|
1220
1271
|
|
|
1221
|
-
# Inventory resources via backend API
|
|
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
|
+
|
|
1222
1277
|
try:
|
|
1223
1278
|
regions = ['us-west-2', 'us-east-1', 'eu-west-1']
|
|
1224
1279
|
for region in regions:
|
|
1225
1280
|
try:
|
|
1226
|
-
|
|
1227
|
-
|
|
1228
|
-
|
|
1229
|
-
|
|
1230
|
-
|
|
1231
|
-
|
|
1232
|
-
|
|
1233
|
-
|
|
1234
|
-
)
|
|
1235
|
-
else:
|
|
1236
|
-
# Fallback to local execution
|
|
1237
|
-
from cost_calculator.forensics import inventory_resources
|
|
1238
|
-
acc_profile = find_account_profile(acc_id)
|
|
1239
|
-
if not acc_profile:
|
|
1240
|
-
raise Exception("No SSO profile found and API not configured")
|
|
1241
|
-
inv = inventory_resources(acc_id, acc_profile, region)
|
|
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
|
+
)
|
|
1242
1289
|
|
|
1243
1290
|
if not inv.get('error'):
|
|
1244
1291
|
inventories.append(inv)
|
|
@@ -1252,13 +1299,15 @@ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
|
|
|
1252
1299
|
except Exception as e:
|
|
1253
1300
|
click.echo(f" ✗ Inventory error: {str(e)}")
|
|
1254
1301
|
|
|
1255
|
-
# CloudTrail analysis via backend API
|
|
1302
|
+
# CloudTrail analysis via backend API only
|
|
1256
1303
|
if not no_cloudtrail:
|
|
1257
|
-
|
|
1258
|
-
|
|
1259
|
-
|
|
1260
|
-
|
|
1261
|
-
|
|
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
|
+
|
|
1262
1311
|
ct_analysis = call_lambda_api(
|
|
1263
1312
|
'forensics',
|
|
1264
1313
|
account_creds,
|
|
@@ -1269,26 +1318,17 @@ def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
|
|
|
1269
1318
|
end_date=end_date,
|
|
1270
1319
|
region='us-west-2'
|
|
1271
1320
|
)
|
|
1272
|
-
|
|
1273
|
-
|
|
1274
|
-
|
|
1275
|
-
|
|
1276
|
-
|
|
1277
|
-
|
|
1278
|
-
|
|
1279
|
-
|
|
1280
|
-
|
|
1281
|
-
|
|
1282
|
-
|
|
1283
|
-
|
|
1284
|
-
if ct_analysis.get('error'):
|
|
1285
|
-
click.echo(f" ⚠️ CloudTrail: {ct_analysis['error']}")
|
|
1286
|
-
else:
|
|
1287
|
-
click.echo(f" ✓ CloudTrail analysis complete")
|
|
1288
|
-
click.echo(f" - {len(ct_analysis['event_summary'])} event types")
|
|
1289
|
-
click.echo(f" - {len(ct_analysis['write_events'])} resource changes")
|
|
1290
|
-
except Exception as e:
|
|
1291
|
-
click.echo(f" ✗ CloudTrail error: {str(e)}")
|
|
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)}")
|
|
1292
1332
|
|
|
1293
1333
|
# Generate report
|
|
1294
1334
|
click.echo(f"\nGenerating report...")
|
|
@@ -1340,5 +1380,638 @@ def find_account_profile(account_id):
|
|
|
1340
1380
|
return None
|
|
1341
1381
|
|
|
1342
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
|
+
Shows day-by-day costs for specific services and accounts, useful for:
|
|
1397
|
+
- Identifying cost spikes on specific dates
|
|
1398
|
+
- Validating daily cost patterns
|
|
1399
|
+
- Calculating precise daily averages
|
|
1400
|
+
|
|
1401
|
+
Examples:
|
|
1402
|
+
# Last 10 days of CloudWatch costs for specific account
|
|
1403
|
+
cc daily --profile khoros --days 10 --service AmazonCloudWatch --account 820054669588
|
|
1404
|
+
|
|
1405
|
+
# Custom date range with JSON output for automation
|
|
1406
|
+
cc daily --profile khoros --start-date 2025-10-28 --end-date 2025-11-06 --json
|
|
1407
|
+
|
|
1408
|
+
# Find high-cost days using jq
|
|
1409
|
+
cc daily --profile khoros --days 30 --json | jq '.daily_costs | map(select(.cost > 1000))'
|
|
1410
|
+
"""
|
|
1411
|
+
# Load profile
|
|
1412
|
+
config = load_profile(profile)
|
|
1413
|
+
|
|
1414
|
+
# Apply SSO if provided
|
|
1415
|
+
if sso:
|
|
1416
|
+
config['aws_profile'] = sso
|
|
1417
|
+
|
|
1418
|
+
# Calculate date range
|
|
1419
|
+
if end_date:
|
|
1420
|
+
end = datetime.strptime(end_date, '%Y-%m-%d')
|
|
1421
|
+
else:
|
|
1422
|
+
end = datetime.now()
|
|
1423
|
+
|
|
1424
|
+
if start_date:
|
|
1425
|
+
start = datetime.strptime(start_date, '%Y-%m-%d')
|
|
1426
|
+
else:
|
|
1427
|
+
start = end - timedelta(days=days)
|
|
1428
|
+
|
|
1429
|
+
click.echo(f"Daily breakdown: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
|
|
1430
|
+
if service:
|
|
1431
|
+
click.echo(f"Service filter: {service}")
|
|
1432
|
+
if account:
|
|
1433
|
+
click.echo(f"Account filter: {account}")
|
|
1434
|
+
click.echo("")
|
|
1435
|
+
|
|
1436
|
+
# Get credentials
|
|
1437
|
+
try:
|
|
1438
|
+
if 'aws_profile' in config:
|
|
1439
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1440
|
+
else:
|
|
1441
|
+
creds = config['credentials']
|
|
1442
|
+
session = boto3.Session(
|
|
1443
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1444
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1445
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1446
|
+
)
|
|
1447
|
+
|
|
1448
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
1449
|
+
|
|
1450
|
+
# Build filter
|
|
1451
|
+
filter_parts = []
|
|
1452
|
+
|
|
1453
|
+
# Account filter
|
|
1454
|
+
if account:
|
|
1455
|
+
filter_parts.append({
|
|
1456
|
+
"Dimensions": {
|
|
1457
|
+
"Key": "LINKED_ACCOUNT",
|
|
1458
|
+
"Values": [account]
|
|
1459
|
+
}
|
|
1460
|
+
})
|
|
1461
|
+
else:
|
|
1462
|
+
filter_parts.append({
|
|
1463
|
+
"Dimensions": {
|
|
1464
|
+
"Key": "LINKED_ACCOUNT",
|
|
1465
|
+
"Values": config['accounts']
|
|
1466
|
+
}
|
|
1467
|
+
})
|
|
1468
|
+
|
|
1469
|
+
# Service filter
|
|
1470
|
+
if service:
|
|
1471
|
+
filter_parts.append({
|
|
1472
|
+
"Dimensions": {
|
|
1473
|
+
"Key": "SERVICE",
|
|
1474
|
+
"Values": [service]
|
|
1475
|
+
}
|
|
1476
|
+
})
|
|
1477
|
+
|
|
1478
|
+
# Exclude support and tax
|
|
1479
|
+
filter_parts.append({
|
|
1480
|
+
"Not": {
|
|
1481
|
+
"Dimensions": {
|
|
1482
|
+
"Key": "RECORD_TYPE",
|
|
1483
|
+
"Values": ["Tax", "Support"]
|
|
1484
|
+
}
|
|
1485
|
+
}
|
|
1486
|
+
})
|
|
1487
|
+
|
|
1488
|
+
cost_filter = {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
|
|
1489
|
+
|
|
1490
|
+
# Get daily costs
|
|
1491
|
+
response = ce_client.get_cost_and_usage(
|
|
1492
|
+
TimePeriod={
|
|
1493
|
+
'Start': start.strftime('%Y-%m-%d'),
|
|
1494
|
+
'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1495
|
+
},
|
|
1496
|
+
Granularity='DAILY',
|
|
1497
|
+
Metrics=['UnblendedCost'],
|
|
1498
|
+
Filter=cost_filter
|
|
1499
|
+
)
|
|
1500
|
+
|
|
1501
|
+
# Collect results
|
|
1502
|
+
daily_costs = []
|
|
1503
|
+
total = 0
|
|
1504
|
+
for day in response['ResultsByTime']:
|
|
1505
|
+
date = day['TimePeriod']['Start']
|
|
1506
|
+
cost = float(day['Total']['UnblendedCost']['Amount'])
|
|
1507
|
+
total += cost
|
|
1508
|
+
daily_costs.append({'date': date, 'cost': cost})
|
|
1509
|
+
|
|
1510
|
+
num_days = len(response['ResultsByTime'])
|
|
1511
|
+
daily_avg = total / num_days if num_days > 0 else 0
|
|
1512
|
+
annual = daily_avg * 365
|
|
1513
|
+
|
|
1514
|
+
# Output results
|
|
1515
|
+
if output_json:
|
|
1516
|
+
import json
|
|
1517
|
+
result = {
|
|
1518
|
+
'period': {
|
|
1519
|
+
'start': start.strftime('%Y-%m-%d'),
|
|
1520
|
+
'end': end.strftime('%Y-%m-%d'),
|
|
1521
|
+
'days': num_days
|
|
1522
|
+
},
|
|
1523
|
+
'filters': {
|
|
1524
|
+
'service': service,
|
|
1525
|
+
'account': account
|
|
1526
|
+
},
|
|
1527
|
+
'daily_costs': daily_costs,
|
|
1528
|
+
'summary': {
|
|
1529
|
+
'total': total,
|
|
1530
|
+
'daily_avg': daily_avg,
|
|
1531
|
+
'annual_projection': annual
|
|
1532
|
+
}
|
|
1533
|
+
}
|
|
1534
|
+
click.echo(json.dumps(result, indent=2))
|
|
1535
|
+
else:
|
|
1536
|
+
click.echo("Date | Cost")
|
|
1537
|
+
click.echo("-----------|-----------")
|
|
1538
|
+
for item in daily_costs:
|
|
1539
|
+
click.echo(f"{item['date']} | ${item['cost']:,.2f}")
|
|
1540
|
+
click.echo("-----------|-----------")
|
|
1541
|
+
click.echo(f"Total | ${total:,.2f}")
|
|
1542
|
+
click.echo(f"Daily Avg | ${daily_avg:,.2f}")
|
|
1543
|
+
click.echo(f"Annual | ${annual:,.0f}")
|
|
1544
|
+
|
|
1545
|
+
except Exception as e:
|
|
1546
|
+
raise click.ClickException(f"Failed to get daily costs: {e}")
|
|
1547
|
+
|
|
1548
|
+
|
|
1549
|
+
@cli.command()
|
|
1550
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1551
|
+
@click.option('--account', help='Account ID to compare')
|
|
1552
|
+
@click.option('--service', help='Service to compare')
|
|
1553
|
+
@click.option('--before', required=True, help='Before period (YYYY-MM-DD:YYYY-MM-DD)')
|
|
1554
|
+
@click.option('--after', required=True, help='After period (YYYY-MM-DD:YYYY-MM-DD)')
|
|
1555
|
+
@click.option('--expected-reduction', type=float, help='Expected reduction percentage')
|
|
1556
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1557
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
1558
|
+
def compare(profile, account, service, before, after, expected_reduction, sso, output_json):
|
|
1559
|
+
"""
|
|
1560
|
+
Compare costs between two periods for validation and analysis.
|
|
1561
|
+
|
|
1562
|
+
Perfect for:
|
|
1563
|
+
- Validating cost optimization savings
|
|
1564
|
+
- Before/after migration analysis
|
|
1565
|
+
- Measuring impact of infrastructure changes
|
|
1566
|
+
- Automated savings validation in CI/CD
|
|
1567
|
+
|
|
1568
|
+
Examples:
|
|
1569
|
+
# Validate Datadog migration savings (expect 50% reduction)
|
|
1570
|
+
cc compare --profile khoros --account 180770971501 --service AmazonCloudWatch \
|
|
1571
|
+
--before "2025-10-28:2025-11-06" --after "2025-11-17:2025-11-26" --expected-reduction 50
|
|
1572
|
+
|
|
1573
|
+
# Compare total costs across all accounts
|
|
1574
|
+
cc compare --profile khoros --before "2025-10-01:2025-10-31" --after "2025-11-01:2025-11-30"
|
|
1575
|
+
|
|
1576
|
+
# JSON output for automated validation
|
|
1577
|
+
cc compare --profile khoros --service EC2 --before "2025-10-01:2025-10-07" \
|
|
1578
|
+
--after "2025-11-08:2025-11-14" --json | jq '.comparison.met_expectation'
|
|
1579
|
+
"""
|
|
1580
|
+
# Load profile
|
|
1581
|
+
config = load_profile(profile)
|
|
1582
|
+
|
|
1583
|
+
# Apply SSO if provided
|
|
1584
|
+
if sso:
|
|
1585
|
+
config['aws_profile'] = sso
|
|
1586
|
+
|
|
1587
|
+
# Parse periods
|
|
1588
|
+
try:
|
|
1589
|
+
before_start, before_end = before.split(':')
|
|
1590
|
+
after_start, after_end = after.split(':')
|
|
1591
|
+
except ValueError:
|
|
1592
|
+
raise click.ClickException("Period format must be 'YYYY-MM-DD:YYYY-MM-DD'")
|
|
1593
|
+
|
|
1594
|
+
if not output_json:
|
|
1595
|
+
click.echo(f"Comparing periods:")
|
|
1596
|
+
click.echo(f" Before: {before_start} to {before_end}")
|
|
1597
|
+
click.echo(f" After: {after_start} to {after_end}")
|
|
1598
|
+
if service:
|
|
1599
|
+
click.echo(f" Service: {service}")
|
|
1600
|
+
if account:
|
|
1601
|
+
click.echo(f" Account: {account}")
|
|
1602
|
+
click.echo("")
|
|
1603
|
+
|
|
1604
|
+
# Get credentials
|
|
1605
|
+
try:
|
|
1606
|
+
if 'aws_profile' in config:
|
|
1607
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1608
|
+
else:
|
|
1609
|
+
creds = config['credentials']
|
|
1610
|
+
session = boto3.Session(
|
|
1611
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1612
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1613
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1614
|
+
)
|
|
1615
|
+
|
|
1616
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
1617
|
+
|
|
1618
|
+
# Build filter
|
|
1619
|
+
def build_filter():
|
|
1620
|
+
filter_parts = []
|
|
1621
|
+
|
|
1622
|
+
if account:
|
|
1623
|
+
filter_parts.append({
|
|
1624
|
+
"Dimensions": {
|
|
1625
|
+
"Key": "LINKED_ACCOUNT",
|
|
1626
|
+
"Values": [account]
|
|
1627
|
+
}
|
|
1628
|
+
})
|
|
1629
|
+
else:
|
|
1630
|
+
filter_parts.append({
|
|
1631
|
+
"Dimensions": {
|
|
1632
|
+
"Key": "LINKED_ACCOUNT",
|
|
1633
|
+
"Values": config['accounts']
|
|
1634
|
+
}
|
|
1635
|
+
})
|
|
1636
|
+
|
|
1637
|
+
if service:
|
|
1638
|
+
filter_parts.append({
|
|
1639
|
+
"Dimensions": {
|
|
1640
|
+
"Key": "SERVICE",
|
|
1641
|
+
"Values": [service]
|
|
1642
|
+
}
|
|
1643
|
+
})
|
|
1644
|
+
|
|
1645
|
+
filter_parts.append({
|
|
1646
|
+
"Not": {
|
|
1647
|
+
"Dimensions": {
|
|
1648
|
+
"Key": "RECORD_TYPE",
|
|
1649
|
+
"Values": ["Tax", "Support"]
|
|
1650
|
+
}
|
|
1651
|
+
}
|
|
1652
|
+
})
|
|
1653
|
+
|
|
1654
|
+
return {"And": filter_parts} if len(filter_parts) > 1 else filter_parts[0]
|
|
1655
|
+
|
|
1656
|
+
cost_filter = build_filter()
|
|
1657
|
+
|
|
1658
|
+
# Get before period costs
|
|
1659
|
+
before_response = ce_client.get_cost_and_usage(
|
|
1660
|
+
TimePeriod={
|
|
1661
|
+
'Start': before_start,
|
|
1662
|
+
'End': (datetime.strptime(before_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1663
|
+
},
|
|
1664
|
+
Granularity='DAILY',
|
|
1665
|
+
Metrics=['UnblendedCost'],
|
|
1666
|
+
Filter=cost_filter
|
|
1667
|
+
)
|
|
1668
|
+
|
|
1669
|
+
# Get after period costs
|
|
1670
|
+
after_response = ce_client.get_cost_and_usage(
|
|
1671
|
+
TimePeriod={
|
|
1672
|
+
'Start': after_start,
|
|
1673
|
+
'End': (datetime.strptime(after_end, '%Y-%m-%d') + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1674
|
+
},
|
|
1675
|
+
Granularity='DAILY',
|
|
1676
|
+
Metrics=['UnblendedCost'],
|
|
1677
|
+
Filter=cost_filter
|
|
1678
|
+
)
|
|
1679
|
+
|
|
1680
|
+
# Calculate totals
|
|
1681
|
+
before_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in before_response['ResultsByTime'])
|
|
1682
|
+
after_total = sum(float(day['Total']['UnblendedCost']['Amount']) for day in after_response['ResultsByTime'])
|
|
1683
|
+
|
|
1684
|
+
before_days = len(before_response['ResultsByTime'])
|
|
1685
|
+
after_days = len(after_response['ResultsByTime'])
|
|
1686
|
+
|
|
1687
|
+
before_daily = before_total / before_days if before_days > 0 else 0
|
|
1688
|
+
after_daily = after_total / after_days if after_days > 0 else 0
|
|
1689
|
+
|
|
1690
|
+
reduction = before_daily - after_daily
|
|
1691
|
+
reduction_pct = (reduction / before_daily * 100) if before_daily > 0 else 0
|
|
1692
|
+
annual_savings = reduction * 365
|
|
1693
|
+
|
|
1694
|
+
# Output results
|
|
1695
|
+
if output_json:
|
|
1696
|
+
import json
|
|
1697
|
+
result = {
|
|
1698
|
+
'before': {
|
|
1699
|
+
'period': {'start': before_start, 'end': before_end},
|
|
1700
|
+
'total': before_total,
|
|
1701
|
+
'daily_avg': before_daily,
|
|
1702
|
+
'days': before_days
|
|
1703
|
+
},
|
|
1704
|
+
'after': {
|
|
1705
|
+
'period': {'start': after_start, 'end': after_end},
|
|
1706
|
+
'total': after_total,
|
|
1707
|
+
'daily_avg': after_daily,
|
|
1708
|
+
'days': after_days
|
|
1709
|
+
},
|
|
1710
|
+
'comparison': {
|
|
1711
|
+
'daily_reduction': reduction,
|
|
1712
|
+
'reduction_pct': reduction_pct,
|
|
1713
|
+
'annual_savings': annual_savings
|
|
1714
|
+
}
|
|
1715
|
+
}
|
|
1716
|
+
|
|
1717
|
+
if expected_reduction is not None:
|
|
1718
|
+
result['comparison']['expected_reduction_pct'] = expected_reduction
|
|
1719
|
+
result['comparison']['met_expectation'] = reduction_pct >= expected_reduction
|
|
1720
|
+
|
|
1721
|
+
click.echo(json.dumps(result, indent=2))
|
|
1722
|
+
else:
|
|
1723
|
+
click.echo("Before Period:")
|
|
1724
|
+
click.echo(f" Total: ${before_total:,.2f}")
|
|
1725
|
+
click.echo(f" Daily Avg: ${before_daily:,.2f}")
|
|
1726
|
+
click.echo(f" Days: {before_days}")
|
|
1727
|
+
click.echo("")
|
|
1728
|
+
click.echo("After Period:")
|
|
1729
|
+
click.echo(f" Total: ${after_total:,.2f}")
|
|
1730
|
+
click.echo(f" Daily Avg: ${after_daily:,.2f}")
|
|
1731
|
+
click.echo(f" Days: {after_days}")
|
|
1732
|
+
click.echo("")
|
|
1733
|
+
click.echo("Comparison:")
|
|
1734
|
+
click.echo(f" Daily Reduction: ${reduction:,.2f}")
|
|
1735
|
+
click.echo(f" Reduction %: {reduction_pct:.1f}%")
|
|
1736
|
+
click.echo(f" Annual Savings: ${annual_savings:,.0f}")
|
|
1737
|
+
|
|
1738
|
+
if expected_reduction is not None:
|
|
1739
|
+
click.echo("")
|
|
1740
|
+
if reduction_pct >= expected_reduction:
|
|
1741
|
+
click.echo(f"✅ Savings achieved: {reduction_pct:.1f}% (expected {expected_reduction}%)")
|
|
1742
|
+
else:
|
|
1743
|
+
click.echo(f"⚠️ Below target: {reduction_pct:.1f}% (expected {expected_reduction}%)")
|
|
1744
|
+
|
|
1745
|
+
except Exception as e:
|
|
1746
|
+
raise click.ClickException(f"Comparison failed: {e}")
|
|
1747
|
+
|
|
1748
|
+
|
|
1749
|
+
@cli.command()
|
|
1750
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1751
|
+
@click.option('--tag-key', required=True, help='Tag key to filter by')
|
|
1752
|
+
@click.option('--tag-value', help='Tag value to filter by (optional)')
|
|
1753
|
+
@click.option('--start-date', help='Start date (YYYY-MM-DD)')
|
|
1754
|
+
@click.option('--end-date', help='End date (YYYY-MM-DD)')
|
|
1755
|
+
@click.option('--days', type=int, default=30, help='Number of days to analyze (default: 30)')
|
|
1756
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1757
|
+
@click.option('--json', 'output_json', is_flag=True, help='Output as JSON')
|
|
1758
|
+
def tags(profile, tag_key, tag_value, start_date, end_date, days, sso, output_json):
|
|
1759
|
+
"""
|
|
1760
|
+
Analyze costs grouped by resource tags for cost attribution.
|
|
1761
|
+
|
|
1762
|
+
Useful for:
|
|
1763
|
+
- Cost allocation by team, project, or environment
|
|
1764
|
+
- Identifying untagged resources (cost attribution gaps)
|
|
1765
|
+
- Tracking costs by cost center or department
|
|
1766
|
+
- Validating tagging compliance
|
|
1767
|
+
|
|
1768
|
+
Examples:
|
|
1769
|
+
# See all costs by Environment tag
|
|
1770
|
+
cc tags --profile khoros --tag-key "Environment" --days 30
|
|
1771
|
+
|
|
1772
|
+
# Filter to specific tag value
|
|
1773
|
+
cc tags --profile khoros --tag-key "Team" --tag-value "Platform" --days 30
|
|
1774
|
+
|
|
1775
|
+
# Find top cost centers with JSON output
|
|
1776
|
+
cc tags --profile khoros --tag-key "CostCenter" --days 30 --json | \
|
|
1777
|
+
jq '.tag_costs | sort_by(-.cost) | .[:5]'
|
|
1778
|
+
|
|
1779
|
+
# Identify untagged resources (look for empty tag values)
|
|
1780
|
+
cc tags --profile khoros --tag-key "Owner" --days 7
|
|
1781
|
+
"""
|
|
1782
|
+
# Load profile
|
|
1783
|
+
config = load_profile(profile)
|
|
1784
|
+
|
|
1785
|
+
# Apply SSO if provided
|
|
1786
|
+
if sso:
|
|
1787
|
+
config['aws_profile'] = sso
|
|
1788
|
+
|
|
1789
|
+
# Calculate date range
|
|
1790
|
+
if end_date:
|
|
1791
|
+
end = datetime.strptime(end_date, '%Y-%m-%d')
|
|
1792
|
+
else:
|
|
1793
|
+
end = datetime.now()
|
|
1794
|
+
|
|
1795
|
+
if start_date:
|
|
1796
|
+
start = datetime.strptime(start_date, '%Y-%m-%d')
|
|
1797
|
+
else:
|
|
1798
|
+
start = end - timedelta(days=days)
|
|
1799
|
+
|
|
1800
|
+
if not output_json:
|
|
1801
|
+
click.echo(f"Tag-based cost analysis: {start.strftime('%Y-%m-%d')} to {end.strftime('%Y-%m-%d')}")
|
|
1802
|
+
click.echo(f"Tag key: {tag_key}")
|
|
1803
|
+
if tag_value:
|
|
1804
|
+
click.echo(f"Tag value: {tag_value}")
|
|
1805
|
+
click.echo("")
|
|
1806
|
+
|
|
1807
|
+
# Get credentials
|
|
1808
|
+
try:
|
|
1809
|
+
if 'aws_profile' in config:
|
|
1810
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1811
|
+
else:
|
|
1812
|
+
creds = config['credentials']
|
|
1813
|
+
session = boto3.Session(
|
|
1814
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1815
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1816
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1817
|
+
)
|
|
1818
|
+
|
|
1819
|
+
ce_client = session.client('ce', region_name='us-east-1')
|
|
1820
|
+
|
|
1821
|
+
# Build filter
|
|
1822
|
+
filter_parts = [
|
|
1823
|
+
{
|
|
1824
|
+
"Dimensions": {
|
|
1825
|
+
"Key": "LINKED_ACCOUNT",
|
|
1826
|
+
"Values": config['accounts']
|
|
1827
|
+
}
|
|
1828
|
+
},
|
|
1829
|
+
{
|
|
1830
|
+
"Not": {
|
|
1831
|
+
"Dimensions": {
|
|
1832
|
+
"Key": "RECORD_TYPE",
|
|
1833
|
+
"Values": ["Tax", "Support"]
|
|
1834
|
+
}
|
|
1835
|
+
}
|
|
1836
|
+
}
|
|
1837
|
+
]
|
|
1838
|
+
|
|
1839
|
+
# Add tag filter if value specified
|
|
1840
|
+
if tag_value:
|
|
1841
|
+
filter_parts.append({
|
|
1842
|
+
"Tags": {
|
|
1843
|
+
"Key": tag_key,
|
|
1844
|
+
"Values": [tag_value]
|
|
1845
|
+
}
|
|
1846
|
+
})
|
|
1847
|
+
|
|
1848
|
+
cost_filter = {"And": filter_parts}
|
|
1849
|
+
|
|
1850
|
+
# Get costs grouped by tag values
|
|
1851
|
+
response = ce_client.get_cost_and_usage(
|
|
1852
|
+
TimePeriod={
|
|
1853
|
+
'Start': start.strftime('%Y-%m-%d'),
|
|
1854
|
+
'End': (end + timedelta(days=1)).strftime('%Y-%m-%d')
|
|
1855
|
+
},
|
|
1856
|
+
Granularity='MONTHLY',
|
|
1857
|
+
Metrics=['UnblendedCost'],
|
|
1858
|
+
GroupBy=[{
|
|
1859
|
+
'Type': 'TAG',
|
|
1860
|
+
'Key': tag_key
|
|
1861
|
+
}],
|
|
1862
|
+
Filter=cost_filter
|
|
1863
|
+
)
|
|
1864
|
+
|
|
1865
|
+
# Collect results
|
|
1866
|
+
tag_costs = {}
|
|
1867
|
+
for period in response['ResultsByTime']:
|
|
1868
|
+
for group in period['Groups']:
|
|
1869
|
+
tag_val = group['Keys'][0].split('$')[1] if '$' in group['Keys'][0] else group['Keys'][0]
|
|
1870
|
+
cost = float(group['Metrics']['UnblendedCost']['Amount'])
|
|
1871
|
+
tag_costs[tag_val] = tag_costs.get(tag_val, 0) + cost
|
|
1872
|
+
|
|
1873
|
+
# Sort by cost
|
|
1874
|
+
sorted_tags = sorted(tag_costs.items(), key=lambda x: x[1], reverse=True)
|
|
1875
|
+
|
|
1876
|
+
total = sum(tag_costs.values())
|
|
1877
|
+
num_days = (end - start).days
|
|
1878
|
+
daily_avg = total / num_days if num_days > 0 else 0
|
|
1879
|
+
|
|
1880
|
+
# Output results
|
|
1881
|
+
if output_json:
|
|
1882
|
+
import json
|
|
1883
|
+
result = {
|
|
1884
|
+
'period': {
|
|
1885
|
+
'start': start.strftime('%Y-%m-%d'),
|
|
1886
|
+
'end': end.strftime('%Y-%m-%d'),
|
|
1887
|
+
'days': num_days
|
|
1888
|
+
},
|
|
1889
|
+
'tag_key': tag_key,
|
|
1890
|
+
'tag_value_filter': tag_value,
|
|
1891
|
+
'tag_costs': [{'tag_value': k, 'cost': v, 'percentage': (v/total*100) if total > 0 else 0} for k, v in sorted_tags],
|
|
1892
|
+
'summary': {
|
|
1893
|
+
'total': total,
|
|
1894
|
+
'daily_avg': daily_avg,
|
|
1895
|
+
'annual_projection': daily_avg * 365
|
|
1896
|
+
}
|
|
1897
|
+
}
|
|
1898
|
+
click.echo(json.dumps(result, indent=2))
|
|
1899
|
+
else:
|
|
1900
|
+
click.echo(f"Tag Value{' '*(30-len('Tag Value'))} | Cost | %")
|
|
1901
|
+
click.echo("-" * 60)
|
|
1902
|
+
for tag_val, cost in sorted_tags:
|
|
1903
|
+
pct = (cost / total * 100) if total > 0 else 0
|
|
1904
|
+
tag_display = tag_val[:30].ljust(30)
|
|
1905
|
+
click.echo(f"{tag_display} | ${cost:>9,.2f} | {pct:>5.1f}%")
|
|
1906
|
+
click.echo("-" * 60)
|
|
1907
|
+
click.echo(f"{'Total'.ljust(30)} | ${total:>9,.2f} | 100.0%")
|
|
1908
|
+
click.echo("")
|
|
1909
|
+
click.echo(f"Daily Avg: ${daily_avg:,.2f}")
|
|
1910
|
+
click.echo(f"Annual Projection: ${daily_avg * 365:,.0f}")
|
|
1911
|
+
|
|
1912
|
+
except Exception as e:
|
|
1913
|
+
raise click.ClickException(f"Tag analysis failed: {e}")
|
|
1914
|
+
|
|
1915
|
+
|
|
1916
|
+
@cli.command()
|
|
1917
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1918
|
+
@click.option('--query', required=True, help='SQL query to execute')
|
|
1919
|
+
@click.option('--database', default='athenacurcfn_cloud_intelligence_dashboard', help='Athena database name')
|
|
1920
|
+
@click.option('--output-bucket', help='S3 bucket for query results (default: from profile)')
|
|
1921
|
+
@click.option('--sso', help='AWS SSO profile name')
|
|
1922
|
+
def query(profile, query, database, output_bucket, sso):
|
|
1923
|
+
"""
|
|
1924
|
+
Execute custom Athena SQL query on CUR data
|
|
1925
|
+
|
|
1926
|
+
Example:
|
|
1927
|
+
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"
|
|
1928
|
+
"""
|
|
1929
|
+
# Load profile
|
|
1930
|
+
config = load_profile(profile)
|
|
1931
|
+
|
|
1932
|
+
# Apply SSO if provided
|
|
1933
|
+
if sso:
|
|
1934
|
+
config['aws_profile'] = sso
|
|
1935
|
+
|
|
1936
|
+
# Get credentials
|
|
1937
|
+
try:
|
|
1938
|
+
if 'aws_profile' in config:
|
|
1939
|
+
session = boto3.Session(profile_name=config['aws_profile'])
|
|
1940
|
+
else:
|
|
1941
|
+
creds = config['credentials']
|
|
1942
|
+
session = boto3.Session(
|
|
1943
|
+
aws_access_key_id=creds['aws_access_key_id'],
|
|
1944
|
+
aws_secret_access_key=creds['aws_secret_access_key'],
|
|
1945
|
+
aws_session_token=creds.get('aws_session_token')
|
|
1946
|
+
)
|
|
1947
|
+
|
|
1948
|
+
athena_client = session.client('athena', region_name='us-east-1')
|
|
1949
|
+
|
|
1950
|
+
# Default output location
|
|
1951
|
+
if not output_bucket:
|
|
1952
|
+
output_bucket = 's3://khoros-finops-athena/athena/'
|
|
1953
|
+
|
|
1954
|
+
click.echo(f"Executing query on database: {database}")
|
|
1955
|
+
click.echo(f"Output location: {output_bucket}")
|
|
1956
|
+
click.echo("")
|
|
1957
|
+
|
|
1958
|
+
# Execute query
|
|
1959
|
+
response = athena_client.start_query_execution(
|
|
1960
|
+
QueryString=query,
|
|
1961
|
+
QueryExecutionContext={'Database': database},
|
|
1962
|
+
ResultConfiguration={'OutputLocation': output_bucket}
|
|
1963
|
+
)
|
|
1964
|
+
|
|
1965
|
+
query_id = response['QueryExecutionId']
|
|
1966
|
+
click.echo(f"Query ID: {query_id}")
|
|
1967
|
+
click.echo("Waiting for query to complete...")
|
|
1968
|
+
|
|
1969
|
+
# Wait for completion
|
|
1970
|
+
import time
|
|
1971
|
+
max_wait = 60
|
|
1972
|
+
waited = 0
|
|
1973
|
+
while waited < max_wait:
|
|
1974
|
+
status_response = athena_client.get_query_execution(QueryExecutionId=query_id)
|
|
1975
|
+
status = status_response['QueryExecution']['Status']['State']
|
|
1976
|
+
|
|
1977
|
+
if status == 'SUCCEEDED':
|
|
1978
|
+
click.echo("✓ Query completed successfully")
|
|
1979
|
+
break
|
|
1980
|
+
elif status in ['FAILED', 'CANCELLED']:
|
|
1981
|
+
reason = status_response['QueryExecution']['Status'].get('StateChangeReason', 'Unknown')
|
|
1982
|
+
raise click.ClickException(f"Query {status}: {reason}")
|
|
1983
|
+
|
|
1984
|
+
time.sleep(2)
|
|
1985
|
+
waited += 2
|
|
1986
|
+
|
|
1987
|
+
if waited >= max_wait:
|
|
1988
|
+
raise click.ClickException(f"Query timeout after {max_wait}s. Check query ID: {query_id}")
|
|
1989
|
+
|
|
1990
|
+
# Get results
|
|
1991
|
+
results = athena_client.get_query_results(QueryExecutionId=query_id)
|
|
1992
|
+
|
|
1993
|
+
# Display results
|
|
1994
|
+
rows = results['ResultSet']['Rows']
|
|
1995
|
+
if not rows:
|
|
1996
|
+
click.echo("No results returned")
|
|
1997
|
+
return
|
|
1998
|
+
|
|
1999
|
+
# Header
|
|
2000
|
+
headers = [col['VarCharValue'] for col in rows[0]['Data']]
|
|
2001
|
+
click.echo(" | ".join(headers))
|
|
2002
|
+
click.echo("-" * (len(" | ".join(headers))))
|
|
2003
|
+
|
|
2004
|
+
# Data rows
|
|
2005
|
+
for row in rows[1:]:
|
|
2006
|
+
values = [col.get('VarCharValue', '') for col in row['Data']]
|
|
2007
|
+
click.echo(" | ".join(values))
|
|
2008
|
+
|
|
2009
|
+
click.echo("")
|
|
2010
|
+
click.echo(f"Returned {len(rows)-1} rows")
|
|
2011
|
+
|
|
2012
|
+
except Exception as e:
|
|
2013
|
+
raise click.ClickException(f"Query failed: {e}")
|
|
2014
|
+
|
|
2015
|
+
|
|
1343
2016
|
if __name__ == '__main__':
|
|
1344
2017
|
cli()
|
cost_calculator/executor.py
CHANGED
|
@@ -3,6 +3,7 @@ Executor that routes to either API or local execution.
|
|
|
3
3
|
"""
|
|
4
4
|
import boto3
|
|
5
5
|
import click
|
|
6
|
+
from pathlib import Path
|
|
6
7
|
from cost_calculator.api_client import is_api_configured, call_lambda_api
|
|
7
8
|
|
|
8
9
|
|
|
@@ -22,6 +23,13 @@ def get_credentials_dict(config):
|
|
|
22
23
|
try:
|
|
23
24
|
session = boto3.Session(profile_name=config['aws_profile'])
|
|
24
25
|
credentials = session.get_credentials()
|
|
26
|
+
|
|
27
|
+
if credentials is None:
|
|
28
|
+
raise Exception(
|
|
29
|
+
f"Could not get credentials for profile '{config['aws_profile']}'.\n"
|
|
30
|
+
f"Run: aws sso login --profile {config['aws_profile']}"
|
|
31
|
+
)
|
|
32
|
+
|
|
25
33
|
frozen_creds = credentials.get_frozen_credentials()
|
|
26
34
|
|
|
27
35
|
return {
|
|
@@ -29,9 +37,44 @@ def get_credentials_dict(config):
|
|
|
29
37
|
'secret_key': frozen_creds.secret_key,
|
|
30
38
|
'session_token': frozen_creds.token
|
|
31
39
|
}
|
|
32
|
-
except Exception:
|
|
33
|
-
#
|
|
34
|
-
|
|
40
|
+
except Exception as e:
|
|
41
|
+
# Show the actual error instead of silently returning None
|
|
42
|
+
error_msg = str(e)
|
|
43
|
+
|
|
44
|
+
# If it's an SSO token error, provide better guidance
|
|
45
|
+
if 'SSO Token' in error_msg or 'sso' in error_msg.lower():
|
|
46
|
+
# Try to detect if using sso_session format
|
|
47
|
+
import subprocess
|
|
48
|
+
try:
|
|
49
|
+
result = subprocess.run(
|
|
50
|
+
['grep', '-A', '3', f'profile {config["aws_profile"]}',
|
|
51
|
+
str(Path.home() / '.aws' / 'config')],
|
|
52
|
+
capture_output=True,
|
|
53
|
+
text=True,
|
|
54
|
+
timeout=2
|
|
55
|
+
)
|
|
56
|
+
if 'sso_session' in result.stdout:
|
|
57
|
+
# Extract session name
|
|
58
|
+
for line in result.stdout.split('\n'):
|
|
59
|
+
if 'sso_session' in line:
|
|
60
|
+
session_name = line.split('=')[1].strip()
|
|
61
|
+
raise Exception(
|
|
62
|
+
f"Failed to get AWS credentials for profile '{config['aws_profile']}'.\n"
|
|
63
|
+
f"Error: {error_msg}\n\n"
|
|
64
|
+
f"Your profile uses SSO session '{session_name}'.\n"
|
|
65
|
+
f"Try: aws sso login --sso-session {session_name}\n"
|
|
66
|
+
f"(Requires AWS CLI v2.9.0+)\n\n"
|
|
67
|
+
f"Or: aws sso login --profile {config['aws_profile']}\n"
|
|
68
|
+
f"(If using older AWS CLI)"
|
|
69
|
+
)
|
|
70
|
+
except:
|
|
71
|
+
pass
|
|
72
|
+
|
|
73
|
+
raise Exception(
|
|
74
|
+
f"Failed to get AWS credentials for profile '{config['aws_profile']}'.\n"
|
|
75
|
+
f"Error: {error_msg}\n"
|
|
76
|
+
f"Try: aws sso login --profile {config['aws_profile']}"
|
|
77
|
+
)
|
|
35
78
|
else:
|
|
36
79
|
# Use static credentials
|
|
37
80
|
creds = config.get('credentials', {})
|
|
@@ -56,32 +99,18 @@ def execute_trends(config, weeks):
|
|
|
56
99
|
"""
|
|
57
100
|
accounts = config['accounts']
|
|
58
101
|
|
|
59
|
-
if is_api_configured():
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
session = boto3.Session(profile_name=config['aws_profile'])
|
|
72
|
-
else:
|
|
73
|
-
creds = config['credentials']
|
|
74
|
-
session_kwargs = {
|
|
75
|
-
'aws_access_key_id': creds['aws_access_key_id'],
|
|
76
|
-
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
77
|
-
'region_name': creds.get('region', 'us-east-1')
|
|
78
|
-
}
|
|
79
|
-
if 'aws_session_token' in creds:
|
|
80
|
-
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
81
|
-
session = boto3.Session(**session_kwargs)
|
|
82
|
-
|
|
83
|
-
ce_client = session.client('ce', region_name='us-east-1')
|
|
84
|
-
return analyze_trends(ce_client, accounts, weeks)
|
|
102
|
+
if not is_api_configured():
|
|
103
|
+
raise Exception(
|
|
104
|
+
"API not configured. Set COST_API_SECRET environment variable.\n"
|
|
105
|
+
"Local execution is disabled. Use the Lambda API."
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
# Use API only
|
|
109
|
+
click.echo("Using Lambda API...")
|
|
110
|
+
credentials = get_credentials_dict(config)
|
|
111
|
+
if not credentials:
|
|
112
|
+
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
113
|
+
return call_lambda_api('trends', credentials, accounts, weeks=weeks)
|
|
85
114
|
|
|
86
115
|
|
|
87
116
|
def execute_monthly(config, months):
|
|
@@ -93,37 +122,23 @@ def execute_monthly(config, months):
|
|
|
93
122
|
"""
|
|
94
123
|
accounts = config['accounts']
|
|
95
124
|
|
|
96
|
-
if is_api_configured():
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
session = boto3.Session(profile_name=config['aws_profile'])
|
|
109
|
-
else:
|
|
110
|
-
creds = config['credentials']
|
|
111
|
-
session_kwargs = {
|
|
112
|
-
'aws_access_key_id': creds['aws_access_key_id'],
|
|
113
|
-
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
114
|
-
'region_name': creds.get('region', 'us-east-1')
|
|
115
|
-
}
|
|
116
|
-
if 'aws_session_token' in creds:
|
|
117
|
-
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
118
|
-
session = boto3.Session(**session_kwargs)
|
|
119
|
-
|
|
120
|
-
ce_client = session.client('ce', region_name='us-east-1')
|
|
121
|
-
return analyze_monthly_trends(ce_client, accounts, months)
|
|
125
|
+
if not is_api_configured():
|
|
126
|
+
raise Exception(
|
|
127
|
+
"API not configured. Set COST_API_SECRET environment variable.\n"
|
|
128
|
+
"Local execution is disabled. Use the Lambda API."
|
|
129
|
+
)
|
|
130
|
+
|
|
131
|
+
# Use API only
|
|
132
|
+
click.echo("Using Lambda API...")
|
|
133
|
+
credentials = get_credentials_dict(config)
|
|
134
|
+
if not credentials:
|
|
135
|
+
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
136
|
+
return call_lambda_api('monthly', credentials, accounts, months=months)
|
|
122
137
|
|
|
123
138
|
|
|
124
139
|
def execute_drill(config, weeks, service_filter=None, account_filter=None, usage_type_filter=None, resources=False):
|
|
125
140
|
"""
|
|
126
|
-
Execute drill-down analysis via API
|
|
141
|
+
Execute drill-down analysis via API.
|
|
127
142
|
|
|
128
143
|
Args:
|
|
129
144
|
config: Profile configuration
|
|
@@ -138,86 +153,31 @@ def execute_drill(config, weeks, service_filter=None, account_filter=None, usage
|
|
|
138
153
|
"""
|
|
139
154
|
accounts = config['accounts']
|
|
140
155
|
|
|
156
|
+
if not is_api_configured():
|
|
157
|
+
raise Exception(
|
|
158
|
+
"API not configured. Set COST_API_SECRET environment variable.\n"
|
|
159
|
+
"Local execution is disabled. Use the Lambda API."
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
# Use API only
|
|
163
|
+
click.echo("Using Lambda API...")
|
|
164
|
+
credentials = get_credentials_dict(config)
|
|
165
|
+
if not credentials:
|
|
166
|
+
raise Exception("Failed to get AWS credentials. Check your AWS SSO session.")
|
|
167
|
+
|
|
168
|
+
kwargs = {'weeks': weeks}
|
|
169
|
+
if service_filter:
|
|
170
|
+
kwargs['service'] = service_filter
|
|
171
|
+
if account_filter:
|
|
172
|
+
kwargs['account'] = account_filter
|
|
173
|
+
if usage_type_filter:
|
|
174
|
+
kwargs['usage_type'] = usage_type_filter
|
|
141
175
|
if resources:
|
|
142
|
-
# Resource-level drill requires service filter
|
|
143
176
|
if not service_filter:
|
|
144
177
|
raise click.ClickException("--service is required when using --resources flag")
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
click.echo("Using Lambda API for CUR resource query...")
|
|
149
|
-
credentials = get_credentials_dict(config)
|
|
150
|
-
kwargs = {
|
|
151
|
-
'weeks': weeks,
|
|
152
|
-
'service': service_filter,
|
|
153
|
-
'resources': True
|
|
154
|
-
}
|
|
155
|
-
if account_filter:
|
|
156
|
-
kwargs['account'] = account_filter
|
|
157
|
-
return call_lambda_api('drill', credentials, accounts, **kwargs)
|
|
158
|
-
else:
|
|
159
|
-
# Use local Athena client
|
|
160
|
-
click.echo("Using local Athena client for CUR resource query...")
|
|
161
|
-
from cost_calculator.cur import query_cur_resources
|
|
162
|
-
|
|
163
|
-
# Initialize boto3 session
|
|
164
|
-
if 'aws_profile' in config:
|
|
165
|
-
session = boto3.Session(profile_name=config['aws_profile'])
|
|
166
|
-
else:
|
|
167
|
-
creds = config['credentials']
|
|
168
|
-
session_kwargs = {
|
|
169
|
-
'aws_access_key_id': creds['aws_access_key_id'],
|
|
170
|
-
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
171
|
-
'region_name': creds.get('region', 'us-east-1')
|
|
172
|
-
}
|
|
173
|
-
if 'aws_session_token' in creds:
|
|
174
|
-
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
175
|
-
session = boto3.Session(**session_kwargs)
|
|
176
|
-
|
|
177
|
-
athena_client = session.client('athena', region_name='us-east-1')
|
|
178
|
-
return query_cur_resources(
|
|
179
|
-
athena_client, accounts, service_filter, account_filter, weeks
|
|
180
|
-
)
|
|
181
|
-
else:
|
|
182
|
-
# Standard drill-down via Cost Explorer
|
|
183
|
-
if is_api_configured():
|
|
184
|
-
# Use API
|
|
185
|
-
click.echo("Using Lambda API...")
|
|
186
|
-
credentials = get_credentials_dict(config)
|
|
187
|
-
kwargs = {'weeks': weeks}
|
|
188
|
-
if service_filter:
|
|
189
|
-
kwargs['service'] = service_filter
|
|
190
|
-
if account_filter:
|
|
191
|
-
kwargs['account'] = account_filter
|
|
192
|
-
if usage_type_filter:
|
|
193
|
-
kwargs['usage_type'] = usage_type_filter
|
|
194
|
-
return call_lambda_api('drill', credentials, accounts, **kwargs)
|
|
195
|
-
else:
|
|
196
|
-
# Use local execution
|
|
197
|
-
click.echo("Using local execution...")
|
|
198
|
-
from cost_calculator.drill import analyze_drill_down
|
|
199
|
-
|
|
200
|
-
# Initialize boto3 client
|
|
201
|
-
if 'aws_profile' in config:
|
|
202
|
-
session = boto3.Session(profile_name=config['aws_profile'])
|
|
203
|
-
else:
|
|
204
|
-
creds = config['credentials']
|
|
205
|
-
session_kwargs = {
|
|
206
|
-
'aws_access_key_id': creds['aws_access_key_id'],
|
|
207
|
-
'aws_secret_access_key': creds['aws_secret_access_key'],
|
|
208
|
-
'region_name': creds.get('region', 'us-east-1')
|
|
209
|
-
}
|
|
210
|
-
if 'aws_session_token' in creds:
|
|
211
|
-
session_kwargs['aws_session_token'] = creds['aws_session_token']
|
|
212
|
-
session = boto3.Session(**session_kwargs)
|
|
213
|
-
|
|
214
|
-
ce_client = session.client('ce', region_name='us-east-1')
|
|
215
|
-
return analyze_drill_down(
|
|
216
|
-
ce_client, accounts, weeks,
|
|
217
|
-
service_filter=service_filter,
|
|
218
|
-
account_filter=account_filter,
|
|
219
|
-
usage_type_filter=usage_type_filter
|
|
220
|
-
)
|
|
178
|
+
kwargs['resources'] = True
|
|
179
|
+
|
|
180
|
+
return call_lambda_api('drill', credentials, accounts, **kwargs)
|
|
221
181
|
|
|
222
182
|
|
|
223
183
|
def execute_analyze(config, weeks, analysis_type, pattern=None, min_cost=None):
|
cost_calculator/forensics.py
CHANGED
|
@@ -236,7 +236,8 @@ def format_investigation_report(cost_data, inventories, cloudtrail_data=None):
|
|
|
236
236
|
report.append("")
|
|
237
237
|
|
|
238
238
|
for inv in inventories:
|
|
239
|
-
|
|
239
|
+
profile_name = inv.get('profile', inv['account_id'])
|
|
240
|
+
report.append(f"### Account {inv['account_id']} ({profile_name})")
|
|
240
241
|
report.append(f"**Region:** {inv['region']}")
|
|
241
242
|
report.append("")
|
|
242
243
|
|
|
@@ -288,7 +289,8 @@ def format_investigation_report(cost_data, inventories, cloudtrail_data=None):
|
|
|
288
289
|
report.append("")
|
|
289
290
|
|
|
290
291
|
for ct in cloudtrail_data:
|
|
291
|
-
|
|
292
|
+
profile_name = ct.get('profile', ct['account_id'])
|
|
293
|
+
report.append(f"### Account {ct['account_id']} ({profile_name})")
|
|
292
294
|
report.append(f"**Period:** {ct['start_date'][:10]} to {ct['end_date'][:10]}")
|
|
293
295
|
report.append("")
|
|
294
296
|
|
|
@@ -1,15 +0,0 @@
|
|
|
1
|
-
aws_cost_calculator_cli-1.8.2.dist-info/licenses/LICENSE,sha256=cYtmQZHNGGTXOtg3T7LHDRneleaH0dHXHfxFV3WR50Y,1079
|
|
2
|
-
cost_calculator/__init__.py,sha256=PJeIqvWh5AYJVrJxPPkI4pJnAt37rIjasrNS0I87kaM,52
|
|
3
|
-
cost_calculator/api_client.py,sha256=4ZI2XcGIN3FBeQqb7xOxQ91kCoeM43-rExiOELXoKBQ,2485
|
|
4
|
-
cost_calculator/cli.py,sha256=jkyPlHAdVgpk5LrP1T9YvBK4-K6hM5fd538VqIHw-7A,52255
|
|
5
|
-
cost_calculator/cur.py,sha256=QaZ_nyDSw5_cti-h5Ho6eYLbqzY5TWoub24DpyzIiSs,9502
|
|
6
|
-
cost_calculator/drill.py,sha256=hGi-prLgZDvNMMICQc4fl3LenM7YaZ3To_Ei4LKwrdc,10543
|
|
7
|
-
cost_calculator/executor.py,sha256=aWLELeisDu6b-5U5LDPDPnK5uJKilYcoUPerxPUqtYg,10435
|
|
8
|
-
cost_calculator/forensics.py,sha256=Ny5IbMhJF6kawQDiLDExQGb5C-zRDbbmziS5JoinmdA,12694
|
|
9
|
-
cost_calculator/monthly.py,sha256=6k9F8S7djhX1wGV3-T1MZP7CvWbbfhSTEaddwCfVu5M,7932
|
|
10
|
-
cost_calculator/trends.py,sha256=k_s4ylBX50sqoiM_fwepi58HW01zz767FMJhQUPDznk,12246
|
|
11
|
-
aws_cost_calculator_cli-1.8.2.dist-info/METADATA,sha256=wCkJT5XL6m3ESmbK0mdfzSpkWiJn9PJQ6nuUBelILXU,11978
|
|
12
|
-
aws_cost_calculator_cli-1.8.2.dist-info/WHEEL,sha256=Nw36Djuh_5VDukK0H78QzOX-_FQEo6V37m3nkm96gtU,91
|
|
13
|
-
aws_cost_calculator_cli-1.8.2.dist-info/entry_points.txt,sha256=_5Qy4EcHbYVYrdgOu1E48faMHb9fLUl5VJ3djDHuJBo,47
|
|
14
|
-
aws_cost_calculator_cli-1.8.2.dist-info/top_level.txt,sha256=PRwGPPlNqASfyhGHDjSfyl4SXeE7GF3OVTu1tY1Uqyc,16
|
|
15
|
-
aws_cost_calculator_cli-1.8.2.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{aws_cost_calculator_cli-1.8.2.dist-info → aws_cost_calculator_cli-1.11.1.dist-info}/top_level.txt
RENAMED
|
File without changes
|