runbooks 0.9.8__py3-none-any.whl → 0.9.9__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.
- runbooks/__init__.py +1 -1
- runbooks/common/rich_utils.py +3 -0
- runbooks/finops/markdown_exporter.py +226 -0
- runbooks/finops/optimizer.py +2 -0
- runbooks/finops/single_dashboard.py +2 -2
- runbooks/finops/vpc_cleanup_exporter.py +328 -0
- runbooks/finops/vpc_cleanup_optimizer.py +536 -35
- runbooks/main.py +315 -7
- runbooks/operate/vpc_operations.py +1 -1
- runbooks/vpc/unified_scenarios.py +3199 -0
- runbooks/vpc/vpc_cleanup_integration.py +4 -4
- {runbooks-0.9.8.dist-info → runbooks-0.9.9.dist-info}/METADATA +1 -1
- {runbooks-0.9.8.dist-info → runbooks-0.9.9.dist-info}/RECORD +17 -15
- {runbooks-0.9.8.dist-info → runbooks-0.9.9.dist-info}/WHEEL +0 -0
- {runbooks-0.9.8.dist-info → runbooks-0.9.9.dist-info}/entry_points.txt +0 -0
- {runbooks-0.9.8.dist-info → runbooks-0.9.9.dist-info}/licenses/LICENSE +0 -0
- {runbooks-0.9.8.dist-info → runbooks-0.9.9.dist-info}/top_level.txt +0 -0
@@ -49,7 +49,7 @@ from ..common.rich_utils import (
|
|
49
49
|
)
|
50
50
|
from .embedded_mcp_validator import EmbeddedMCPValidator
|
51
51
|
from ..common.profile_utils import get_profile_for_operation
|
52
|
-
from ..
|
52
|
+
from ..security.enterprise_security_framework import EnterpriseSecurityFramework
|
53
53
|
|
54
54
|
logger = logging.getLogger(__name__)
|
55
55
|
|
@@ -87,6 +87,28 @@ class VPCCleanupCandidate(BaseModel):
|
|
87
87
|
risk_assessment: str = "medium" # low, medium, high
|
88
88
|
business_impact: str = "minimal" # minimal, moderate, significant
|
89
89
|
tags: Dict[str, str] = Field(default_factory=dict)
|
90
|
+
|
91
|
+
# Enhanced fields for advanced filtering
|
92
|
+
account_id: Optional[str] = None
|
93
|
+
flow_logs_enabled: bool = False
|
94
|
+
load_balancers: List[str] = Field(default_factory=list)
|
95
|
+
iac_detected: bool = False
|
96
|
+
owners_approvals: List[str] = Field(default_factory=list)
|
97
|
+
|
98
|
+
@property
|
99
|
+
def is_no_eni_vpc(self) -> bool:
|
100
|
+
"""Check if VPC has zero ENI attachments (safe for cleanup)."""
|
101
|
+
return self.dependency_analysis.eni_count == 0
|
102
|
+
|
103
|
+
@property
|
104
|
+
def is_nil_vpc(self) -> bool:
|
105
|
+
"""Check if VPC has no resources (empty VPC)."""
|
106
|
+
return (
|
107
|
+
self.dependency_analysis.eni_count == 0 and
|
108
|
+
len(self.dependency_analysis.route_tables) <= 1 and # Only default route table
|
109
|
+
len(self.dependency_analysis.nat_gateways) == 0 and
|
110
|
+
len(self.dependency_analysis.vpc_endpoints) == 0
|
111
|
+
)
|
90
112
|
|
91
113
|
|
92
114
|
class VPCCleanupResults(BaseModel):
|
@@ -102,6 +124,7 @@ class VPCCleanupResults(BaseModel):
|
|
102
124
|
evidence_hash: Optional[str] = None
|
103
125
|
safety_assessment: str = "graduated_risk_approach"
|
104
126
|
security_assessment: Optional[Dict[str, Any]] = None
|
127
|
+
multi_account_context: Optional[Dict[str, Any]] = Field(default_factory=dict)
|
105
128
|
|
106
129
|
|
107
130
|
class VPCCleanupOptimizer:
|
@@ -129,7 +152,55 @@ class VPCCleanupOptimizer:
|
|
129
152
|
|
130
153
|
print_info(f"VPC Cleanup Optimizer initialized with profile: {self.profile}")
|
131
154
|
|
132
|
-
def
|
155
|
+
def filter_vpcs_by_criteria(self, candidates: List[VPCCleanupCandidate],
|
156
|
+
no_eni_only: bool = False,
|
157
|
+
filter_type: str = "all") -> List[VPCCleanupCandidate]:
|
158
|
+
"""
|
159
|
+
Filter VPC candidates based on specified criteria.
|
160
|
+
|
161
|
+
Args:
|
162
|
+
candidates: List of VPC cleanup candidates
|
163
|
+
no_eni_only: If True, show only VPCs with zero ENI attachments
|
164
|
+
filter_type: Filter type - 'none', 'default', or 'all'
|
165
|
+
|
166
|
+
Returns:
|
167
|
+
Filtered list of VPC candidates
|
168
|
+
"""
|
169
|
+
filtered_candidates = candidates.copy()
|
170
|
+
|
171
|
+
# Apply no-ENI-only filter
|
172
|
+
if no_eni_only:
|
173
|
+
filtered_candidates = [
|
174
|
+
candidate for candidate in filtered_candidates
|
175
|
+
if candidate.is_no_eni_vpc
|
176
|
+
]
|
177
|
+
print_info(f"🔍 No-ENI filter applied - {len(filtered_candidates)} VPCs with zero ENI attachments")
|
178
|
+
|
179
|
+
# Apply type-based filters
|
180
|
+
if filter_type == "none":
|
181
|
+
# Show only VPCs with no resources (nil VPCs)
|
182
|
+
filtered_candidates = [
|
183
|
+
candidate for candidate in filtered_candidates
|
184
|
+
if candidate.is_nil_vpc
|
185
|
+
]
|
186
|
+
print_info(f"📋 'None' filter applied - {len(filtered_candidates)} VPCs with no resources")
|
187
|
+
|
188
|
+
elif filter_type == "default":
|
189
|
+
# Show only default VPCs
|
190
|
+
filtered_candidates = [
|
191
|
+
candidate for candidate in filtered_candidates
|
192
|
+
if candidate.is_default
|
193
|
+
]
|
194
|
+
print_info(f"🏠 'Default' filter applied - {len(filtered_candidates)} default VPCs")
|
195
|
+
|
196
|
+
elif filter_type == "all":
|
197
|
+
# Show all VPCs (no additional filtering)
|
198
|
+
print_info(f"📊 'All' filter applied - {len(filtered_candidates)} total VPCs")
|
199
|
+
|
200
|
+
return filtered_candidates
|
201
|
+
|
202
|
+
def analyze_vpc_cleanup_opportunities(self, no_eni_only: bool = False,
|
203
|
+
filter_type: str = "all") -> VPCCleanupResults:
|
133
204
|
"""
|
134
205
|
Comprehensive VPC cleanup analysis with three-bucket strategy.
|
135
206
|
|
@@ -143,7 +214,7 @@ class VPCCleanupOptimizer:
|
|
143
214
|
|
144
215
|
Returns: Comprehensive cleanup analysis with validated savings
|
145
216
|
"""
|
146
|
-
print_header("VPC Cleanup Cost Optimization Engine", "v0.9.
|
217
|
+
print_header("VPC Cleanup Cost Optimization Engine", "v0.9.9")
|
147
218
|
print_info("AWSO-05 Implementation - Three-Bucket Strategy")
|
148
219
|
|
149
220
|
# Initialize MCP validator for accuracy validation
|
@@ -155,12 +226,22 @@ class VPCCleanupOptimizer:
|
|
155
226
|
# Step 2: Dependency analysis for each VPC
|
156
227
|
analyzed_candidates = self._analyze_vpc_dependencies(vpc_candidates)
|
157
228
|
|
229
|
+
# Step 2.5: Apply filtering based on criteria
|
230
|
+
filtered_candidates = self.filter_vpcs_by_criteria(
|
231
|
+
analyzed_candidates,
|
232
|
+
no_eni_only=no_eni_only,
|
233
|
+
filter_type=filter_type
|
234
|
+
)
|
235
|
+
|
158
236
|
# Step 3: Three-bucket classification
|
159
|
-
bucket_classification = self._classify_three_bucket_strategy(
|
237
|
+
bucket_classification = self._classify_three_bucket_strategy(filtered_candidates)
|
160
238
|
|
161
239
|
# Step 4: Enhanced VPC security assessment integration
|
162
240
|
security_assessment = self._perform_vpc_security_assessment(analyzed_candidates)
|
163
241
|
|
242
|
+
# Step 4.5: Re-classify buckets after security assessment (ensure NO-ENI VPCs stay in Bucket 1)
|
243
|
+
bucket_classification = self._ensure_no_eni_bucket_1_classification(bucket_classification)
|
244
|
+
|
164
245
|
# Step 5: Cost calculation and savings estimation
|
165
246
|
cost_analysis = self._calculate_vpc_cleanup_costs(bucket_classification)
|
166
247
|
|
@@ -175,6 +256,190 @@ class VPCCleanupOptimizer:
|
|
175
256
|
|
176
257
|
return results
|
177
258
|
|
259
|
+
def analyze_vpc_cleanup_opportunities_multi_account(self,
|
260
|
+
account_ids: List[str],
|
261
|
+
accounts_info: Dict[str, Any],
|
262
|
+
no_eni_only: bool = False,
|
263
|
+
filter_type: str = "all",
|
264
|
+
progress_callback=None) -> 'VPCCleanupResults':
|
265
|
+
"""
|
266
|
+
ENHANCED: Multi-account VPC cleanup analysis with Organizations integration.
|
267
|
+
|
268
|
+
Critical Fix: Instead of assuming cross-account access, this method aggregates
|
269
|
+
VPC discovery from the current profile's accessible scope, which may span
|
270
|
+
multiple accounts if the profile has appropriate permissions.
|
271
|
+
|
272
|
+
Args:
|
273
|
+
account_ids: List of account IDs from Organizations discovery
|
274
|
+
accounts_info: Account metadata from Organizations API
|
275
|
+
no_eni_only: Filter to only VPCs with zero ENI attachments
|
276
|
+
filter_type: Filter criteria ("all", "no-eni", "default", "tagged")
|
277
|
+
progress_callback: Optional callback for progress updates
|
278
|
+
|
279
|
+
Returns: Aggregated VPC cleanup analysis across accessible accounts
|
280
|
+
"""
|
281
|
+
print_header("Multi-Account VPC Cleanup Analysis", "v0.9.9")
|
282
|
+
print_info(f"🏢 Analyzing VPCs across {len(account_ids)} organization accounts")
|
283
|
+
print_info(f"🔐 Using profile: {self.profile} (scope: accessible accounts only)")
|
284
|
+
|
285
|
+
# Initialize analysis timing
|
286
|
+
self.analysis_start_time = time.time()
|
287
|
+
|
288
|
+
# Initialize MCP validator for accuracy validation
|
289
|
+
self.mcp_validator = EmbeddedMCPValidator([self.profile])
|
290
|
+
|
291
|
+
if progress_callback:
|
292
|
+
progress_callback("Initializing multi-account discovery...")
|
293
|
+
|
294
|
+
# Enhanced VPC discovery with organization context
|
295
|
+
vpc_candidates = self._discover_vpc_candidates_multi_account(account_ids, accounts_info, progress_callback)
|
296
|
+
|
297
|
+
if progress_callback:
|
298
|
+
progress_callback("Analyzing VPC dependencies...")
|
299
|
+
|
300
|
+
# Follow standard analysis pipeline
|
301
|
+
analyzed_candidates = self._analyze_vpc_dependencies(vpc_candidates)
|
302
|
+
filtered_candidates = self.filter_vpcs_by_criteria(
|
303
|
+
analyzed_candidates,
|
304
|
+
no_eni_only=no_eni_only,
|
305
|
+
filter_type=filter_type
|
306
|
+
)
|
307
|
+
|
308
|
+
if progress_callback:
|
309
|
+
progress_callback("Performing three-bucket classification...")
|
310
|
+
|
311
|
+
bucket_classification = self._classify_three_bucket_strategy(filtered_candidates)
|
312
|
+
security_assessment = self._perform_vpc_security_assessment(analyzed_candidates)
|
313
|
+
bucket_classification = self._ensure_no_eni_bucket_1_classification(bucket_classification)
|
314
|
+
|
315
|
+
if progress_callback:
|
316
|
+
progress_callback("Calculating cost analysis...")
|
317
|
+
|
318
|
+
cost_analysis = self._calculate_vpc_cleanup_costs(bucket_classification)
|
319
|
+
validation_results = self._validate_analysis_with_mcp(cost_analysis)
|
320
|
+
|
321
|
+
if progress_callback:
|
322
|
+
progress_callback("Generating comprehensive results...")
|
323
|
+
|
324
|
+
# Generate results with multi-account context
|
325
|
+
results = self._generate_comprehensive_results(cost_analysis, validation_results, security_assessment)
|
326
|
+
|
327
|
+
# Add multi-account metadata
|
328
|
+
results.multi_account_context = {
|
329
|
+
'total_accounts_analyzed': len(account_ids),
|
330
|
+
'accounts_with_vpcs': len(set(candidate.account_id for candidate in vpc_candidates if candidate.account_id)),
|
331
|
+
'organization_id': accounts_info.get('organization_id', 'unknown'),
|
332
|
+
'accounts': [
|
333
|
+
{
|
334
|
+
'account_id': account_id,
|
335
|
+
'account_name': accounts_info.get('accounts', {}).get(account_id, {}).get('Name', 'unknown'),
|
336
|
+
'status': accounts_info.get('accounts', {}).get(account_id, {}).get('Status', 'unknown')
|
337
|
+
}
|
338
|
+
for account_id in account_ids
|
339
|
+
],
|
340
|
+
'vpc_count_by_account': self._calculate_vpc_count_by_account(vpc_candidates),
|
341
|
+
'analysis_scope': 'organization',
|
342
|
+
'profile_access_scope': self.profile
|
343
|
+
}
|
344
|
+
|
345
|
+
print_success(f"✅ Multi-account analysis complete: {results.total_vpcs_analyzed} VPCs across organization")
|
346
|
+
return results
|
347
|
+
|
348
|
+
def _calculate_vpc_count_by_account(self, vpc_candidates: List[VPCCleanupCandidate]) -> Dict[str, int]:
|
349
|
+
"""Calculate VPC count by account from the candidate list."""
|
350
|
+
vpc_count_by_account = {}
|
351
|
+
|
352
|
+
for candidate in vpc_candidates:
|
353
|
+
account_id = candidate.account_id or 'unknown'
|
354
|
+
if account_id in vpc_count_by_account:
|
355
|
+
vpc_count_by_account[account_id] += 1
|
356
|
+
else:
|
357
|
+
vpc_count_by_account[account_id] = 1
|
358
|
+
|
359
|
+
return vpc_count_by_account
|
360
|
+
|
361
|
+
def _discover_vpc_candidates_multi_account(self, account_ids: List[str],
|
362
|
+
accounts_info: Dict[str, Any],
|
363
|
+
progress_callback=None) -> List[VPCCleanupCandidate]:
|
364
|
+
"""
|
365
|
+
Enhanced VPC discovery with organization account context.
|
366
|
+
|
367
|
+
CRITICAL INSIGHT: This method discovers VPCs that the current profile can access,
|
368
|
+
which may include VPCs from multiple accounts if the profile has cross-account permissions
|
369
|
+
(e.g., via AWS SSO or cross-account roles).
|
370
|
+
"""
|
371
|
+
vpc_candidates = []
|
372
|
+
|
373
|
+
if progress_callback:
|
374
|
+
progress_callback("Discovering VPCs across regions...")
|
375
|
+
|
376
|
+
# Get list of all regions
|
377
|
+
ec2_client = self.session.client('ec2', region_name='us-east-1')
|
378
|
+
regions = [region['RegionName'] for region in ec2_client.describe_regions()['Regions']]
|
379
|
+
|
380
|
+
print_info(f"🌍 Scanning {len(regions)} AWS regions for accessible VPCs...")
|
381
|
+
|
382
|
+
regions_with_vpcs = 0
|
383
|
+
for region in regions:
|
384
|
+
try:
|
385
|
+
regional_ec2 = self.session.client('ec2', region_name=region)
|
386
|
+
response = regional_ec2.describe_vpcs()
|
387
|
+
|
388
|
+
region_vpc_count = 0
|
389
|
+
for vpc in response['Vpcs']:
|
390
|
+
# Enhanced VPC candidate with account detection
|
391
|
+
candidate = VPCCleanupCandidate(
|
392
|
+
vpc_id=vpc['VpcId'],
|
393
|
+
region=region,
|
394
|
+
state=vpc['State'],
|
395
|
+
cidr_block=vpc['CidrBlock'],
|
396
|
+
is_default=vpc.get('IsDefault', False),
|
397
|
+
account_id=self._detect_vpc_account_id(vpc), # Enhanced account detection
|
398
|
+
dependency_analysis=VPCDependencyAnalysis(
|
399
|
+
vpc_id=vpc['VpcId'],
|
400
|
+
region=region,
|
401
|
+
is_default_vpc=vpc.get('IsDefault', False)
|
402
|
+
),
|
403
|
+
tags={tag['Key']: tag['Value'] for tag in vpc.get('Tags', [])}
|
404
|
+
)
|
405
|
+
vpc_candidates.append(candidate)
|
406
|
+
region_vpc_count += 1
|
407
|
+
|
408
|
+
if region_vpc_count > 0:
|
409
|
+
regions_with_vpcs += 1
|
410
|
+
|
411
|
+
except ClientError as e:
|
412
|
+
if "UnauthorizedOperation" in str(e):
|
413
|
+
print_warning(f"No access to region {region} (expected for cross-account)")
|
414
|
+
else:
|
415
|
+
print_warning(f"Could not access region {region}: {e}")
|
416
|
+
|
417
|
+
print_success(f"✅ Discovered {len(vpc_candidates)} VPCs across {regions_with_vpcs} accessible regions")
|
418
|
+
print_info(f"📊 Organization context: {len(account_ids)} accounts in scope")
|
419
|
+
|
420
|
+
return vpc_candidates
|
421
|
+
|
422
|
+
def _detect_vpc_account_id(self, vpc_data: Dict[str, Any]) -> Optional[str]:
|
423
|
+
"""
|
424
|
+
Detect account ID for VPC (enhanced for multi-account context).
|
425
|
+
|
426
|
+
In multi-account scenarios, VPC ARN or tags may contain account information.
|
427
|
+
"""
|
428
|
+
# Try to extract account ID from VPC ARN if available
|
429
|
+
if 'VpcArn' in vpc_data:
|
430
|
+
# VPC ARN format: arn:aws:ec2:region:account-id:vpc/vpc-id
|
431
|
+
arn_parts = vpc_data['VpcArn'].split(':')
|
432
|
+
if len(arn_parts) >= 5:
|
433
|
+
return arn_parts[4]
|
434
|
+
|
435
|
+
# Fallback: Try to get from current session context
|
436
|
+
try:
|
437
|
+
sts_client = self.session.client('sts')
|
438
|
+
response = sts_client.get_caller_identity()
|
439
|
+
return response.get('Account')
|
440
|
+
except Exception:
|
441
|
+
return None
|
442
|
+
|
178
443
|
def _discover_vpc_candidates(self) -> List[VPCCleanupCandidate]:
|
179
444
|
"""Discover VPC candidates across all AWS regions."""
|
180
445
|
vpc_candidates = []
|
@@ -295,6 +560,9 @@ class VPCCleanupOptimizer:
|
|
295
560
|
candidate.dependency_analysis
|
296
561
|
)
|
297
562
|
|
563
|
+
# Enhanced data collection for new fields
|
564
|
+
self._collect_enhanced_vpc_data(candidate, ec2_client)
|
565
|
+
|
298
566
|
analyzed_candidates.append(candidate)
|
299
567
|
|
300
568
|
except ClientError as e:
|
@@ -307,20 +575,140 @@ class VPCCleanupOptimizer:
|
|
307
575
|
return analyzed_candidates
|
308
576
|
|
309
577
|
def _calculate_dependency_risk(self, dependency_analysis: VPCDependencyAnalysis) -> str:
|
310
|
-
"""
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
315
|
-
|
316
|
-
|
317
|
-
|
318
|
-
|
578
|
+
"""
|
579
|
+
Calculate dependency risk level based on VPC resource analysis.
|
580
|
+
|
581
|
+
CRITICAL FIX: Prioritize ENI count = 0 for safe Bucket 1 classification.
|
582
|
+
NO-ENI VPCs are inherently safe regardless of other infrastructure present.
|
583
|
+
"""
|
584
|
+
# PRIORITY 1: NO-ENI VPCs are inherently low risk (safe for immediate deletion)
|
585
|
+
# This overrides all other factors - if no ENI attachments, no active workloads depend on it
|
586
|
+
if dependency_analysis.eni_count == 0:
|
587
|
+
return "low" # Safe for Bucket 1 - Ready for deletion
|
588
|
+
|
589
|
+
# PRIORITY 2: Default VPCs always require careful handling
|
319
590
|
if dependency_analysis.is_default_vpc:
|
320
|
-
return "high"
|
591
|
+
return "high" # Bucket 3 - Manual review required
|
592
|
+
|
593
|
+
# PRIORITY 3: VPCs with ENI attachments require dependency analysis
|
594
|
+
# These have active workloads and need investigation
|
595
|
+
return "medium" # Bucket 2 - Requires analysis
|
596
|
+
|
597
|
+
def _collect_enhanced_vpc_data(self, candidate: VPCCleanupCandidate, ec2_client) -> None:
|
598
|
+
"""Collect enhanced VPC data for new fields."""
|
599
|
+
try:
|
600
|
+
# Get account ID from session
|
601
|
+
sts_client = self.session.client('sts', region_name=candidate.region)
|
602
|
+
account_info = sts_client.get_caller_identity()
|
603
|
+
candidate.account_id = account_info.get('Account')
|
604
|
+
|
605
|
+
# Detect flow logs
|
606
|
+
candidate.flow_logs_enabled = self._detect_flow_logs(candidate.vpc_id, ec2_client)
|
607
|
+
|
608
|
+
# Detect load balancers
|
609
|
+
candidate.load_balancers = self._detect_load_balancers(candidate.vpc_id, candidate.region)
|
610
|
+
|
611
|
+
# Analyze IaC indicators in tags
|
612
|
+
candidate.iac_detected = self._analyze_iac_tags(candidate.tags)
|
613
|
+
|
614
|
+
# Extract owner information from tags
|
615
|
+
candidate.owners_approvals = self._extract_owners_from_tags(candidate.tags)
|
616
|
+
|
617
|
+
except Exception as e:
|
618
|
+
print_warning(f"Enhanced data collection failed for VPC {candidate.vpc_id}: {e}")
|
619
|
+
|
620
|
+
def _detect_flow_logs(self, vpc_id: str, ec2_client) -> bool:
|
621
|
+
"""Detect if VPC Flow Logs are enabled."""
|
622
|
+
try:
|
623
|
+
response = ec2_client.describe_flow_logs(
|
624
|
+
Filters=[
|
625
|
+
{'Name': 'resource-id', 'Values': [vpc_id]},
|
626
|
+
{'Name': 'resource-type', 'Values': ['VPC']}
|
627
|
+
]
|
628
|
+
)
|
629
|
+
return len(response['FlowLogs']) > 0
|
630
|
+
except Exception as e:
|
631
|
+
print_warning(f"Flow logs detection failed for VPC {vpc_id}: {e}")
|
632
|
+
return False
|
633
|
+
|
634
|
+
def _detect_load_balancers(self, vpc_id: str, region: str) -> List[str]:
|
635
|
+
"""Detect load balancers associated with the VPC."""
|
636
|
+
load_balancers = []
|
637
|
+
|
638
|
+
try:
|
639
|
+
# Check Application/Network Load Balancers (ELBv2)
|
640
|
+
elbv2_client = self.session.client('elbv2', region_name=region)
|
641
|
+
response = elbv2_client.describe_load_balancers()
|
642
|
+
|
643
|
+
for lb in response['LoadBalancers']:
|
644
|
+
if lb.get('VpcId') == vpc_id:
|
645
|
+
load_balancers.append(lb['LoadBalancerArn'])
|
646
|
+
|
647
|
+
except Exception as e:
|
648
|
+
print_warning(f"ELBv2 load balancer detection failed for VPC {vpc_id}: {e}")
|
649
|
+
|
650
|
+
try:
|
651
|
+
# Check Classic Load Balancers (ELB)
|
652
|
+
elb_client = self.session.client('elb', region_name=region)
|
653
|
+
response = elb_client.describe_load_balancers()
|
654
|
+
|
655
|
+
for lb in response['LoadBalancerDescriptions']:
|
656
|
+
if lb.get('VPCId') == vpc_id:
|
657
|
+
load_balancers.append(lb['LoadBalancerName'])
|
658
|
+
|
659
|
+
except Exception as e:
|
660
|
+
print_warning(f"Classic load balancer detection failed for VPC {vpc_id}: {e}")
|
661
|
+
|
662
|
+
return load_balancers
|
663
|
+
|
664
|
+
def _analyze_iac_tags(self, tags: Dict[str, str]) -> bool:
|
665
|
+
"""Analyze tags for Infrastructure as Code indicators."""
|
666
|
+
iac_indicators = [
|
667
|
+
'terraform', 'cloudformation', 'cdk', 'pulumi', 'ansible',
|
668
|
+
'created-by', 'managed-by', 'provisioned-by', 'stack-name',
|
669
|
+
'aws:cloudformation:', 'terraform:', 'cdk:'
|
670
|
+
]
|
671
|
+
|
672
|
+
# Check tag keys and values for IaC indicators
|
673
|
+
all_tag_text = ' '.join([
|
674
|
+
f"{key} {value}" for key, value in tags.items()
|
675
|
+
]).lower()
|
321
676
|
|
322
|
-
|
323
|
-
|
677
|
+
return any(indicator in all_tag_text for indicator in iac_indicators)
|
678
|
+
|
679
|
+
def _extract_owners_from_tags(self, tags: Dict[str, str]) -> List[str]:
|
680
|
+
"""Extract owner/approval information from tags with enhanced patterns."""
|
681
|
+
owners = []
|
682
|
+
|
683
|
+
# Enhanced owner key patterns - Common AWS tagging patterns
|
684
|
+
owner_keys = [
|
685
|
+
'Owner', 'owner', 'OWNER', # Direct owner tags
|
686
|
+
'CreatedBy', 'createdby', 'Created-By', 'created-by', # Creator tags
|
687
|
+
'ManagedBy', 'managedby', 'Managed-By', 'managed-by', # Management tags
|
688
|
+
'Team', 'team', 'TEAM', # Team tags
|
689
|
+
'Contact', 'contact', 'CONTACT', # Contact tags
|
690
|
+
'BusinessOwner', 'business-owner', 'business_owner', # Business owner
|
691
|
+
'TechnicalOwner', 'technical-owner', 'technical_owner', # Technical owner
|
692
|
+
'Approver', 'approver', 'APPROVER' # Approval tags
|
693
|
+
]
|
694
|
+
|
695
|
+
for key in owner_keys:
|
696
|
+
if key in tags and tags[key]:
|
697
|
+
# Split multiple owners if comma-separated
|
698
|
+
owner_values = [owner.strip() for owner in tags[key].split(',')]
|
699
|
+
owners.extend(owner_values)
|
700
|
+
|
701
|
+
# Format owners with role context if identifiable
|
702
|
+
formatted_owners = []
|
703
|
+
for owner in owners:
|
704
|
+
if any(business_key in owner.lower() for business_key in ['business', 'manager', 'finance']):
|
705
|
+
formatted_owners.append(f"{owner} (Business)")
|
706
|
+
elif any(tech_key in owner.lower() for tech_key in ['ops', 'devops', 'engineering', 'tech']):
|
707
|
+
formatted_owners.append(f"{owner} (Technical)")
|
708
|
+
else:
|
709
|
+
formatted_owners.append(owner)
|
710
|
+
|
711
|
+
return list(set(formatted_owners)) # Remove duplicates
|
324
712
|
|
325
713
|
def _classify_three_bucket_strategy(self, candidates: List[VPCCleanupCandidate]) -> Dict[str, List[VPCCleanupCandidate]]:
|
326
714
|
"""Classify VPCs using AWSO-05 three-bucket strategy."""
|
@@ -333,25 +721,24 @@ class VPCCleanupOptimizer:
|
|
333
721
|
for candidate in candidates:
|
334
722
|
dependency = candidate.dependency_analysis
|
335
723
|
|
336
|
-
#
|
337
|
-
|
338
|
-
|
339
|
-
not dependency.is_default_vpc):
|
724
|
+
# PRIORITY 1: ENI count = 0 takes precedence (safety-first approach)
|
725
|
+
# NO-ENI VPCs are inherently safe regardless of default status
|
726
|
+
if dependency.eni_count == 0 and dependency.dependency_risk_level == "low":
|
340
727
|
candidate.cleanup_bucket = "internal"
|
341
728
|
candidate.cleanup_recommendation = "ready"
|
342
729
|
candidate.risk_assessment = "low"
|
343
730
|
candidate.business_impact = "minimal"
|
344
731
|
bucket_1_internal.append(candidate)
|
345
732
|
|
346
|
-
#
|
347
|
-
elif dependency.is_default_vpc:
|
733
|
+
# PRIORITY 2: Default VPCs with ENI attachments need careful handling
|
734
|
+
elif dependency.is_default_vpc and dependency.eni_count > 0:
|
348
735
|
candidate.cleanup_bucket = "control"
|
349
736
|
candidate.cleanup_recommendation = "manual_review"
|
350
737
|
candidate.risk_assessment = "high"
|
351
738
|
candidate.business_impact = "significant"
|
352
739
|
bucket_3_control.append(candidate)
|
353
740
|
|
354
|
-
#
|
741
|
+
# PRIORITY 3: Non-default VPCs with ENI attachments require analysis
|
355
742
|
else:
|
356
743
|
candidate.cleanup_bucket = "external"
|
357
744
|
candidate.cleanup_recommendation = "investigate"
|
@@ -372,13 +759,82 @@ class VPCCleanupOptimizer:
|
|
372
759
|
|
373
760
|
return classification_results
|
374
761
|
|
762
|
+
def _ensure_no_eni_bucket_1_classification(self, bucket_classification: Dict[str, List[VPCCleanupCandidate]]) -> Dict[str, List[VPCCleanupCandidate]]:
|
763
|
+
"""
|
764
|
+
Ensure NO-ENI VPCs remain in Bucket 1 after security assessment.
|
765
|
+
|
766
|
+
CRITICAL FIX: Security assessment may have modified VPC properties, but
|
767
|
+
NO-ENI VPCs (ENI count = 0) should ALWAYS remain in Bucket 1 regardless
|
768
|
+
of default status or security findings. They are inherently safe.
|
769
|
+
"""
|
770
|
+
print_info("🔧 Ensuring NO-ENI VPCs remain in Bucket 1 (safety-first approach)...")
|
771
|
+
|
772
|
+
# Create new bucket structure
|
773
|
+
new_bucket_1 = []
|
774
|
+
new_bucket_2 = []
|
775
|
+
new_bucket_3 = []
|
776
|
+
|
777
|
+
# Collect all VPCs from all buckets
|
778
|
+
all_vpcs = []
|
779
|
+
for bucket_vpcs in bucket_classification.values():
|
780
|
+
all_vpcs.extend(bucket_vpcs)
|
781
|
+
|
782
|
+
# Re-classify with NO-ENI priority
|
783
|
+
for candidate in all_vpcs:
|
784
|
+
# PRIORITY 1: NO-ENI VPCs ALWAYS go to Bucket 1 (overrides all other factors)
|
785
|
+
if candidate.dependency_analysis.eni_count == 0:
|
786
|
+
# Ensure NO-ENI VPCs maintain Bucket 1 properties
|
787
|
+
candidate.cleanup_bucket = "internal"
|
788
|
+
candidate.cleanup_recommendation = "ready"
|
789
|
+
candidate.risk_assessment = "low"
|
790
|
+
candidate.business_impact = "minimal"
|
791
|
+
new_bucket_1.append(candidate)
|
792
|
+
|
793
|
+
# PRIORITY 2: Default VPCs with ENI attachments go to Bucket 3
|
794
|
+
elif candidate.is_default and candidate.dependency_analysis.eni_count > 0:
|
795
|
+
candidate.cleanup_bucket = "control"
|
796
|
+
candidate.cleanup_recommendation = "manual_review"
|
797
|
+
candidate.risk_assessment = "high"
|
798
|
+
candidate.business_impact = "significant"
|
799
|
+
new_bucket_3.append(candidate)
|
800
|
+
|
801
|
+
# PRIORITY 3: All other VPCs go to Bucket 2
|
802
|
+
else:
|
803
|
+
candidate.cleanup_bucket = "external"
|
804
|
+
candidate.cleanup_recommendation = "investigate"
|
805
|
+
candidate.risk_assessment = "medium"
|
806
|
+
candidate.business_impact = "moderate"
|
807
|
+
new_bucket_2.append(candidate)
|
808
|
+
|
809
|
+
corrected_classification = {
|
810
|
+
"bucket_1_internal": new_bucket_1,
|
811
|
+
"bucket_2_external": new_bucket_2,
|
812
|
+
"bucket_3_control": new_bucket_3
|
813
|
+
}
|
814
|
+
|
815
|
+
# Log corrections if any VPCs were moved
|
816
|
+
original_b1_count = len(bucket_classification["bucket_1_internal"])
|
817
|
+
original_b3_count = len(bucket_classification["bucket_3_control"])
|
818
|
+
new_b1_count = len(new_bucket_1)
|
819
|
+
new_b3_count = len(new_bucket_3)
|
820
|
+
|
821
|
+
if original_b1_count != new_b1_count or original_b3_count != new_b3_count:
|
822
|
+
print_warning(f"🔧 Bucket re-classification applied:")
|
823
|
+
print_info(f" • Bucket 1: {original_b1_count} → {new_b1_count} VPCs")
|
824
|
+
print_info(f" • Bucket 3: {original_b3_count} → {new_b3_count} VPCs")
|
825
|
+
print_success("✅ NO-ENI VPCs prioritized for Bucket 1 (safety-first)")
|
826
|
+
else:
|
827
|
+
print_success("✅ NO-ENI VPC classification already correct")
|
828
|
+
|
829
|
+
return corrected_classification
|
830
|
+
|
375
831
|
def _perform_vpc_security_assessment(self, candidates: List[VPCCleanupCandidate]) -> Dict[str, Any]:
|
376
832
|
"""Perform comprehensive VPC security assessment using enterprise security module."""
|
377
833
|
print_info("🔒 Performing comprehensive VPC security assessment...")
|
378
834
|
|
379
835
|
try:
|
380
|
-
# Initialize enterprise security
|
381
|
-
|
836
|
+
# Initialize enterprise security framework
|
837
|
+
security_framework = EnterpriseSecurityFramework(profile=self.profile)
|
382
838
|
|
383
839
|
security_results = {
|
384
840
|
"assessed_vpcs": 0,
|
@@ -401,19 +857,30 @@ class VPCCleanupOptimizer:
|
|
401
857
|
for candidate in candidates:
|
402
858
|
try:
|
403
859
|
# Enhanced security assessment for each VPC
|
404
|
-
vpc_security = self._assess_individual_vpc_security(candidate,
|
860
|
+
vpc_security = self._assess_individual_vpc_security(candidate, security_framework)
|
405
861
|
|
406
862
|
# Classify security risk level
|
863
|
+
# CRITICAL FIX: Don't override NO-ENI VPC classifications (they're inherently safe)
|
864
|
+
# NO-ENI VPCs should remain in Bucket 1 regardless of default status
|
407
865
|
if candidate.is_default or vpc_security["high_risk_findings"] > 2:
|
408
866
|
security_results["security_risks"]["high_risk"].append(candidate.vpc_id)
|
409
|
-
|
410
|
-
|
867
|
+
|
868
|
+
# Only override classification if VPC has ENI attachments
|
869
|
+
# NO-ENI VPCs (ENI count = 0) remain safe for Bucket 1 regardless of default status
|
870
|
+
if candidate.dependency_analysis.eni_count > 0:
|
871
|
+
candidate.risk_assessment = "high"
|
872
|
+
candidate.cleanup_recommendation = "manual_review"
|
873
|
+
# NO-ENI VPCs keep their original Bucket 1 classification
|
411
874
|
elif vpc_security["medium_risk_findings"] > 1:
|
412
875
|
security_results["security_risks"]["medium_risk"].append(candidate.vpc_id)
|
413
|
-
|
876
|
+
# Only override classification if VPC has ENI attachments
|
877
|
+
if candidate.dependency_analysis.eni_count > 0:
|
878
|
+
candidate.risk_assessment = "medium"
|
414
879
|
else:
|
415
880
|
security_results["security_risks"]["low_risk"].append(candidate.vpc_id)
|
416
|
-
|
881
|
+
# Only override classification if VPC has ENI attachments
|
882
|
+
if candidate.dependency_analysis.eni_count > 0:
|
883
|
+
candidate.risk_assessment = "low"
|
417
884
|
|
418
885
|
# Track compliance issues
|
419
886
|
if candidate.is_default:
|
@@ -447,7 +914,7 @@ class VPCCleanupOptimizer:
|
|
447
914
|
print_error(f"Security assessment failed: {e}")
|
448
915
|
return {"error": str(e), "assessed_vpcs": 0}
|
449
916
|
|
450
|
-
def _assess_individual_vpc_security(self, candidate: VPCCleanupCandidate,
|
917
|
+
def _assess_individual_vpc_security(self, candidate: VPCCleanupCandidate, security_framework) -> Dict[str, Any]:
|
451
918
|
"""Assess individual VPC security posture."""
|
452
919
|
security_findings = {
|
453
920
|
"high_risk_findings": 0,
|
@@ -613,7 +1080,16 @@ class VPCCleanupOptimizer:
|
|
613
1080
|
total_annual_savings=cost_analysis["total_annual_savings"],
|
614
1081
|
mcp_validation_accuracy=validation_results["overall_accuracy"],
|
615
1082
|
analysis_timestamp=datetime.now(),
|
616
|
-
security_assessment=security_assessment
|
1083
|
+
security_assessment=security_assessment,
|
1084
|
+
multi_account_context={
|
1085
|
+
'total_accounts_analyzed': 1,
|
1086
|
+
'accounts_with_vpcs': 1 if all_candidates else 0,
|
1087
|
+
'organization_id': 'single_account_analysis',
|
1088
|
+
'accounts': [{'account_id': 'current', 'account_name': 'current', 'status': 'active'}],
|
1089
|
+
'vpc_count_by_account': {'current': len(all_candidates)},
|
1090
|
+
'analysis_scope': 'single_account',
|
1091
|
+
'profile_access_scope': self.profile
|
1092
|
+
}
|
617
1093
|
)
|
618
1094
|
|
619
1095
|
# Generate SHA256 evidence hash for audit compliance
|
@@ -760,6 +1236,9 @@ class VPCCleanupOptimizer:
|
|
760
1236
|
ready_table.add_column("Region", style="blue", width=12)
|
761
1237
|
ready_table.add_column("CIDR Block", style="yellow", width=18)
|
762
1238
|
ready_table.add_column("ENI Count", justify="right", style="green")
|
1239
|
+
ready_table.add_column("Flow Logs", justify="center", style="magenta")
|
1240
|
+
ready_table.add_column("Load Balancers", justify="right", style="red")
|
1241
|
+
ready_table.add_column("IaC", justify="center", style="cyan")
|
763
1242
|
ready_table.add_column("Annual Savings", justify="right", style="green")
|
764
1243
|
|
765
1244
|
for candidate in results.bucket_1_internal[:10]: # Show first 10
|
@@ -768,6 +1247,9 @@ class VPCCleanupOptimizer:
|
|
768
1247
|
candidate.region,
|
769
1248
|
candidate.cidr_block,
|
770
1249
|
str(candidate.dependency_analysis.eni_count),
|
1250
|
+
"✅" if candidate.flow_logs_enabled else "❌",
|
1251
|
+
str(len(candidate.load_balancers)),
|
1252
|
+
"✅" if candidate.iac_detected else "❌",
|
771
1253
|
format_cost(candidate.annual_savings)
|
772
1254
|
)
|
773
1255
|
|
@@ -784,7 +1266,11 @@ class VPCCleanupOptimizer:
|
|
784
1266
|
@click.option('--export', default='json', help='Export format: json, csv, pdf')
|
785
1267
|
@click.option('--evidence-bundle', is_flag=True, help='Generate SHA256 evidence bundle')
|
786
1268
|
@click.option('--dry-run', is_flag=True, default=True, help='Perform analysis only (default: true)')
|
787
|
-
|
1269
|
+
@click.option('--no-eni-only', is_flag=True, help='Show only VPCs with zero ENI attachments')
|
1270
|
+
@click.option('--filter', type=click.Choice(['none', 'default', 'all']), default='all',
|
1271
|
+
help='Filter VPCs: none=no resources, default=default VPCs only, all=show all')
|
1272
|
+
def vpc_cleanup_command(profile: str, export: str, evidence_bundle: bool, dry_run: bool,
|
1273
|
+
no_eni_only: bool, filter: str):
|
788
1274
|
"""
|
789
1275
|
AWSO-05 VPC Cleanup Cost Optimization Engine
|
790
1276
|
|
@@ -798,13 +1284,28 @@ def vpc_cleanup_command(profile: str, export: str, evidence_bundle: bool, dry_ru
|
|
798
1284
|
|
799
1285
|
try:
|
800
1286
|
optimizer = VPCCleanupOptimizer(profile=profile)
|
801
|
-
results = optimizer.analyze_vpc_cleanup_opportunities(
|
1287
|
+
results = optimizer.analyze_vpc_cleanup_opportunities(
|
1288
|
+
no_eni_only=no_eni_only,
|
1289
|
+
filter_type=filter
|
1290
|
+
)
|
802
1291
|
|
803
1292
|
if evidence_bundle:
|
804
1293
|
print_info(f"📁 Evidence bundle generated: SHA256 {results.evidence_hash}")
|
805
1294
|
|
806
1295
|
if export:
|
807
|
-
|
1296
|
+
from .vpc_cleanup_exporter import export_vpc_cleanup_results
|
1297
|
+
export_formats = [format.strip() for format in export.split(',')]
|
1298
|
+
export_results = export_vpc_cleanup_results(
|
1299
|
+
results,
|
1300
|
+
export_formats=export_formats,
|
1301
|
+
output_dir="./tmp"
|
1302
|
+
)
|
1303
|
+
|
1304
|
+
for format_type, filename in export_results.items():
|
1305
|
+
if filename:
|
1306
|
+
print_success(f"📄 {format_type.upper()} export: {filename}")
|
1307
|
+
else:
|
1308
|
+
print_warning(f"⚠️ {format_type.upper()} export failed")
|
808
1309
|
|
809
1310
|
print_success("🎯 AWSO-05 VPC cleanup analysis completed successfully")
|
810
1311
|
|