aws-cost-calculator-cli 1.7.0__tar.gz → 1.8.3__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/CHANGES.md +19 -0
- {aws_cost_calculator_cli-1.7.0/aws_cost_calculator_cli.egg-info → aws_cost_calculator_cli-1.8.3}/PKG-INFO +1 -1
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3/aws_cost_calculator_cli.egg-info}/PKG-INFO +1 -1
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/aws_cost_calculator_cli.egg-info/SOURCES.txt +1 -13
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/aws_cost_calculator_cli.egg-info/top_level.txt +0 -1
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/cost_calculator/api_client.py +2 -1
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/cost_calculator/cli.py +206 -1
- aws_cost_calculator_cli-1.8.3/cost_calculator/forensics.py +322 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/setup.py +2 -2
- aws_cost_calculator_cli-1.7.0/backend/__init__.py +0 -1
- aws_cost_calculator_cli-1.7.0/backend/algorithms/__init__.py +0 -1
- aws_cost_calculator_cli-1.7.0/backend/algorithms/analyze.py +0 -272
- aws_cost_calculator_cli-1.7.0/backend/algorithms/cur.py +0 -189
- aws_cost_calculator_cli-1.7.0/backend/handlers/__init__.py +0 -1
- aws_cost_calculator_cli-1.7.0/backend/handlers/analyze.py +0 -112
- aws_cost_calculator_cli-1.7.0/backend/handlers/drill.py +0 -142
- aws_cost_calculator_cli-1.7.0/backend/handlers/monthly.py +0 -106
- aws_cost_calculator_cli-1.7.0/backend/handlers/profiles.py +0 -148
- aws_cost_calculator_cli-1.7.0/backend/handlers/trends.py +0 -106
- aws_cost_calculator_cli-1.7.0/cost_calculator/drill.py +0 -323
- aws_cost_calculator_cli-1.7.0/cost_calculator/monthly.py +0 -242
- aws_cost_calculator_cli-1.7.0/cost_calculator/trends.py +0 -353
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/LICENSE +0 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/MANIFEST.in +0 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/README.md +0 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/aws_cost_calculator_cli.egg-info/dependency_links.txt +0 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/aws_cost_calculator_cli.egg-info/entry_points.txt +0 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/aws_cost_calculator_cli.egg-info/requires.txt +0 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/cost_calculator/__init__.py +0 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/cost_calculator/cur.py +0 -0
- {aws_cost_calculator_cli-1.7.0/backend/algorithms → aws_cost_calculator_cli-1.8.3/cost_calculator}/drill.py +0 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/cost_calculator/executor.py +0 -0
- {aws_cost_calculator_cli-1.7.0/backend/algorithms → aws_cost_calculator_cli-1.8.3/cost_calculator}/monthly.py +0 -0
- {aws_cost_calculator_cli-1.7.0/backend/algorithms → aws_cost_calculator_cli-1.8.3/cost_calculator}/trends.py +0 -0
- {aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/setup.cfg +0 -0
|
@@ -1,5 +1,24 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## Version 1.8.0 (2025-11-07)
|
|
4
|
+
|
|
5
|
+
### Added
|
|
6
|
+
- **New `cc investigate` command** - Multi-stage cost investigation tool
|
|
7
|
+
- Combines cost analysis, resource inventory, and CloudTrail events
|
|
8
|
+
- Automatically finds SSO profiles for accounts
|
|
9
|
+
- Generates comprehensive markdown reports
|
|
10
|
+
- Supports `--no-cloudtrail` flag for faster execution
|
|
11
|
+
- Example: `cc investigate --profile myprofile --account 123456789012`
|
|
12
|
+
|
|
13
|
+
### Changed
|
|
14
|
+
- **Package cleanup** - Excluded backend, tests, and notebooks from PyPI distribution
|
|
15
|
+
- Backend code is only for Lambda deployment, not needed in CLI package
|
|
16
|
+
- Reduces package size and eliminates unnecessary files
|
|
17
|
+
|
|
18
|
+
### Security
|
|
19
|
+
- Verified no sensitive data (company names, account IDs) in PyPI package
|
|
20
|
+
- All example data uses generic placeholders
|
|
21
|
+
|
|
3
22
|
## Version 1.7.0 (2025-11-06)
|
|
4
23
|
|
|
5
24
|
### Added
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aws-cost-calculator-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.3
|
|
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
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: aws-cost-calculator-cli
|
|
3
|
-
Version: 1.
|
|
3
|
+
Version: 1.8.3
|
|
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
|
|
@@ -9,24 +9,12 @@ aws_cost_calculator_cli.egg-info/dependency_links.txt
|
|
|
9
9
|
aws_cost_calculator_cli.egg-info/entry_points.txt
|
|
10
10
|
aws_cost_calculator_cli.egg-info/requires.txt
|
|
11
11
|
aws_cost_calculator_cli.egg-info/top_level.txt
|
|
12
|
-
backend/__init__.py
|
|
13
|
-
backend/algorithms/__init__.py
|
|
14
|
-
backend/algorithms/analyze.py
|
|
15
|
-
backend/algorithms/cur.py
|
|
16
|
-
backend/algorithms/drill.py
|
|
17
|
-
backend/algorithms/monthly.py
|
|
18
|
-
backend/algorithms/trends.py
|
|
19
|
-
backend/handlers/__init__.py
|
|
20
|
-
backend/handlers/analyze.py
|
|
21
|
-
backend/handlers/drill.py
|
|
22
|
-
backend/handlers/monthly.py
|
|
23
|
-
backend/handlers/profiles.py
|
|
24
|
-
backend/handlers/trends.py
|
|
25
12
|
cost_calculator/__init__.py
|
|
26
13
|
cost_calculator/api_client.py
|
|
27
14
|
cost_calculator/cli.py
|
|
28
15
|
cost_calculator/cur.py
|
|
29
16
|
cost_calculator/drill.py
|
|
30
17
|
cost_calculator/executor.py
|
|
18
|
+
cost_calculator/forensics.py
|
|
31
19
|
cost_calculator/monthly.py
|
|
32
20
|
cost_calculator/trends.py
|
{aws_cost_calculator_cli-1.7.0 → aws_cost_calculator_cli-1.8.3}/cost_calculator/api_client.py
RENAMED
|
@@ -50,7 +50,8 @@ def call_lambda_api(endpoint, credentials, accounts, **kwargs):
|
|
|
50
50
|
'monthly': 'https://6aueebodw6q4zdeu3aaexb6tle0fqhhr.lambda-url.us-east-1.on.aws/',
|
|
51
51
|
'drill': 'https://3ncm2gzxrsyptrhud3ua3x5lju0akvsr.lambda-url.us-east-1.on.aws/',
|
|
52
52
|
'analyze': 'https://y6npmidtxwzg62nrqzkbacfs5q0edwgs.lambda-url.us-east-1.on.aws/',
|
|
53
|
-
'profiles': 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/'
|
|
53
|
+
'profiles': 'https://64g7jq7sjygec2zmll5lsghrpi0txrzo.lambda-url.us-east-1.on.aws/',
|
|
54
|
+
'forensics': 'https://gaekfzz7sc2hwn4mjyk64sieke0vadfo.lambda-url.us-east-1.on.aws/' # Will be populated after deployment
|
|
54
55
|
}
|
|
55
56
|
|
|
56
57
|
url = endpoint_urls.get(endpoint)
|
|
@@ -325,7 +325,8 @@ def calculate_costs(profile_config, accounts, start_date, offset, window):
|
|
|
325
325
|
# Calculate days in the month that the support covers
|
|
326
326
|
# Support on Nov 1 covers October (31 days)
|
|
327
327
|
support_month = support_month_date - timedelta(days=1) # Go back to previous month
|
|
328
|
-
|
|
328
|
+
import calendar
|
|
329
|
+
days_in_support_month = calendar.monthrange(support_month.year, support_month.month)[1]
|
|
329
330
|
|
|
330
331
|
# Support allocation: divide by 2 (50% allocation), then by days in month
|
|
331
332
|
support_per_day = (support_cost / 2) / days_in_support_month
|
|
@@ -1135,5 +1136,209 @@ def profile(operation, name, accounts, description):
|
|
|
1135
1136
|
click.echo(result.get('message', 'Operation completed'))
|
|
1136
1137
|
|
|
1137
1138
|
|
|
1139
|
+
@cli.command()
|
|
1140
|
+
@click.option('--profile', required=True, help='Profile name')
|
|
1141
|
+
@click.option('--sso', help='AWS SSO profile to use')
|
|
1142
|
+
@click.option('--weeks', default=8, help='Number of weeks to analyze')
|
|
1143
|
+
@click.option('--account', help='Focus on specific account ID')
|
|
1144
|
+
@click.option('--service', help='Focus on specific service')
|
|
1145
|
+
@click.option('--no-cloudtrail', is_flag=True, help='Skip CloudTrail analysis (faster)')
|
|
1146
|
+
@click.option('--output', default='investigation_report.md', help='Output file path')
|
|
1147
|
+
def investigate(profile, sso, weeks, account, service, no_cloudtrail, output):
|
|
1148
|
+
"""
|
|
1149
|
+
Multi-stage cost investigation:
|
|
1150
|
+
1. Analyze cost trends and drill-downs
|
|
1151
|
+
2. Inventory actual resources in problem accounts
|
|
1152
|
+
3. Analyze CloudTrail events (optional)
|
|
1153
|
+
4. Generate comprehensive report
|
|
1154
|
+
"""
|
|
1155
|
+
from cost_calculator.executor import execute_trends, execute_drill, get_credentials_dict
|
|
1156
|
+
from cost_calculator.api_client import call_lambda_api, is_api_configured
|
|
1157
|
+
from cost_calculator.forensics import format_investigation_report
|
|
1158
|
+
from datetime import datetime, timedelta
|
|
1159
|
+
|
|
1160
|
+
click.echo("=" * 80)
|
|
1161
|
+
click.echo("COST INVESTIGATION")
|
|
1162
|
+
click.echo("=" * 80)
|
|
1163
|
+
click.echo(f"Profile: {profile}")
|
|
1164
|
+
click.echo(f"Weeks: {weeks}")
|
|
1165
|
+
if account:
|
|
1166
|
+
click.echo(f"Account: {account}")
|
|
1167
|
+
if service:
|
|
1168
|
+
click.echo(f"Service: {service}")
|
|
1169
|
+
click.echo("")
|
|
1170
|
+
|
|
1171
|
+
# Load profile
|
|
1172
|
+
config = load_profile(profile)
|
|
1173
|
+
|
|
1174
|
+
# Override with SSO if provided
|
|
1175
|
+
if sso:
|
|
1176
|
+
config['aws_profile'] = sso
|
|
1177
|
+
|
|
1178
|
+
# Step 1: Cost Analysis
|
|
1179
|
+
click.echo("Step 1/3: Analyzing cost trends...")
|
|
1180
|
+
try:
|
|
1181
|
+
trends_data = execute_trends(config, weeks)
|
|
1182
|
+
click.echo(f"✓ Found cost data for {weeks} weeks")
|
|
1183
|
+
except Exception as e:
|
|
1184
|
+
click.echo(f"✗ Error analyzing trends: {str(e)}")
|
|
1185
|
+
trends_data = None
|
|
1186
|
+
|
|
1187
|
+
# Step 2: Drill-down
|
|
1188
|
+
click.echo("\nStep 2/3: Drilling down into costs...")
|
|
1189
|
+
drill_data = None
|
|
1190
|
+
if service or account:
|
|
1191
|
+
try:
|
|
1192
|
+
drill_data = execute_drill(config, weeks, service, account, None, False)
|
|
1193
|
+
click.echo(f"✓ Drill-down complete")
|
|
1194
|
+
except Exception as e:
|
|
1195
|
+
click.echo(f"✗ Error in drill-down: {str(e)}")
|
|
1196
|
+
|
|
1197
|
+
# Step 3: Resource Inventory
|
|
1198
|
+
click.echo("\nStep 3/3: Inventorying resources...")
|
|
1199
|
+
inventories = []
|
|
1200
|
+
cloudtrail_analyses = []
|
|
1201
|
+
|
|
1202
|
+
# Determine which accounts to investigate
|
|
1203
|
+
accounts_to_investigate = []
|
|
1204
|
+
if account:
|
|
1205
|
+
accounts_to_investigate = [account]
|
|
1206
|
+
else:
|
|
1207
|
+
# Extract top cost accounts from trends/drill data
|
|
1208
|
+
# For now, we'll need the user to specify
|
|
1209
|
+
click.echo("⚠️ No account specified. Use --account to inventory resources.")
|
|
1210
|
+
|
|
1211
|
+
# For each account, do inventory and CloudTrail via backend API
|
|
1212
|
+
for acc_id in accounts_to_investigate:
|
|
1213
|
+
click.echo(f"\n Investigating account {acc_id}...")
|
|
1214
|
+
|
|
1215
|
+
# Get credentials (SSO or static)
|
|
1216
|
+
account_creds = get_credentials_dict(config)
|
|
1217
|
+
if not account_creds:
|
|
1218
|
+
click.echo(f" ⚠️ No credentials available for account")
|
|
1219
|
+
continue
|
|
1220
|
+
|
|
1221
|
+
# Inventory resources via backend API
|
|
1222
|
+
try:
|
|
1223
|
+
regions = ['us-west-2', 'us-east-1', 'eu-west-1']
|
|
1224
|
+
for region in regions:
|
|
1225
|
+
try:
|
|
1226
|
+
if is_api_configured():
|
|
1227
|
+
inv = call_lambda_api(
|
|
1228
|
+
'forensics',
|
|
1229
|
+
account_creds,
|
|
1230
|
+
[], # accounts not needed for forensics
|
|
1231
|
+
operation='inventory',
|
|
1232
|
+
account_id=acc_id,
|
|
1233
|
+
region=region
|
|
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)
|
|
1242
|
+
|
|
1243
|
+
if not inv.get('error'):
|
|
1244
|
+
inventories.append(inv)
|
|
1245
|
+
click.echo(f" ✓ Inventory complete for {region}")
|
|
1246
|
+
click.echo(f" - EC2: {len(inv['ec2_instances'])} instances")
|
|
1247
|
+
click.echo(f" - EFS: {len(inv['efs_file_systems'])} file systems ({inv.get('total_efs_size_gb', 0):,.0f} GB)")
|
|
1248
|
+
click.echo(f" - ELB: {len(inv['load_balancers'])} load balancers")
|
|
1249
|
+
break
|
|
1250
|
+
except Exception as e:
|
|
1251
|
+
continue
|
|
1252
|
+
except Exception as e:
|
|
1253
|
+
click.echo(f" ✗ Inventory error: {str(e)}")
|
|
1254
|
+
|
|
1255
|
+
# CloudTrail analysis via backend API
|
|
1256
|
+
if not no_cloudtrail:
|
|
1257
|
+
try:
|
|
1258
|
+
start_date = (datetime.now() - timedelta(days=weeks * 7)).isoformat() + 'Z'
|
|
1259
|
+
end_date = datetime.now().isoformat() + 'Z'
|
|
1260
|
+
|
|
1261
|
+
if is_api_configured():
|
|
1262
|
+
ct_analysis = call_lambda_api(
|
|
1263
|
+
'forensics',
|
|
1264
|
+
account_creds,
|
|
1265
|
+
[],
|
|
1266
|
+
operation='cloudtrail',
|
|
1267
|
+
account_id=acc_id,
|
|
1268
|
+
start_date=start_date,
|
|
1269
|
+
end_date=end_date,
|
|
1270
|
+
region='us-west-2'
|
|
1271
|
+
)
|
|
1272
|
+
else:
|
|
1273
|
+
# Fallback to local execution
|
|
1274
|
+
from cost_calculator.forensics import analyze_cloudtrail
|
|
1275
|
+
acc_profile = find_account_profile(acc_id)
|
|
1276
|
+
if not acc_profile:
|
|
1277
|
+
raise Exception("No SSO profile found and API not configured")
|
|
1278
|
+
start_dt = datetime.now() - timedelta(days=weeks * 7)
|
|
1279
|
+
end_dt = datetime.now()
|
|
1280
|
+
ct_analysis = analyze_cloudtrail(acc_id, acc_profile, start_dt, end_dt)
|
|
1281
|
+
|
|
1282
|
+
cloudtrail_analyses.append(ct_analysis)
|
|
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)}")
|
|
1292
|
+
|
|
1293
|
+
# Generate report
|
|
1294
|
+
click.echo(f"\nGenerating report...")
|
|
1295
|
+
report = format_investigation_report(trends_data, inventories, cloudtrail_analyses if not no_cloudtrail else None)
|
|
1296
|
+
|
|
1297
|
+
# Write to file
|
|
1298
|
+
with open(output, 'w') as f:
|
|
1299
|
+
f.write(report)
|
|
1300
|
+
|
|
1301
|
+
click.echo(f"\n✓ Investigation complete!")
|
|
1302
|
+
click.echo(f"✓ Report saved to: {output}")
|
|
1303
|
+
click.echo("")
|
|
1304
|
+
|
|
1305
|
+
|
|
1306
|
+
def find_account_profile(account_id):
|
|
1307
|
+
"""
|
|
1308
|
+
Find the SSO profile name for a given account ID
|
|
1309
|
+
Returns profile name or None
|
|
1310
|
+
"""
|
|
1311
|
+
import subprocess
|
|
1312
|
+
|
|
1313
|
+
try:
|
|
1314
|
+
# Get list of profiles
|
|
1315
|
+
result = subprocess.run(
|
|
1316
|
+
['aws', 'configure', 'list-profiles'],
|
|
1317
|
+
capture_output=True,
|
|
1318
|
+
text=True
|
|
1319
|
+
)
|
|
1320
|
+
|
|
1321
|
+
profiles = result.stdout.strip().split('\n')
|
|
1322
|
+
|
|
1323
|
+
# Check each profile
|
|
1324
|
+
for profile in profiles:
|
|
1325
|
+
try:
|
|
1326
|
+
result = subprocess.run(
|
|
1327
|
+
['aws', 'sts', 'get-caller-identity', '--profile', profile],
|
|
1328
|
+
capture_output=True,
|
|
1329
|
+
text=True,
|
|
1330
|
+
timeout=5
|
|
1331
|
+
)
|
|
1332
|
+
|
|
1333
|
+
if account_id in result.stdout:
|
|
1334
|
+
return profile
|
|
1335
|
+
except:
|
|
1336
|
+
continue
|
|
1337
|
+
|
|
1338
|
+
return None
|
|
1339
|
+
except:
|
|
1340
|
+
return None
|
|
1341
|
+
|
|
1342
|
+
|
|
1138
1343
|
if __name__ == '__main__':
|
|
1139
1344
|
cli()
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
"""
|
|
2
|
+
Cost forensics module - Resource inventory and CloudTrail analysis
|
|
3
|
+
"""
|
|
4
|
+
import boto3
|
|
5
|
+
from datetime import datetime, timedelta
|
|
6
|
+
from collections import defaultdict
|
|
7
|
+
import json
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
def inventory_resources(account_id, profile, region='us-west-2'):
|
|
11
|
+
"""
|
|
12
|
+
Inventory AWS resources in an account
|
|
13
|
+
|
|
14
|
+
Args:
|
|
15
|
+
account_id: AWS account ID
|
|
16
|
+
profile: AWS profile name (SSO)
|
|
17
|
+
region: AWS region
|
|
18
|
+
|
|
19
|
+
Returns:
|
|
20
|
+
dict with resource inventory
|
|
21
|
+
"""
|
|
22
|
+
session = boto3.Session(profile_name=profile)
|
|
23
|
+
inventory = {
|
|
24
|
+
'account_id': account_id,
|
|
25
|
+
'profile': profile,
|
|
26
|
+
'region': region,
|
|
27
|
+
'timestamp': datetime.utcnow().isoformat(),
|
|
28
|
+
'ec2_instances': [],
|
|
29
|
+
'efs_file_systems': [],
|
|
30
|
+
'load_balancers': [],
|
|
31
|
+
'dynamodb_tables': []
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
try:
|
|
35
|
+
# EC2 Instances
|
|
36
|
+
ec2_client = session.client('ec2', region_name=region)
|
|
37
|
+
instances_response = ec2_client.describe_instances()
|
|
38
|
+
|
|
39
|
+
for reservation in instances_response['Reservations']:
|
|
40
|
+
for instance in reservation['Instances']:
|
|
41
|
+
if instance['State']['Name'] == 'running':
|
|
42
|
+
name = 'N/A'
|
|
43
|
+
for tag in instance.get('Tags', []):
|
|
44
|
+
if tag['Key'] == 'Name':
|
|
45
|
+
name = tag['Value']
|
|
46
|
+
break
|
|
47
|
+
|
|
48
|
+
inventory['ec2_instances'].append({
|
|
49
|
+
'instance_id': instance['InstanceId'],
|
|
50
|
+
'instance_type': instance['InstanceType'],
|
|
51
|
+
'name': name,
|
|
52
|
+
'state': instance['State']['Name'],
|
|
53
|
+
'launch_time': instance['LaunchTime'].isoformat(),
|
|
54
|
+
'availability_zone': instance['Placement']['AvailabilityZone']
|
|
55
|
+
})
|
|
56
|
+
|
|
57
|
+
# EFS File Systems
|
|
58
|
+
efs_client = session.client('efs', region_name=region)
|
|
59
|
+
efs_response = efs_client.describe_file_systems()
|
|
60
|
+
|
|
61
|
+
total_efs_size = 0
|
|
62
|
+
for fs in efs_response['FileSystems']:
|
|
63
|
+
size_bytes = fs['SizeInBytes']['Value']
|
|
64
|
+
size_gb = size_bytes / (1024**3)
|
|
65
|
+
total_efs_size += size_gb
|
|
66
|
+
|
|
67
|
+
inventory['efs_file_systems'].append({
|
|
68
|
+
'file_system_id': fs['FileSystemId'],
|
|
69
|
+
'name': fs.get('Name', 'N/A'),
|
|
70
|
+
'size_gb': round(size_gb, 2),
|
|
71
|
+
'creation_time': fs['CreationTime'].isoformat(),
|
|
72
|
+
'number_of_mount_targets': fs['NumberOfMountTargets']
|
|
73
|
+
})
|
|
74
|
+
|
|
75
|
+
inventory['total_efs_size_gb'] = round(total_efs_size, 2)
|
|
76
|
+
|
|
77
|
+
# Load Balancers
|
|
78
|
+
elbv2_client = session.client('elbv2', region_name=region)
|
|
79
|
+
elb_response = elbv2_client.describe_load_balancers()
|
|
80
|
+
|
|
81
|
+
for lb in elb_response['LoadBalancers']:
|
|
82
|
+
inventory['load_balancers'].append({
|
|
83
|
+
'name': lb['LoadBalancerName'],
|
|
84
|
+
'type': lb['Type'],
|
|
85
|
+
'dns_name': lb['DNSName'],
|
|
86
|
+
'scheme': lb['Scheme'],
|
|
87
|
+
'created_time': lb['CreatedTime'].isoformat(),
|
|
88
|
+
'availability_zones': [az['ZoneName'] for az in lb['AvailabilityZones']]
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
# DynamoDB Tables (only if region supports it)
|
|
92
|
+
try:
|
|
93
|
+
ddb_client = session.client('dynamodb', region_name=region)
|
|
94
|
+
tables_response = ddb_client.list_tables()
|
|
95
|
+
|
|
96
|
+
for table_name in tables_response['TableNames'][:20]: # Limit to 20 tables
|
|
97
|
+
table_desc = ddb_client.describe_table(TableName=table_name)
|
|
98
|
+
table_info = table_desc['Table']
|
|
99
|
+
|
|
100
|
+
# Get backup settings
|
|
101
|
+
try:
|
|
102
|
+
backup_desc = ddb_client.describe_continuous_backups(TableName=table_name)
|
|
103
|
+
pitr_status = backup_desc['ContinuousBackupsDescription']['PointInTimeRecoveryDescription']['PointInTimeRecoveryStatus']
|
|
104
|
+
except:
|
|
105
|
+
pitr_status = 'UNKNOWN'
|
|
106
|
+
|
|
107
|
+
size_gb = table_info.get('TableSizeBytes', 0) / (1024**3)
|
|
108
|
+
|
|
109
|
+
inventory['dynamodb_tables'].append({
|
|
110
|
+
'table_name': table_name,
|
|
111
|
+
'size_gb': round(size_gb, 2),
|
|
112
|
+
'item_count': table_info.get('ItemCount', 0),
|
|
113
|
+
'pitr_status': pitr_status,
|
|
114
|
+
'created_time': table_info['CreationDateTime'].isoformat()
|
|
115
|
+
})
|
|
116
|
+
except Exception as e:
|
|
117
|
+
# DynamoDB might not be available in all regions
|
|
118
|
+
pass
|
|
119
|
+
|
|
120
|
+
except Exception as e:
|
|
121
|
+
inventory['error'] = str(e)
|
|
122
|
+
|
|
123
|
+
return inventory
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
def analyze_cloudtrail(account_id, profile, start_date, end_date, region='us-west-2'):
|
|
127
|
+
"""
|
|
128
|
+
Analyze CloudTrail events for an account
|
|
129
|
+
|
|
130
|
+
Args:
|
|
131
|
+
account_id: AWS account ID
|
|
132
|
+
profile: AWS profile name (SSO)
|
|
133
|
+
start_date: Start datetime
|
|
134
|
+
end_date: End datetime
|
|
135
|
+
region: AWS region
|
|
136
|
+
|
|
137
|
+
Returns:
|
|
138
|
+
dict with CloudTrail event summary
|
|
139
|
+
"""
|
|
140
|
+
session = boto3.Session(profile_name=profile)
|
|
141
|
+
ct_client = session.client('cloudtrail', region_name=region)
|
|
142
|
+
|
|
143
|
+
analysis = {
|
|
144
|
+
'account_id': account_id,
|
|
145
|
+
'profile': profile,
|
|
146
|
+
'region': region,
|
|
147
|
+
'start_date': start_date.isoformat(),
|
|
148
|
+
'end_date': end_date.isoformat(),
|
|
149
|
+
'event_summary': {},
|
|
150
|
+
'write_events': [],
|
|
151
|
+
'error': None
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
# Events that indicate resource creation/modification
|
|
155
|
+
write_event_names = [
|
|
156
|
+
'RunInstances', 'CreateVolume', 'AttachVolume',
|
|
157
|
+
'CreateFileSystem', 'ModifyFileSystem',
|
|
158
|
+
'CreateLoadBalancer', 'ModifyLoadBalancerAttributes',
|
|
159
|
+
'CreateTable', 'UpdateTable', 'UpdateContinuousBackups',
|
|
160
|
+
'CreateBackupVault', 'StartBackupJob'
|
|
161
|
+
]
|
|
162
|
+
|
|
163
|
+
try:
|
|
164
|
+
event_counts = defaultdict(int)
|
|
165
|
+
|
|
166
|
+
# Query CloudTrail
|
|
167
|
+
paginator = ct_client.get_paginator('lookup_events')
|
|
168
|
+
|
|
169
|
+
for page in paginator.paginate(
|
|
170
|
+
StartTime=start_date,
|
|
171
|
+
EndTime=end_date,
|
|
172
|
+
MaxResults=50,
|
|
173
|
+
PaginationConfig={'MaxItems': 200}
|
|
174
|
+
):
|
|
175
|
+
for event in page.get('Events', []):
|
|
176
|
+
event_name = event.get('EventName', '')
|
|
177
|
+
event_counts[event_name] += 1
|
|
178
|
+
|
|
179
|
+
# Capture write events
|
|
180
|
+
if event_name in write_event_names:
|
|
181
|
+
event_detail = json.loads(event['CloudTrailEvent'])
|
|
182
|
+
|
|
183
|
+
analysis['write_events'].append({
|
|
184
|
+
'time': event.get('EventTime').isoformat(),
|
|
185
|
+
'event_name': event_name,
|
|
186
|
+
'username': event.get('Username', 'N/A'),
|
|
187
|
+
'resources': [
|
|
188
|
+
{
|
|
189
|
+
'type': r.get('ResourceType', 'N/A'),
|
|
190
|
+
'name': r.get('ResourceName', 'N/A')
|
|
191
|
+
}
|
|
192
|
+
for r in event.get('Resources', [])[:3]
|
|
193
|
+
]
|
|
194
|
+
})
|
|
195
|
+
|
|
196
|
+
# Convert to regular dict and sort
|
|
197
|
+
analysis['event_summary'] = dict(sorted(
|
|
198
|
+
event_counts.items(),
|
|
199
|
+
key=lambda x: x[1],
|
|
200
|
+
reverse=True
|
|
201
|
+
))
|
|
202
|
+
|
|
203
|
+
except Exception as e:
|
|
204
|
+
analysis['error'] = str(e)
|
|
205
|
+
|
|
206
|
+
return analysis
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def format_investigation_report(cost_data, inventories, cloudtrail_data=None):
|
|
210
|
+
"""
|
|
211
|
+
Format investigation data into markdown report
|
|
212
|
+
|
|
213
|
+
Args:
|
|
214
|
+
cost_data: Cost analysis results from trends/drill
|
|
215
|
+
inventories: List of resource inventories
|
|
216
|
+
cloudtrail_data: List of CloudTrail analyses (optional)
|
|
217
|
+
|
|
218
|
+
Returns:
|
|
219
|
+
str: Markdown formatted report
|
|
220
|
+
"""
|
|
221
|
+
report = []
|
|
222
|
+
report.append("# Cost Investigation Report")
|
|
223
|
+
report.append(f"**Generated:** {datetime.utcnow().strftime('%Y-%m-%d %H:%M:%S UTC')}")
|
|
224
|
+
report.append("")
|
|
225
|
+
|
|
226
|
+
# Cost Analysis Section
|
|
227
|
+
if cost_data:
|
|
228
|
+
report.append("## Cost Analysis")
|
|
229
|
+
report.append("")
|
|
230
|
+
# Add cost data formatting here
|
|
231
|
+
# This will be populated from trends/drill results
|
|
232
|
+
|
|
233
|
+
# Resource Inventory Section
|
|
234
|
+
if inventories:
|
|
235
|
+
report.append("## Resource Inventory")
|
|
236
|
+
report.append("")
|
|
237
|
+
|
|
238
|
+
for inv in inventories:
|
|
239
|
+
profile_name = inv.get('profile', inv['account_id'])
|
|
240
|
+
report.append(f"### Account {inv['account_id']} ({profile_name})")
|
|
241
|
+
report.append(f"**Region:** {inv['region']}")
|
|
242
|
+
report.append("")
|
|
243
|
+
|
|
244
|
+
# EC2 Instances
|
|
245
|
+
if inv['ec2_instances']:
|
|
246
|
+
report.append(f"**EC2 Instances:** {len(inv['ec2_instances'])} running")
|
|
247
|
+
for instance in inv['ec2_instances'][:10]: # Show first 10
|
|
248
|
+
report.append(f"- `{instance['instance_id']}`: {instance['instance_type']} ({instance['name']})")
|
|
249
|
+
report.append(f" - Launched: {instance['launch_time'][:10]}, AZ: {instance['availability_zone']}")
|
|
250
|
+
if len(inv['ec2_instances']) > 10:
|
|
251
|
+
report.append(f" ... and {len(inv['ec2_instances']) - 10} more")
|
|
252
|
+
report.append("")
|
|
253
|
+
|
|
254
|
+
# EFS File Systems
|
|
255
|
+
if inv['efs_file_systems']:
|
|
256
|
+
total_size = inv.get('total_efs_size_gb', 0)
|
|
257
|
+
report.append(f"**EFS File Systems:** {len(inv['efs_file_systems'])} total, {total_size:,.0f} GB")
|
|
258
|
+
for fs in inv['efs_file_systems']:
|
|
259
|
+
report.append(f"- `{fs['file_system_id']}` ({fs['name']}): {fs['size_gb']:,.2f} GB")
|
|
260
|
+
report.append(f" - Created: {fs['creation_time'][:10]}")
|
|
261
|
+
report.append("")
|
|
262
|
+
|
|
263
|
+
# Load Balancers
|
|
264
|
+
if inv['load_balancers']:
|
|
265
|
+
report.append(f"**Load Balancers:** {len(inv['load_balancers'])}")
|
|
266
|
+
for lb in inv['load_balancers'][:10]: # Show first 10
|
|
267
|
+
report.append(f"- `{lb['name']}`: {lb['type']}")
|
|
268
|
+
report.append(f" - Created: {lb['created_time'][:10]}, Scheme: {lb['scheme']}")
|
|
269
|
+
if len(inv['load_balancers']) > 10:
|
|
270
|
+
report.append(f" ... and {len(inv['load_balancers']) - 10} more")
|
|
271
|
+
report.append("")
|
|
272
|
+
|
|
273
|
+
# DynamoDB Tables
|
|
274
|
+
if inv['dynamodb_tables']:
|
|
275
|
+
report.append(f"**DynamoDB Tables:** {len(inv['dynamodb_tables'])}")
|
|
276
|
+
for table in inv['dynamodb_tables'][:10]:
|
|
277
|
+
report.append(f"- `{table['table_name']}`: {table['size_gb']:.2f} GB, {table['item_count']:,} items")
|
|
278
|
+
report.append(f" - PITR: {table['pitr_status']}, Created: {table['created_time'][:10]}")
|
|
279
|
+
if len(inv['dynamodb_tables']) > 10:
|
|
280
|
+
report.append(f" ... and {len(inv['dynamodb_tables']) - 10} more")
|
|
281
|
+
report.append("")
|
|
282
|
+
|
|
283
|
+
report.append("---")
|
|
284
|
+
report.append("")
|
|
285
|
+
|
|
286
|
+
# CloudTrail Section
|
|
287
|
+
if cloudtrail_data:
|
|
288
|
+
report.append("## CloudTrail Events")
|
|
289
|
+
report.append("")
|
|
290
|
+
|
|
291
|
+
for ct in cloudtrail_data:
|
|
292
|
+
report.append(f"### Account {ct['account_id']} ({ct['profile']})")
|
|
293
|
+
report.append(f"**Period:** {ct['start_date'][:10]} to {ct['end_date'][:10]}")
|
|
294
|
+
report.append("")
|
|
295
|
+
|
|
296
|
+
if ct.get('error'):
|
|
297
|
+
report.append(f"⚠️ Error: {ct['error']}")
|
|
298
|
+
report.append("")
|
|
299
|
+
continue
|
|
300
|
+
|
|
301
|
+
# Write events (resource changes)
|
|
302
|
+
if ct['write_events']:
|
|
303
|
+
report.append(f"**Resource Changes:** {len(ct['write_events'])} events")
|
|
304
|
+
for evt in ct['write_events'][:10]:
|
|
305
|
+
report.append(f"- `{evt['time'][:19]}` - **{evt['event_name']}**")
|
|
306
|
+
report.append(f" - User: {evt['username']}")
|
|
307
|
+
if evt['resources']:
|
|
308
|
+
for res in evt['resources']:
|
|
309
|
+
report.append(f" - Resource: {res['type']} - {res['name']}")
|
|
310
|
+
report.append("")
|
|
311
|
+
|
|
312
|
+
# Event summary
|
|
313
|
+
if ct['event_summary']:
|
|
314
|
+
report.append("**Top Events:**")
|
|
315
|
+
for event_name, count in list(ct['event_summary'].items())[:15]:
|
|
316
|
+
report.append(f"- {event_name}: {count}")
|
|
317
|
+
report.append("")
|
|
318
|
+
|
|
319
|
+
report.append("---")
|
|
320
|
+
report.append("")
|
|
321
|
+
|
|
322
|
+
return "\n".join(report)
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# Backend package
|
|
@@ -1 +0,0 @@
|
|
|
1
|
-
# Algorithms package
|