runbooks 0.9.9__py3-none-any.whl → 1.0.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- runbooks/cfat/cloud_foundations_assessment.py +626 -0
- runbooks/cloudops/cost_optimizer.py +95 -33
- runbooks/common/aws_pricing.py +388 -0
- runbooks/common/aws_pricing_api.py +205 -0
- runbooks/common/aws_utils.py +2 -2
- runbooks/common/comprehensive_cost_explorer_integration.py +979 -0
- runbooks/common/cross_account_manager.py +606 -0
- runbooks/common/enhanced_exception_handler.py +4 -0
- runbooks/common/env_utils.py +96 -0
- runbooks/common/mcp_integration.py +49 -2
- runbooks/common/organizations_client.py +579 -0
- runbooks/common/profile_utils.py +96 -2
- runbooks/finops/cost_optimizer.py +2 -1
- runbooks/finops/elastic_ip_optimizer.py +13 -9
- runbooks/finops/embedded_mcp_validator.py +31 -0
- runbooks/finops/enhanced_trend_visualization.py +3 -2
- runbooks/finops/markdown_exporter.py +217 -2
- runbooks/finops/nat_gateway_optimizer.py +57 -20
- runbooks/finops/vpc_cleanup_exporter.py +28 -26
- runbooks/finops/vpc_cleanup_optimizer.py +370 -16
- runbooks/inventory/__init__.py +10 -1
- runbooks/inventory/cloud_foundations_integration.py +409 -0
- runbooks/inventory/core/collector.py +1148 -88
- runbooks/inventory/discovery.md +389 -0
- runbooks/inventory/drift_detection_cli.py +327 -0
- runbooks/inventory/inventory_mcp_cli.py +171 -0
- runbooks/inventory/inventory_modules.py +4 -7
- runbooks/inventory/mcp_inventory_validator.py +2149 -0
- runbooks/inventory/mcp_vpc_validator.py +23 -6
- runbooks/inventory/organizations_discovery.py +91 -1
- runbooks/inventory/rich_inventory_display.py +129 -1
- runbooks/inventory/unified_validation_engine.py +1292 -0
- runbooks/inventory/verify_ec2_security_groups.py +3 -1
- runbooks/inventory/vpc_analyzer.py +825 -7
- runbooks/inventory/vpc_flow_analyzer.py +36 -42
- runbooks/main.py +654 -35
- runbooks/monitoring/performance_monitor.py +11 -7
- runbooks/operate/dynamodb_operations.py +6 -5
- runbooks/operate/ec2_operations.py +3 -2
- runbooks/operate/networking_cost_heatmap.py +4 -3
- runbooks/operate/s3_operations.py +13 -12
- runbooks/operate/vpc_operations.py +49 -1
- runbooks/remediation/base.py +1 -1
- runbooks/remediation/commvault_ec2_analysis.py +6 -1
- runbooks/remediation/ec2_unattached_ebs_volumes.py +6 -3
- runbooks/remediation/rds_snapshot_list.py +5 -3
- runbooks/validation/__init__.py +21 -1
- runbooks/validation/comprehensive_2way_validator.py +1996 -0
- runbooks/validation/mcp_validator.py +904 -94
- runbooks/validation/terraform_citations_validator.py +363 -0
- runbooks/validation/terraform_drift_detector.py +1098 -0
- runbooks/vpc/cleanup_wrapper.py +231 -10
- runbooks/vpc/config.py +310 -62
- runbooks/vpc/cross_account_session.py +308 -0
- runbooks/vpc/heatmap_engine.py +96 -29
- runbooks/vpc/manager_interface.py +9 -9
- runbooks/vpc/mcp_no_eni_validator.py +1551 -0
- runbooks/vpc/networking_wrapper.py +14 -8
- runbooks/vpc/runbooks.inventory.organizations_discovery.log +0 -0
- runbooks/vpc/runbooks.security.report_generator.log +0 -0
- runbooks/vpc/runbooks.security.run_script.log +0 -0
- runbooks/vpc/runbooks.security.security_export.log +0 -0
- runbooks/vpc/tests/test_cost_engine.py +1 -1
- runbooks/vpc/unified_scenarios.py +73 -3
- runbooks/vpc/vpc_cleanup_integration.py +512 -78
- {runbooks-0.9.9.dist-info → runbooks-1.0.0.dist-info}/METADATA +94 -52
- {runbooks-0.9.9.dist-info → runbooks-1.0.0.dist-info}/RECORD +71 -49
- {runbooks-0.9.9.dist-info → runbooks-1.0.0.dist-info}/WHEEL +0 -0
- {runbooks-0.9.9.dist-info → runbooks-1.0.0.dist-info}/entry_points.txt +0 -0
- {runbooks-0.9.9.dist-info → runbooks-1.0.0.dist-info}/licenses/LICENSE +0 -0
- {runbooks-0.9.9.dist-info → runbooks-1.0.0.dist-info}/top_level.txt +0 -0
runbooks/common/profile_utils.py
CHANGED
@@ -17,12 +17,28 @@ Version: 0.9.0
|
|
17
17
|
"""
|
18
18
|
|
19
19
|
import os
|
20
|
+
import time
|
20
21
|
from typing import Dict, Optional
|
21
22
|
|
22
23
|
import boto3
|
23
24
|
|
24
25
|
from runbooks.common.rich_utils import console
|
25
26
|
|
27
|
+
# Profile cache to reduce duplicate calls (enterprise performance optimization)
|
28
|
+
_profile_cache = {}
|
29
|
+
_cache_timestamp = None
|
30
|
+
_cache_ttl = 300 # 5 minutes cache TTL
|
31
|
+
|
32
|
+
# Enterprise AWS profile mappings with fallback defaults
|
33
|
+
ENV_PROFILE_MAP = {
|
34
|
+
"billing": os.getenv("BILLING_PROFILE"),
|
35
|
+
"management": os.getenv("MANAGEMENT_PROFILE"),
|
36
|
+
"operational": os.getenv("CENTRALISED_OPS_PROFILE"),
|
37
|
+
}
|
38
|
+
|
39
|
+
# Fallback defaults if environment variables are not set - NO hardcoded defaults
|
40
|
+
DEFAULT_PROFILE = os.getenv("AWS_PROFILE") or "default" # "default" is AWS boto3 expected fallback
|
41
|
+
|
26
42
|
|
27
43
|
def get_profile_for_operation(operation_type: str, user_specified_profile: Optional[str] = None) -> str:
|
28
44
|
"""
|
@@ -45,12 +61,30 @@ def get_profile_for_operation(operation_type: str, user_specified_profile: Optio
|
|
45
61
|
Raises:
|
46
62
|
SystemExit: If user-specified profile not found in AWS config
|
47
63
|
"""
|
64
|
+
global _profile_cache, _cache_timestamp
|
65
|
+
|
66
|
+
# Check cache first to reduce duplicate calls (enterprise performance optimization)
|
67
|
+
cache_key = f"{operation_type}:{user_specified_profile or 'None'}"
|
68
|
+
current_time = time.time()
|
69
|
+
|
70
|
+
if (_cache_timestamp and
|
71
|
+
current_time - _cache_timestamp < _cache_ttl and
|
72
|
+
cache_key in _profile_cache):
|
73
|
+
return _profile_cache[cache_key]
|
74
|
+
|
75
|
+
# Clear cache if TTL expired
|
76
|
+
if not _cache_timestamp or current_time - _cache_timestamp >= _cache_ttl:
|
77
|
+
_profile_cache.clear()
|
78
|
+
_cache_timestamp = current_time
|
79
|
+
|
48
80
|
available_profiles = boto3.Session().available_profiles
|
49
81
|
|
50
82
|
# PRIORITY 1: User-specified profile ALWAYS takes precedence
|
51
83
|
if user_specified_profile and user_specified_profile != "default":
|
52
84
|
if user_specified_profile in available_profiles:
|
53
85
|
console.log(f"[green]Using user-specified profile for {operation_type}: {user_specified_profile}[/]")
|
86
|
+
# Cache the result to reduce duplicate calls
|
87
|
+
_profile_cache[cache_key] = user_specified_profile
|
54
88
|
return user_specified_profile
|
55
89
|
else:
|
56
90
|
console.log(f"[red]Error: User-specified profile '{user_specified_profile}' not found in AWS config[/]")
|
@@ -68,11 +102,16 @@ def get_profile_for_operation(operation_type: str, user_specified_profile: Optio
|
|
68
102
|
env_profile = profile_map.get(operation_type)
|
69
103
|
if env_profile and env_profile in available_profiles:
|
70
104
|
console.log(f"[dim cyan]Using {operation_type} profile from environment: {env_profile}[/]")
|
105
|
+
# Cache the result to reduce duplicate calls
|
106
|
+
_profile_cache[cache_key] = env_profile
|
71
107
|
return env_profile
|
72
108
|
|
73
109
|
# PRIORITY 3: Default profile (last resort)
|
74
|
-
|
75
|
-
|
110
|
+
default_profile = user_specified_profile or "default"
|
111
|
+
console.log(f"[yellow]No {operation_type} profile found, using default: {default_profile}[/]")
|
112
|
+
# Cache the result to reduce duplicate calls
|
113
|
+
_profile_cache[cache_key] = default_profile
|
114
|
+
return default_profile
|
76
115
|
|
77
116
|
|
78
117
|
def resolve_profile_for_operation_silent(operation_type: str, user_specified_profile: Optional[str] = None) -> str:
|
@@ -206,6 +245,60 @@ def validate_profile_access(profile_name: str, operation_type: str = "general")
|
|
206
245
|
return False
|
207
246
|
|
208
247
|
|
248
|
+
def get_available_profiles_for_validation() -> list:
|
249
|
+
"""
|
250
|
+
Get available AWS profiles for validation - universal compatibility approach.
|
251
|
+
|
252
|
+
Returns all configured AWS profiles for validation without hardcoded assumptions.
|
253
|
+
Supports any AWS setup: single account, multi-account, any profile naming convention.
|
254
|
+
|
255
|
+
Returns:
|
256
|
+
list: Available AWS profile names for validation
|
257
|
+
"""
|
258
|
+
try:
|
259
|
+
# Get all available profiles from AWS CLI configuration
|
260
|
+
available_profiles = boto3.Session().available_profiles
|
261
|
+
|
262
|
+
# Filter out common system profiles that shouldn't be tested
|
263
|
+
system_profiles = {'default', 'none', 'null', ''}
|
264
|
+
|
265
|
+
# Return profiles for validation, including default if it's the only one
|
266
|
+
validation_profiles = []
|
267
|
+
|
268
|
+
# Add environment variable profiles if they exist
|
269
|
+
env_profiles = [
|
270
|
+
os.getenv("AWS_BILLING_PROFILE"),
|
271
|
+
os.getenv("AWS_MANAGEMENT_PROFILE"),
|
272
|
+
os.getenv("AWS_CENTRALISED_OPS_PROFILE"),
|
273
|
+
os.getenv("AWS_SINGLE_ACCOUNT_PROFILE"),
|
274
|
+
os.getenv("BILLING_PROFILE"),
|
275
|
+
os.getenv("MANAGEMENT_PROFILE"),
|
276
|
+
os.getenv("CENTRALISED_OPS_PROFILE"),
|
277
|
+
os.getenv("SINGLE_AWS_PROFILE"),
|
278
|
+
]
|
279
|
+
|
280
|
+
# Add valid environment profiles
|
281
|
+
for profile in env_profiles:
|
282
|
+
if profile and profile in available_profiles and profile not in validation_profiles:
|
283
|
+
validation_profiles.append(profile)
|
284
|
+
|
285
|
+
# If no environment profiles found, use available profiles (universal approach)
|
286
|
+
if not validation_profiles:
|
287
|
+
for profile in available_profiles:
|
288
|
+
if profile not in system_profiles:
|
289
|
+
validation_profiles.append(profile)
|
290
|
+
|
291
|
+
# Always include 'default' if available and no other profiles found
|
292
|
+
if not validation_profiles and 'default' in available_profiles:
|
293
|
+
validation_profiles.append('default')
|
294
|
+
|
295
|
+
return validation_profiles
|
296
|
+
|
297
|
+
except Exception as e:
|
298
|
+
console.log(f"[yellow]Warning: Could not detect AWS profiles: {e}[/]")
|
299
|
+
return ['default'] # Fallback to default profile
|
300
|
+
|
301
|
+
|
209
302
|
# Export all public functions
|
210
303
|
__all__ = [
|
211
304
|
"get_profile_for_operation",
|
@@ -215,4 +308,5 @@ __all__ = [
|
|
215
308
|
"create_operational_session",
|
216
309
|
"get_enterprise_profile_mapping",
|
217
310
|
"validate_profile_access",
|
311
|
+
"get_available_profiles_for_validation",
|
218
312
|
]
|
@@ -25,6 +25,7 @@ from ..common.rich_utils import (
|
|
25
25
|
console, print_header, print_success, print_error, print_warning,
|
26
26
|
create_table, create_progress_bar, format_cost
|
27
27
|
)
|
28
|
+
from ..common.aws_pricing import DynamicAWSPricing
|
28
29
|
|
29
30
|
@dataclass
|
30
31
|
class IdleInstance:
|
@@ -55,7 +56,7 @@ class UnusedNATGateway:
|
|
55
56
|
region: str
|
56
57
|
vpc_id: str = ""
|
57
58
|
state: str = ""
|
58
|
-
estimated_monthly_cost: float =
|
59
|
+
estimated_monthly_cost: float = 0.0 # Calculated dynamically using AWS pricing
|
59
60
|
creation_date: Optional[str] = None
|
60
61
|
tags: Dict[str, str] = Field(default_factory=dict)
|
61
62
|
|
@@ -36,6 +36,7 @@ from ..common.rich_utils import (
|
|
36
36
|
console, print_header, print_success, print_error, print_warning, print_info,
|
37
37
|
create_table, create_progress_bar, format_cost, create_panel, STATUS_INDICATORS
|
38
38
|
)
|
39
|
+
from ..common.aws_pricing import get_service_monthly_cost, calculate_annual_cost
|
39
40
|
from .embedded_mcp_validator import EmbeddedMCPValidator
|
40
41
|
from ..common.profile_utils import get_profile_for_operation
|
41
42
|
|
@@ -65,8 +66,8 @@ class ElasticIPOptimizationResult(BaseModel):
|
|
65
66
|
domain: str
|
66
67
|
is_attached: bool
|
67
68
|
instance_id: Optional[str] = None
|
68
|
-
monthly_cost: float =
|
69
|
-
annual_cost: float =
|
69
|
+
monthly_cost: float = 0.0 # Calculated dynamically per region
|
70
|
+
annual_cost: float = 0.0 # Calculated dynamically (monthly * 12)
|
70
71
|
optimization_recommendation: str = "retain" # retain, release
|
71
72
|
risk_level: str = "low" # low, medium, high
|
72
73
|
business_impact: str = "minimal"
|
@@ -118,8 +119,8 @@ class ElasticIPOptimizer:
|
|
118
119
|
profile_name=get_profile_for_operation("operational", profile_name)
|
119
120
|
)
|
120
121
|
|
121
|
-
# Elastic IP pricing
|
122
|
-
|
122
|
+
# Dynamic Elastic IP pricing - Enterprise compliance (no hardcoded values)
|
123
|
+
# Pricing will be calculated dynamically per region using AWS Pricing API
|
123
124
|
|
124
125
|
# All AWS regions for comprehensive discovery
|
125
126
|
self.all_regions = [
|
@@ -360,9 +361,12 @@ class ElasticIPOptimizer:
|
|
360
361
|
try:
|
361
362
|
dns_refs = dns_dependencies.get(elastic_ip.allocation_id, [])
|
362
363
|
|
363
|
-
# Calculate current costs (only unattached EIPs are charged)
|
364
|
-
|
365
|
-
|
364
|
+
# Calculate current costs (only unattached EIPs are charged) - Dynamic pricing
|
365
|
+
if elastic_ip.is_attached:
|
366
|
+
monthly_cost = 0.0 # Attached EIPs are free
|
367
|
+
else:
|
368
|
+
monthly_cost = get_service_monthly_cost("elastic_ip", elastic_ip.region)
|
369
|
+
annual_cost = calculate_annual_cost(monthly_cost)
|
366
370
|
|
367
371
|
# Determine optimization recommendation
|
368
372
|
recommendation = "retain" # Default: keep the Elastic IP
|
@@ -384,14 +388,14 @@ class ElasticIPOptimizer:
|
|
384
388
|
recommendation = "release"
|
385
389
|
risk_level = "low"
|
386
390
|
business_impact = "none"
|
387
|
-
potential_monthly_savings =
|
391
|
+
potential_monthly_savings = monthly_cost
|
388
392
|
safety_checks["safe_to_release"] = True
|
389
393
|
else:
|
390
394
|
# Unattached but has DNS references - investigate before release
|
391
395
|
recommendation = "investigate"
|
392
396
|
risk_level = "medium"
|
393
397
|
business_impact = "potential"
|
394
|
-
potential_monthly_savings =
|
398
|
+
potential_monthly_savings = monthly_cost * 0.8 # Conservative estimate
|
395
399
|
elif elastic_ip.is_attached:
|
396
400
|
# Attached EIPs are retained (no cost for attached EIPs)
|
397
401
|
recommendation = "retain"
|
@@ -126,6 +126,37 @@ class EmbeddedMCPValidator:
|
|
126
126
|
self._finalize_validation_results(validation_results)
|
127
127
|
return validation_results
|
128
128
|
|
129
|
+
def validate_cost_data_sync(self, runbooks_data: Dict[str, Any]) -> Dict[str, Any]:
|
130
|
+
"""
|
131
|
+
Synchronous wrapper for MCP validation - for compatibility with test scripts.
|
132
|
+
|
133
|
+
Args:
|
134
|
+
runbooks_data: Cost data from runbooks FinOps analysis
|
135
|
+
|
136
|
+
Returns:
|
137
|
+
Validation results with accuracy metrics
|
138
|
+
"""
|
139
|
+
# Use asyncio to run the async validation method
|
140
|
+
try:
|
141
|
+
loop = asyncio.get_event_loop()
|
142
|
+
except RuntimeError:
|
143
|
+
loop = asyncio.new_event_loop()
|
144
|
+
asyncio.set_event_loop(loop)
|
145
|
+
|
146
|
+
try:
|
147
|
+
return loop.run_until_complete(self.validate_cost_data_async(runbooks_data))
|
148
|
+
except Exception as e:
|
149
|
+
print_error(f"Synchronous MCP validation failed: {e}")
|
150
|
+
return {
|
151
|
+
"validation_timestamp": datetime.now().isoformat(),
|
152
|
+
"profiles_validated": 0,
|
153
|
+
"total_accuracy": 0.0,
|
154
|
+
"passed_validation": False,
|
155
|
+
"profile_results": [],
|
156
|
+
"validation_method": "embedded_mcp_direct_aws_api_sync",
|
157
|
+
"error": str(e)
|
158
|
+
}
|
159
|
+
|
129
160
|
def _validate_profile_sync(self, profile: str, session: boto3.Session, runbooks_data: List[Dict[str, Any]]) -> Optional[Dict[str, Any]]:
|
130
161
|
"""Synchronous wrapper for profile validation (for parallel execution)."""
|
131
162
|
try:
|
@@ -343,8 +343,9 @@ def create_resource_based_trend_estimate(session, months: int = 6) -> List[Tuple
|
|
343
343
|
current_date = datetime.now()
|
344
344
|
trend_data = []
|
345
345
|
|
346
|
-
# Base resource cost estimation (
|
347
|
-
|
346
|
+
# Base resource cost estimation (dynamic model)
|
347
|
+
# Calculate baseline from actual service usage patterns
|
348
|
+
base_monthly_cost = 0.0 # Will be calculated from actual usage data or dynamic pricing
|
348
349
|
|
349
350
|
# Simulate realistic cost variations over 6 months
|
350
351
|
# Based on typical AWS usage patterns
|
@@ -458,7 +458,7 @@ class MarkdownExporter:
|
|
458
458
|
"| " + " | ".join(["---" for _ in headers]) + " |"
|
459
459
|
]
|
460
460
|
|
461
|
-
# Process each VPC candidate
|
461
|
+
# Process each VPC candidate with enhanced data extraction
|
462
462
|
for candidate in vpc_candidates:
|
463
463
|
# Extract data with safe attribute access and formatting
|
464
464
|
account_id = getattr(candidate, 'account_id', 'Unknown')
|
@@ -466,7 +466,44 @@ class MarkdownExporter:
|
|
466
466
|
vpc_name = getattr(candidate, 'vpc_name', '') or 'Unnamed'
|
467
467
|
cidr_block = getattr(candidate, 'cidr_block', 'Unknown')
|
468
468
|
|
469
|
-
# Handle overlapping logic -
|
469
|
+
# Handle overlapping logic - check CIDR conflicts
|
470
|
+
overlapping = self._check_cidr_overlapping(cidr_block, vpc_candidates)
|
471
|
+
|
472
|
+
# Enhanced is_default handling
|
473
|
+
is_default = getattr(candidate, 'is_default', False)
|
474
|
+
is_default_display = "⚠️ Yes" if is_default else "No"
|
475
|
+
|
476
|
+
# Enhanced ENI count
|
477
|
+
dependency_analysis = getattr(candidate, 'dependency_analysis', None)
|
478
|
+
eni_count = dependency_analysis.eni_count if dependency_analysis else 0
|
479
|
+
|
480
|
+
# Enhanced tags with owner focus
|
481
|
+
tags_dict = getattr(candidate, 'tags', {}) or {}
|
482
|
+
tags_display = self._format_tags_for_owners_display(tags_dict)
|
483
|
+
|
484
|
+
# Flow logs detection
|
485
|
+
flow_logs = self._detect_flow_logs(candidate)
|
486
|
+
|
487
|
+
# TGW/Peering detection
|
488
|
+
tgw_peering = self._detect_tgw_peering(candidate)
|
489
|
+
|
490
|
+
# Load balancers detection
|
491
|
+
lbs_present = self._detect_load_balancers(candidate)
|
492
|
+
|
493
|
+
# IaC detection from tags
|
494
|
+
iac_detected = self._detect_iac_from_tags(tags_dict)
|
495
|
+
|
496
|
+
# Timeline estimation based on VPC state
|
497
|
+
timeline = self._estimate_cleanup_timeline(candidate)
|
498
|
+
|
499
|
+
# Decision based on bucket classification
|
500
|
+
decision = self._determine_cleanup_decision(candidate)
|
501
|
+
|
502
|
+
# Enhanced owners/approvals extraction
|
503
|
+
owners_approvals = self._extract_owners_approvals(tags_dict, is_default)
|
504
|
+
|
505
|
+
# Notes based on VPC characteristics
|
506
|
+
notes = self._generate_vpc_notes(candidate)
|
470
507
|
overlapping = "Yes" if getattr(candidate, 'overlapping', False) else "No"
|
471
508
|
|
472
509
|
# Format boolean indicators with emoji
|
@@ -649,6 +686,184 @@ class MarkdownExporter:
|
|
649
686
|
print_warning(f"❌ Failed to export VPC analysis: {e}")
|
650
687
|
return ""
|
651
688
|
|
689
|
+
def _check_cidr_overlapping(self, cidr_block: str, vpc_candidates: List[Any]) -> str:
|
690
|
+
"""Check for CIDR block overlapping across VPCs."""
|
691
|
+
if not cidr_block or not vpc_candidates:
|
692
|
+
return "No"
|
693
|
+
|
694
|
+
# Simple overlapping check - in enterprise scenario, this would use more sophisticated logic
|
695
|
+
current_cidr = cidr_block
|
696
|
+
for candidate in vpc_candidates:
|
697
|
+
other_cidr = getattr(candidate, 'cidr_block', None)
|
698
|
+
if other_cidr and other_cidr != current_cidr and current_cidr.startswith(other_cidr.split('/')[0].rsplit('.', 1)[0]):
|
699
|
+
return "Yes"
|
700
|
+
|
701
|
+
return "No"
|
702
|
+
|
703
|
+
def _detect_flow_logs(self, candidate: Any) -> str:
|
704
|
+
"""Detect if VPC has flow logs enabled."""
|
705
|
+
return "Yes" if getattr(candidate, 'flow_logs_enabled', False) else "No"
|
706
|
+
|
707
|
+
def _detect_tgw_peering(self, candidate: Any) -> str:
|
708
|
+
"""Analyze Transit Gateway and VPC peering connections."""
|
709
|
+
# Check for TGW attachments and peering connections
|
710
|
+
tgw_attachments = getattr(candidate, 'tgw_attachments', []) or []
|
711
|
+
peering_connections = getattr(candidate, 'peering_connections', []) or []
|
712
|
+
|
713
|
+
if tgw_attachments or peering_connections:
|
714
|
+
connection_count = len(tgw_attachments) + len(peering_connections)
|
715
|
+
return f"Yes ({connection_count})"
|
716
|
+
return "No"
|
717
|
+
|
718
|
+
def _detect_load_balancers(self, candidate: Any) -> str:
|
719
|
+
"""Detect load balancers in the VPC."""
|
720
|
+
load_balancers = getattr(candidate, 'load_balancers', []) or []
|
721
|
+
return "Yes" if load_balancers else "No"
|
722
|
+
|
723
|
+
def _detect_iac_from_tags(self, tags_dict: dict) -> str:
|
724
|
+
"""Detect Infrastructure as Code management from tags."""
|
725
|
+
iac_keys = ['aws:cloudformation:stack-name', 'terraform:module', 'cdktf:stack', 'pulumi:project']
|
726
|
+
for key in iac_keys:
|
727
|
+
if key in tags_dict and tags_dict[key]:
|
728
|
+
return "Yes"
|
729
|
+
return "No"
|
730
|
+
|
731
|
+
def _estimate_cleanup_timeline(self, candidate: Any) -> str:
|
732
|
+
"""Estimate cleanup timeline based on complexity."""
|
733
|
+
# Simple heuristic based on dependencies
|
734
|
+
if hasattr(candidate, 'dependency_analysis') and candidate.dependency_analysis:
|
735
|
+
eni_count = getattr(candidate.dependency_analysis, 'eni_count', 0)
|
736
|
+
else:
|
737
|
+
eni_count = 0
|
738
|
+
|
739
|
+
if eni_count == 0:
|
740
|
+
return "1-2 days"
|
741
|
+
elif eni_count < 5:
|
742
|
+
return "3-5 days"
|
743
|
+
else:
|
744
|
+
return "1-2 weeks"
|
745
|
+
|
746
|
+
def _format_cleanup_decision(self, candidate: Any) -> str:
|
747
|
+
"""Format cleanup decision recommendation."""
|
748
|
+
recommendation = getattr(candidate, 'cleanup_recommendation', 'unknown')
|
749
|
+
if recommendation == 'delete':
|
750
|
+
return "Delete"
|
751
|
+
elif recommendation == 'keep':
|
752
|
+
return "Keep"
|
753
|
+
elif recommendation == 'review':
|
754
|
+
return "Review"
|
755
|
+
else:
|
756
|
+
return "TBD"
|
757
|
+
|
758
|
+
def _format_tags_for_owners_display(self, tags_dict: dict) -> str:
|
759
|
+
"""Format tags for display with priority on ownership information."""
|
760
|
+
if not tags_dict:
|
761
|
+
return "No tags"
|
762
|
+
|
763
|
+
# Priority keys focusing on ownership and approvals
|
764
|
+
priority_keys = ['Name', 'Owner', 'BusinessOwner', 'TechnicalOwner', 'Team', 'Contact']
|
765
|
+
relevant_tags = []
|
766
|
+
|
767
|
+
for key in priority_keys:
|
768
|
+
if key in tags_dict and tags_dict[key]:
|
769
|
+
relevant_tags.append(f"{key}:{tags_dict[key]}")
|
770
|
+
if len(relevant_tags) >= 3: # Limit for table readability
|
771
|
+
break
|
772
|
+
|
773
|
+
return "; ".join(relevant_tags) if relevant_tags else f"({len(tags_dict)} tags)"
|
774
|
+
|
775
|
+
def _determine_cleanup_decision(self, candidate: Any) -> str:
|
776
|
+
"""Determine cleanup decision based on VPC analysis."""
|
777
|
+
# Check the cleanup bucket from three-bucket strategy
|
778
|
+
cleanup_bucket = getattr(candidate, 'cleanup_bucket', 'unknown')
|
779
|
+
|
780
|
+
if cleanup_bucket == 'bucket_1':
|
781
|
+
return "Delete"
|
782
|
+
elif cleanup_bucket == 'bucket_2':
|
783
|
+
return "Review"
|
784
|
+
elif cleanup_bucket == 'bucket_3':
|
785
|
+
return "Keep"
|
786
|
+
else:
|
787
|
+
# Fallback logic based on other attributes
|
788
|
+
is_default = getattr(candidate, 'is_default', False)
|
789
|
+
has_eni = getattr(candidate, 'eni_count', 0) > 0
|
790
|
+
|
791
|
+
if is_default and not has_eni:
|
792
|
+
return "Delete"
|
793
|
+
elif has_eni:
|
794
|
+
return "Review"
|
795
|
+
else:
|
796
|
+
return "TBD"
|
797
|
+
|
798
|
+
def _extract_owners_approvals(self, tags_dict: dict, is_default: bool) -> str:
|
799
|
+
"""Extract owners and approval information from tags and VPC status."""
|
800
|
+
# Extract from tags with enhanced owner detection
|
801
|
+
owner_keys = ['Owner', 'BusinessOwner', 'TechnicalOwner', 'Team', 'Contact', 'CreatedBy', 'ManagedBy']
|
802
|
+
|
803
|
+
extracted_owners = []
|
804
|
+
for key in owner_keys:
|
805
|
+
if key in tags_dict and tags_dict[key]:
|
806
|
+
value = tags_dict[key]
|
807
|
+
if 'business' in key.lower():
|
808
|
+
extracted_owners.append(f"{value} (Business)")
|
809
|
+
elif 'technical' in key.lower():
|
810
|
+
extracted_owners.append(f"{value} (Technical)")
|
811
|
+
elif 'team' in key.lower():
|
812
|
+
extracted_owners.append(f"{value} (Team)")
|
813
|
+
else:
|
814
|
+
extracted_owners.append(f"{value} ({key})")
|
815
|
+
|
816
|
+
if len(extracted_owners) >= 2: # Limit for table readability
|
817
|
+
break
|
818
|
+
|
819
|
+
if extracted_owners:
|
820
|
+
return "; ".join(extracted_owners)
|
821
|
+
|
822
|
+
# Fallback based on VPC type
|
823
|
+
if is_default:
|
824
|
+
return "System Default VPC"
|
825
|
+
else:
|
826
|
+
# Check for IaC tags
|
827
|
+
iac_keys = ['aws:cloudformation:stack-name', 'terraform:module', 'cdktf:stack', 'pulumi:project']
|
828
|
+
for key in iac_keys:
|
829
|
+
if key in tags_dict and tags_dict[key]:
|
830
|
+
return "IaC Managed"
|
831
|
+
return "No owner tags found"
|
832
|
+
|
833
|
+
def _generate_vpc_notes(self, candidate: Any) -> str:
|
834
|
+
"""Generate comprehensive notes for VPC candidate."""
|
835
|
+
notes = []
|
836
|
+
|
837
|
+
# Add bucket classification note
|
838
|
+
cleanup_bucket = getattr(candidate, 'cleanup_bucket', 'unknown')
|
839
|
+
if cleanup_bucket == 'bucket_1':
|
840
|
+
notes.append("Internal data plane - safe for cleanup")
|
841
|
+
elif cleanup_bucket == 'bucket_2':
|
842
|
+
notes.append("External interconnects - requires analysis")
|
843
|
+
elif cleanup_bucket == 'bucket_3':
|
844
|
+
notes.append("Control plane - manual review required")
|
845
|
+
|
846
|
+
# Add ENI count if significant
|
847
|
+
if hasattr(candidate, 'dependency_analysis') and candidate.dependency_analysis:
|
848
|
+
eni_count = getattr(candidate.dependency_analysis, 'eni_count', 0)
|
849
|
+
if eni_count > 0:
|
850
|
+
notes.append(f"{eni_count} ENI attachments")
|
851
|
+
|
852
|
+
# Add default VPC note
|
853
|
+
if getattr(candidate, 'is_default', False):
|
854
|
+
notes.append("Default VPC (CIS compliance issue)")
|
855
|
+
|
856
|
+
# Add IaC detection
|
857
|
+
if getattr(candidate, 'iac_detected', False):
|
858
|
+
notes.append("IaC managed")
|
859
|
+
|
860
|
+
# Add security concerns
|
861
|
+
risk_level = getattr(candidate, 'risk_level', 'unknown')
|
862
|
+
if risk_level == 'high':
|
863
|
+
notes.append("High security risk")
|
864
|
+
|
865
|
+
return "; ".join(notes) if notes else "Standard VPC cleanup candidate"
|
866
|
+
|
652
867
|
|
653
868
|
def export_finops_to_markdown(
|
654
869
|
profile_data: Union[Dict[str, Any], List[Dict[str, Any]]],
|
@@ -49,6 +49,7 @@ from ..common.rich_utils import (
|
|
49
49
|
console, print_header, print_success, print_error, print_warning, print_info,
|
50
50
|
create_table, create_progress_bar, format_cost, create_panel, STATUS_INDICATORS
|
51
51
|
)
|
52
|
+
from ..common.aws_pricing import get_service_monthly_cost, calculate_annual_cost
|
52
53
|
from .embedded_mcp_validator import EmbeddedMCPValidator
|
53
54
|
from ..common.profile_utils import get_profile_for_operation
|
54
55
|
|
@@ -139,14 +140,24 @@ class NATGatewayOptimizer:
|
|
139
140
|
profile_name=get_profile_for_operation("operational", profile_name)
|
140
141
|
)
|
141
142
|
|
142
|
-
# NAT Gateway pricing
|
143
|
-
|
144
|
-
self.
|
143
|
+
# NAT Gateway pricing - using dynamic pricing engine
|
144
|
+
# Base monthly cost calculation (will be applied per region)
|
145
|
+
self._base_monthly_cost_us_east_1 = get_service_monthly_cost("nat_gateway", "us-east-1")
|
146
|
+
self.nat_gateway_data_processing_cost = 0.045 # $0.045/GB (data transfer pricing)
|
145
147
|
|
146
148
|
# Enterprise thresholds for optimization recommendations
|
147
149
|
self.low_usage_threshold_connections = 10 # Active connections per day
|
148
150
|
self.low_usage_threshold_bytes = 1_000_000 # 1MB per day
|
149
151
|
self.analysis_period_days = 7 # CloudWatch analysis period
|
152
|
+
|
153
|
+
def _get_regional_monthly_cost(self, region: str) -> float:
|
154
|
+
"""Get dynamic monthly NAT Gateway cost for specified region."""
|
155
|
+
try:
|
156
|
+
return get_service_monthly_cost("nat_gateway", region)
|
157
|
+
except Exception:
|
158
|
+
# Fallback to regional cost calculation
|
159
|
+
from ..common.aws_pricing import calculate_regional_cost
|
160
|
+
return calculate_regional_cost(self._base_monthly_cost_us_east_1, region)
|
150
161
|
|
151
162
|
async def analyze_nat_gateways(self, dry_run: bool = True) -> NATGatewayOptimizerResults:
|
152
163
|
"""
|
@@ -413,9 +424,9 @@ class NATGatewayOptimizer:
|
|
413
424
|
metrics = usage_metrics.get(nat_gateway.nat_gateway_id)
|
414
425
|
route_tables = dependencies.get(nat_gateway.nat_gateway_id, [])
|
415
426
|
|
416
|
-
# Calculate current costs
|
417
|
-
monthly_cost = self.
|
418
|
-
annual_cost = monthly_cost
|
427
|
+
# Calculate current costs using dynamic pricing
|
428
|
+
monthly_cost = self._get_regional_monthly_cost(nat_gateway.region)
|
429
|
+
annual_cost = calculate_annual_cost(monthly_cost)
|
419
430
|
|
420
431
|
# Determine optimization recommendation
|
421
432
|
recommendation = "retain" # Default: keep the NAT Gateway
|
@@ -724,9 +735,9 @@ class TransitGatewayCostAnalysis(BaseModel):
|
|
724
735
|
"""Transit Gateway cost analysis results"""
|
725
736
|
transit_gateway_id: str
|
726
737
|
region: str
|
727
|
-
monthly_base_cost: float =
|
738
|
+
monthly_base_cost: float = 0.0 # Will be calculated dynamically based on region
|
728
739
|
attachment_count: int = 0
|
729
|
-
attachment_hourly_cost: float = 0.05 # $0.05/hour per attachment
|
740
|
+
attachment_hourly_cost: float = 0.05 # $0.05/hour per attachment (attachment pricing)
|
730
741
|
data_processing_cost: float = 0.0
|
731
742
|
total_monthly_cost: float = 0.0
|
732
743
|
annual_cost: float = 0.0
|
@@ -737,7 +748,7 @@ class NetworkDataTransferCostAnalysis(BaseModel):
|
|
737
748
|
"""Network data transfer cost analysis"""
|
738
749
|
region_pair: str # e.g., "us-east-1 -> us-west-2"
|
739
750
|
monthly_gb_transferred: float = 0.0
|
740
|
-
cost_per_gb: float = 0.
|
751
|
+
cost_per_gb: float = 0.0 # Will be calculated dynamically based on region pair
|
741
752
|
monthly_transfer_cost: float = 0.0
|
742
753
|
annual_transfer_cost: float = 0.0
|
743
754
|
optimization_recommendations: List[str] = Field(default_factory=list)
|
@@ -763,17 +774,43 @@ class EnhancedVPCCostOptimizer:
|
|
763
774
|
self.profile = profile
|
764
775
|
self.nat_optimizer = NATGatewayOptimizer(profile=profile)
|
765
776
|
|
766
|
-
#
|
767
|
-
self.cost_model =
|
768
|
-
|
769
|
-
|
770
|
-
|
771
|
-
|
772
|
-
|
773
|
-
"
|
774
|
-
|
775
|
-
|
776
|
-
|
777
|
+
# Dynamic cost model using AWS pricing engine
|
778
|
+
self.cost_model = self._initialize_dynamic_cost_model()
|
779
|
+
|
780
|
+
def _initialize_dynamic_cost_model(self) -> Dict[str, float]:
|
781
|
+
"""Initialize dynamic cost model using AWS pricing engine."""
|
782
|
+
try:
|
783
|
+
# Get base pricing for us-east-1, then apply regional multipliers as needed
|
784
|
+
base_region = "us-east-1"
|
785
|
+
|
786
|
+
return {
|
787
|
+
"nat_gateway_monthly": get_service_monthly_cost("nat_gateway", base_region),
|
788
|
+
"nat_gateway_data_processing": get_service_monthly_cost("data_transfer", base_region),
|
789
|
+
"transit_gateway_monthly": get_service_monthly_cost("transit_gateway", base_region),
|
790
|
+
"vpc_endpoint_monthly": get_service_monthly_cost("vpc_endpoint", base_region),
|
791
|
+
"data_transfer_regional": get_service_monthly_cost("data_transfer", base_region),
|
792
|
+
"data_transfer_internet": get_service_monthly_cost("data_transfer", base_region) * 4.5, # Internet is ~4.5x higher
|
793
|
+
}
|
794
|
+
except Exception as e:
|
795
|
+
print_warning(f"Dynamic pricing initialization failed: {e}")
|
796
|
+
# Fallback to regional cost calculation
|
797
|
+
from ..common.aws_pricing import calculate_regional_cost
|
798
|
+
base_costs = {
|
799
|
+
"nat_gateway_hourly": 0.045,
|
800
|
+
"nat_gateway_data_processing": 0.045, # per GB
|
801
|
+
"transit_gateway_monthly": 36.50,
|
802
|
+
"transit_gateway_attachment_hourly": 0.05,
|
803
|
+
"vpc_endpoint_interface_hourly": 0.01,
|
804
|
+
"data_transfer_regional": 0.01, # per GB within region
|
805
|
+
"data_transfer_cross_region": 0.02, # per GB cross-region
|
806
|
+
"data_transfer_internet": 0.09 # per GB to internet
|
807
|
+
}
|
808
|
+
|
809
|
+
# Apply regional multipliers to fallback costs
|
810
|
+
return {
|
811
|
+
key: calculate_regional_cost(value, "us-east-1")
|
812
|
+
for key, value in base_costs.items()
|
813
|
+
}
|
777
814
|
|
778
815
|
async def analyze_comprehensive_vpc_costs(self, profile: Optional[str] = None,
|
779
816
|
regions: Optional[List[str]] = None) -> Dict[str, Any]:
|