runbooks 1.1.3__py3-none-any.whl → 1.1.5__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 +31 -2
- runbooks/__init___optimized.py +18 -4
- runbooks/_platform/__init__.py +1 -5
- runbooks/_platform/core/runbooks_wrapper.py +141 -138
- runbooks/aws2/accuracy_validator.py +812 -0
- runbooks/base.py +7 -0
- runbooks/cfat/WEIGHT_CONFIG_README.md +1 -1
- runbooks/cfat/assessment/compliance.py +8 -8
- runbooks/cfat/assessment/runner.py +1 -0
- runbooks/cfat/cloud_foundations_assessment.py +227 -239
- runbooks/cfat/models.py +6 -2
- runbooks/cfat/tests/__init__.py +6 -1
- runbooks/cli/__init__.py +13 -0
- runbooks/cli/commands/cfat.py +274 -0
- runbooks/cli/commands/finops.py +1164 -0
- runbooks/cli/commands/inventory.py +379 -0
- runbooks/cli/commands/operate.py +239 -0
- runbooks/cli/commands/security.py +248 -0
- runbooks/cli/commands/validation.py +825 -0
- runbooks/cli/commands/vpc.py +310 -0
- runbooks/cli/registry.py +107 -0
- runbooks/cloudops/__init__.py +23 -30
- runbooks/cloudops/base.py +96 -107
- runbooks/cloudops/cost_optimizer.py +549 -547
- runbooks/cloudops/infrastructure_optimizer.py +5 -4
- runbooks/cloudops/interfaces.py +226 -227
- runbooks/cloudops/lifecycle_manager.py +5 -4
- runbooks/cloudops/mcp_cost_validation.py +252 -235
- runbooks/cloudops/models.py +78 -53
- runbooks/cloudops/monitoring_automation.py +5 -4
- runbooks/cloudops/notebook_framework.py +179 -215
- runbooks/cloudops/security_enforcer.py +125 -159
- runbooks/common/accuracy_validator.py +11 -0
- runbooks/common/aws_pricing.py +349 -326
- runbooks/common/aws_pricing_api.py +211 -212
- runbooks/common/aws_profile_manager.py +341 -0
- runbooks/common/aws_utils.py +75 -80
- runbooks/common/business_logic.py +127 -105
- runbooks/common/cli_decorators.py +36 -60
- runbooks/common/comprehensive_cost_explorer_integration.py +456 -464
- runbooks/common/cross_account_manager.py +198 -205
- runbooks/common/date_utils.py +27 -39
- runbooks/common/decorators.py +235 -0
- runbooks/common/dry_run_examples.py +173 -208
- runbooks/common/dry_run_framework.py +157 -155
- runbooks/common/enhanced_exception_handler.py +15 -4
- runbooks/common/enhanced_logging_example.py +50 -64
- runbooks/common/enhanced_logging_integration_example.py +65 -37
- runbooks/common/env_utils.py +16 -16
- runbooks/common/error_handling.py +40 -38
- runbooks/common/lazy_loader.py +41 -23
- runbooks/common/logging_integration_helper.py +79 -86
- runbooks/common/mcp_cost_explorer_integration.py +478 -495
- runbooks/common/mcp_integration.py +63 -74
- runbooks/common/memory_optimization.py +140 -118
- runbooks/common/module_cli_base.py +37 -58
- runbooks/common/organizations_client.py +176 -194
- runbooks/common/patterns.py +204 -0
- runbooks/common/performance_monitoring.py +67 -71
- runbooks/common/performance_optimization_engine.py +283 -274
- runbooks/common/profile_utils.py +248 -39
- runbooks/common/rich_utils.py +643 -92
- runbooks/common/sre_performance_suite.py +177 -186
- runbooks/enterprise/__init__.py +1 -1
- runbooks/enterprise/logging.py +144 -106
- runbooks/enterprise/security.py +187 -204
- runbooks/enterprise/validation.py +43 -56
- runbooks/finops/__init__.py +29 -33
- runbooks/finops/account_resolver.py +1 -1
- runbooks/finops/advanced_optimization_engine.py +980 -0
- runbooks/finops/automation_core.py +268 -231
- runbooks/finops/business_case_config.py +184 -179
- runbooks/finops/cli.py +660 -139
- runbooks/finops/commvault_ec2_analysis.py +157 -164
- runbooks/finops/compute_cost_optimizer.py +336 -320
- runbooks/finops/config.py +20 -20
- runbooks/finops/cost_optimizer.py +488 -622
- runbooks/finops/cost_processor.py +332 -214
- runbooks/finops/dashboard_runner.py +1006 -172
- runbooks/finops/ebs_cost_optimizer.py +991 -657
- runbooks/finops/elastic_ip_optimizer.py +317 -257
- runbooks/finops/enhanced_mcp_integration.py +340 -0
- runbooks/finops/enhanced_progress.py +40 -37
- runbooks/finops/enhanced_trend_visualization.py +3 -2
- runbooks/finops/enterprise_wrappers.py +230 -292
- runbooks/finops/executive_export.py +203 -160
- runbooks/finops/helpers.py +130 -288
- runbooks/finops/iam_guidance.py +1 -1
- runbooks/finops/infrastructure/__init__.py +80 -0
- runbooks/finops/infrastructure/commands.py +506 -0
- runbooks/finops/infrastructure/load_balancer_optimizer.py +866 -0
- runbooks/finops/infrastructure/vpc_endpoint_optimizer.py +832 -0
- runbooks/finops/markdown_exporter.py +338 -175
- runbooks/finops/mcp_validator.py +1952 -0
- runbooks/finops/nat_gateway_optimizer.py +1513 -482
- runbooks/finops/network_cost_optimizer.py +657 -587
- runbooks/finops/notebook_utils.py +226 -188
- runbooks/finops/optimization_engine.py +1136 -0
- runbooks/finops/optimizer.py +25 -29
- runbooks/finops/rds_snapshot_optimizer.py +367 -411
- runbooks/finops/reservation_optimizer.py +427 -363
- runbooks/finops/scenario_cli_integration.py +77 -78
- runbooks/finops/scenarios.py +1278 -439
- runbooks/finops/schemas.py +218 -182
- runbooks/finops/snapshot_manager.py +2289 -0
- runbooks/finops/tests/test_finops_dashboard.py +3 -3
- runbooks/finops/tests/test_reference_images_validation.py +2 -2
- runbooks/finops/tests/test_single_account_features.py +17 -17
- runbooks/finops/tests/validate_test_suite.py +1 -1
- runbooks/finops/types.py +3 -3
- runbooks/finops/validation_framework.py +263 -269
- runbooks/finops/vpc_cleanup_exporter.py +191 -146
- runbooks/finops/vpc_cleanup_optimizer.py +593 -575
- runbooks/finops/workspaces_analyzer.py +171 -182
- runbooks/hitl/enhanced_workflow_engine.py +1 -1
- runbooks/integration/__init__.py +89 -0
- runbooks/integration/mcp_integration.py +1920 -0
- runbooks/inventory/CLAUDE.md +816 -0
- runbooks/inventory/README.md +3 -3
- runbooks/inventory/Tests/common_test_data.py +30 -30
- runbooks/inventory/__init__.py +2 -2
- runbooks/inventory/cloud_foundations_integration.py +144 -149
- runbooks/inventory/collectors/aws_comprehensive.py +28 -11
- runbooks/inventory/collectors/aws_networking.py +111 -101
- runbooks/inventory/collectors/base.py +4 -0
- runbooks/inventory/core/collector.py +495 -313
- runbooks/inventory/discovery.md +2 -2
- runbooks/inventory/drift_detection_cli.py +69 -96
- runbooks/inventory/find_ec2_security_groups.py +1 -1
- runbooks/inventory/inventory_mcp_cli.py +48 -46
- runbooks/inventory/list_rds_snapshots_aggregator.py +192 -208
- runbooks/inventory/mcp_inventory_validator.py +549 -465
- runbooks/inventory/mcp_vpc_validator.py +359 -442
- runbooks/inventory/organizations_discovery.py +56 -52
- runbooks/inventory/rich_inventory_display.py +33 -32
- runbooks/inventory/unified_validation_engine.py +278 -251
- runbooks/inventory/vpc_analyzer.py +733 -696
- runbooks/inventory/vpc_architecture_validator.py +293 -348
- runbooks/inventory/vpc_dependency_analyzer.py +382 -378
- runbooks/inventory/vpc_flow_analyzer.py +3 -3
- runbooks/main.py +152 -9147
- runbooks/main_final.py +91 -60
- runbooks/main_minimal.py +22 -10
- runbooks/main_optimized.py +131 -100
- runbooks/main_ultra_minimal.py +7 -2
- runbooks/mcp/__init__.py +36 -0
- runbooks/mcp/integration.py +679 -0
- runbooks/metrics/dora_metrics_engine.py +2 -2
- runbooks/monitoring/performance_monitor.py +9 -4
- runbooks/operate/dynamodb_operations.py +3 -1
- runbooks/operate/ec2_operations.py +145 -137
- runbooks/operate/iam_operations.py +146 -152
- runbooks/operate/mcp_integration.py +1 -1
- runbooks/operate/networking_cost_heatmap.py +33 -10
- runbooks/operate/privatelink_operations.py +1 -1
- runbooks/operate/rds_operations.py +223 -254
- runbooks/operate/s3_operations.py +107 -118
- runbooks/operate/vpc_endpoints.py +1 -1
- runbooks/operate/vpc_operations.py +648 -618
- runbooks/remediation/base.py +1 -1
- runbooks/remediation/commons.py +10 -7
- runbooks/remediation/commvault_ec2_analysis.py +71 -67
- runbooks/remediation/ec2_unattached_ebs_volumes.py +1 -0
- runbooks/remediation/multi_account.py +24 -21
- runbooks/remediation/rds_snapshot_list.py +91 -65
- runbooks/remediation/remediation_cli.py +92 -146
- runbooks/remediation/universal_account_discovery.py +83 -79
- runbooks/remediation/workspaces_list.py +49 -44
- runbooks/security/__init__.py +19 -0
- runbooks/security/assessment_runner.py +1150 -0
- runbooks/security/baseline_checker.py +812 -0
- runbooks/security/cloudops_automation_security_validator.py +509 -535
- runbooks/security/compliance_automation_engine.py +17 -17
- runbooks/security/config/__init__.py +2 -2
- runbooks/security/config/compliance_config.py +50 -50
- runbooks/security/config_template_generator.py +63 -76
- runbooks/security/enterprise_security_framework.py +1 -1
- runbooks/security/executive_security_dashboard.py +519 -508
- runbooks/security/integration_test_enterprise_security.py +5 -3
- runbooks/security/multi_account_security_controls.py +959 -1210
- runbooks/security/real_time_security_monitor.py +422 -444
- runbooks/security/run_script.py +1 -1
- runbooks/security/security_baseline_tester.py +1 -1
- runbooks/security/security_cli.py +143 -112
- runbooks/security/test_2way_validation.py +439 -0
- runbooks/security/two_way_validation_framework.py +852 -0
- runbooks/sre/mcp_reliability_engine.py +6 -6
- runbooks/sre/production_monitoring_framework.py +167 -177
- runbooks/tdd/__init__.py +15 -0
- runbooks/tdd/cli.py +1071 -0
- runbooks/utils/__init__.py +14 -17
- runbooks/utils/logger.py +7 -2
- runbooks/utils/version_validator.py +51 -48
- runbooks/validation/__init__.py +6 -6
- runbooks/validation/cli.py +9 -3
- runbooks/validation/comprehensive_2way_validator.py +754 -708
- runbooks/validation/mcp_validator.py +906 -228
- runbooks/validation/terraform_citations_validator.py +104 -115
- runbooks/validation/terraform_drift_detector.py +447 -451
- runbooks/vpc/README.md +617 -0
- runbooks/vpc/__init__.py +8 -1
- runbooks/vpc/analyzer.py +577 -0
- runbooks/vpc/cleanup_wrapper.py +476 -413
- runbooks/vpc/cli_cloudtrail_commands.py +339 -0
- runbooks/vpc/cli_mcp_validation_commands.py +480 -0
- runbooks/vpc/cloudtrail_audit_integration.py +717 -0
- runbooks/vpc/config.py +92 -97
- runbooks/vpc/cost_engine.py +411 -148
- runbooks/vpc/cost_explorer_integration.py +553 -0
- runbooks/vpc/cross_account_session.py +101 -106
- runbooks/vpc/enhanced_mcp_validation.py +917 -0
- runbooks/vpc/eni_gate_validator.py +961 -0
- runbooks/vpc/heatmap_engine.py +190 -162
- runbooks/vpc/mcp_no_eni_validator.py +681 -640
- runbooks/vpc/nat_gateway_optimizer.py +358 -0
- runbooks/vpc/networking_wrapper.py +15 -8
- runbooks/vpc/pdca_remediation_planner.py +528 -0
- runbooks/vpc/performance_optimized_analyzer.py +219 -231
- runbooks/vpc/runbooks_adapter.py +1167 -241
- runbooks/vpc/tdd_red_phase_stubs.py +601 -0
- runbooks/vpc/test_data_loader.py +358 -0
- runbooks/vpc/tests/conftest.py +314 -4
- runbooks/vpc/tests/test_cleanup_framework.py +1022 -0
- runbooks/vpc/tests/test_cost_engine.py +0 -2
- runbooks/vpc/topology_generator.py +326 -0
- runbooks/vpc/unified_scenarios.py +1302 -1129
- runbooks/vpc/vpc_cleanup_integration.py +1943 -1115
- runbooks-1.1.5.dist-info/METADATA +328 -0
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/RECORD +233 -200
- runbooks/finops/README.md +0 -414
- runbooks/finops/accuracy_cross_validator.py +0 -647
- runbooks/finops/business_cases.py +0 -950
- runbooks/finops/dashboard_router.py +0 -922
- runbooks/finops/ebs_optimizer.py +0 -956
- runbooks/finops/embedded_mcp_validator.py +0 -1629
- runbooks/finops/enhanced_dashboard_runner.py +0 -527
- runbooks/finops/finops_dashboard.py +0 -584
- runbooks/finops/finops_scenarios.py +0 -1218
- runbooks/finops/legacy_migration.py +0 -730
- runbooks/finops/multi_dashboard.py +0 -1519
- runbooks/finops/single_dashboard.py +0 -1113
- runbooks/finops/unlimited_scenarios.py +0 -393
- runbooks-1.1.3.dist-info/METADATA +0 -799
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/WHEEL +0 -0
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/entry_points.txt +0 -0
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/licenses/LICENSE +0 -0
- {runbooks-1.1.3.dist-info → runbooks-1.1.5.dist-info}/top_level.txt +0 -0
@@ -28,6 +28,7 @@ NON_ANALYTICAL_SERVICES = ["Tax"] # Services excluded from Top N analysis per u
|
|
28
28
|
_filter_cache: Dict[str, tuple] = {}
|
29
29
|
_filter_session_id: Optional[str] = None
|
30
30
|
|
31
|
+
|
31
32
|
def _get_filter_session_id() -> str:
|
32
33
|
"""Generate filter session ID for cache scoping"""
|
33
34
|
global _filter_session_id
|
@@ -79,7 +80,9 @@ def filter_analytical_services(
|
|
79
80
|
|
80
81
|
# Only log if not already logged in this session
|
81
82
|
if cache_key not in _filter_cache:
|
82
|
-
console.log(
|
83
|
+
console.log(
|
84
|
+
f"[dim yellow]🔍 Filtered {filtered_count} non-analytical services: {', '.join(excluded_names)}[/]"
|
85
|
+
)
|
83
86
|
_filter_cache[cache_key] = (filtered_count, excluded_names)
|
84
87
|
|
85
88
|
return filtered_services
|
@@ -87,29 +90,30 @@ def filter_analytical_services(
|
|
87
90
|
|
88
91
|
class DualMetricCostProcessor:
|
89
92
|
"""Enhanced processor for UnblendedCost (technical) and AmortizedCost (financial) reporting."""
|
90
|
-
|
91
|
-
def __init__(self, session: Session, profile_name: Optional[str] = None):
|
93
|
+
|
94
|
+
def __init__(self, session: Session, profile_name: Optional[str] = None, analysis_mode: str = "comprehensive"):
|
92
95
|
"""Initialize dual-metric cost processor.
|
93
|
-
|
96
|
+
|
94
97
|
Args:
|
95
98
|
session: AWS boto3 session
|
96
99
|
profile_name: AWS profile name for error handling
|
100
|
+
analysis_mode: Analysis mode - "technical" (UnblendedCost), "financial" (AmortizedCost), or "comprehensive" (both)
|
97
101
|
"""
|
98
102
|
self.session = session
|
99
103
|
self.profile_name = profile_name or "default"
|
104
|
+
self.analysis_mode = analysis_mode
|
100
105
|
self.ce = session.client("ce")
|
101
|
-
|
102
|
-
def collect_dual_metrics(
|
103
|
-
|
104
|
-
|
105
|
-
end_date: str = None) -> DualMetricResult:
|
106
|
+
|
107
|
+
def collect_dual_metrics(
|
108
|
+
self, account_id: Optional[str] = None, start_date: str = None, end_date: str = None
|
109
|
+
) -> DualMetricResult:
|
106
110
|
"""Collect both UnblendedCost and AmortizedCost for comprehensive reporting.
|
107
|
-
|
111
|
+
|
108
112
|
Args:
|
109
113
|
account_id: AWS account ID for filtering (multi-account support)
|
110
114
|
start_date: Start date in ISO format (YYYY-MM-DD)
|
111
115
|
end_date: End date in ISO format (YYYY-MM-DD)
|
112
|
-
|
116
|
+
|
113
117
|
Returns:
|
114
118
|
DualMetricResult with both technical and financial perspectives
|
115
119
|
"""
|
@@ -117,39 +121,47 @@ class DualMetricCostProcessor:
|
|
117
121
|
filter_param = None
|
118
122
|
if account_id:
|
119
123
|
filter_param = {"Dimensions": {"Key": "LINKED_ACCOUNT", "Values": [account_id]}}
|
120
|
-
|
124
|
+
|
121
125
|
# Set default dates if not provided
|
122
126
|
if not start_date or not end_date:
|
123
127
|
today = date.today()
|
124
128
|
start_date = today.replace(day=1).isoformat()
|
125
129
|
end_date = (today + timedelta(days=1)).isoformat() # AWS CE end date is exclusive
|
126
|
-
|
130
|
+
|
127
131
|
try:
|
128
|
-
#
|
129
|
-
|
132
|
+
# Inform user about the metric collection based on analysis mode
|
133
|
+
if self.analysis_mode == "technical":
|
134
|
+
console.log("[bright_blue]🔧 Collecting UnblendedCost data (Technical Analysis)[/]")
|
135
|
+
elif self.analysis_mode == "financial":
|
136
|
+
console.log("[bright_green]📊 Collecting AmortizedCost data (Financial Analysis)[/]")
|
137
|
+
else:
|
138
|
+
console.log(
|
139
|
+
"[bright_cyan]💰 Collecting both UnblendedCost and AmortizedCost data (Dual-Metrics Analysis)[/]"
|
140
|
+
)
|
141
|
+
|
142
|
+
# Technical Analysis (UnblendedCost) - always collect for comparison
|
130
143
|
unblended_response = self.ce.get_cost_and_usage(
|
131
|
-
TimePeriod={
|
132
|
-
Granularity=
|
133
|
-
Metrics=[
|
134
|
-
GroupBy=[{
|
135
|
-
**({"Filter": filter_param} if filter_param else {})
|
144
|
+
TimePeriod={"Start": start_date, "End": end_date},
|
145
|
+
Granularity="MONTHLY",
|
146
|
+
Metrics=["UnblendedCost"],
|
147
|
+
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
|
148
|
+
**({"Filter": filter_param} if filter_param else {}),
|
136
149
|
)
|
137
|
-
|
138
|
-
# Financial Reporting (AmortizedCost)
|
139
|
-
console.log("[blue]📊 Collecting financial cost data (AmortizedCost)[/]")
|
150
|
+
|
151
|
+
# Financial Reporting (AmortizedCost) - always collect for comparison
|
140
152
|
amortized_response = self.ce.get_cost_and_usage(
|
141
|
-
TimePeriod={
|
142
|
-
Granularity=
|
143
|
-
Metrics=[
|
144
|
-
GroupBy=[{
|
145
|
-
**({"Filter": filter_param} if filter_param else {})
|
153
|
+
TimePeriod={"Start": start_date, "End": end_date},
|
154
|
+
Granularity="MONTHLY",
|
155
|
+
Metrics=["AmortizedCost"],
|
156
|
+
GroupBy=[{"Type": "DIMENSION", "Key": "SERVICE"}],
|
157
|
+
**({"Filter": filter_param} if filter_param else {}),
|
146
158
|
)
|
147
|
-
|
159
|
+
|
148
160
|
# Parse UnblendedCost data
|
149
161
|
unblended_costs = {}
|
150
162
|
technical_total = 0.0
|
151
163
|
service_breakdown_unblended = []
|
152
|
-
|
164
|
+
|
153
165
|
for result in unblended_response.get("ResultsByTime", []):
|
154
166
|
for group in result.get("Groups", []):
|
155
167
|
service = group["Keys"][0]
|
@@ -158,12 +170,12 @@ class DualMetricCostProcessor:
|
|
158
170
|
unblended_costs[service] = amount
|
159
171
|
technical_total += amount
|
160
172
|
service_breakdown_unblended.append((service, amount))
|
161
|
-
|
173
|
+
|
162
174
|
# Parse AmortizedCost data
|
163
175
|
amortized_costs = {}
|
164
176
|
financial_total = 0.0
|
165
177
|
service_breakdown_amortized = []
|
166
|
-
|
178
|
+
|
167
179
|
for result in amortized_response.get("ResultsByTime", []):
|
168
180
|
for group in result.get("Groups", []):
|
169
181
|
service = group["Keys"][0]
|
@@ -172,17 +184,19 @@ class DualMetricCostProcessor:
|
|
172
184
|
amortized_costs[service] = amount
|
173
185
|
financial_total += amount
|
174
186
|
service_breakdown_amortized.append((service, amount))
|
175
|
-
|
187
|
+
|
176
188
|
# Calculate variance
|
177
189
|
variance = abs(technical_total - financial_total)
|
178
190
|
variance_percentage = (variance / financial_total * 100) if financial_total > 0 else 0.0
|
179
|
-
|
191
|
+
|
180
192
|
# Sort service breakdowns by cost (descending)
|
181
193
|
service_breakdown_unblended.sort(key=lambda x: x[1], reverse=True)
|
182
194
|
service_breakdown_amortized.sort(key=lambda x: x[1], reverse=True)
|
183
|
-
|
184
|
-
console.log(
|
185
|
-
|
195
|
+
|
196
|
+
console.log(
|
197
|
+
f"[green]✅ Dual-metric collection complete: Technical ${technical_total:.2f}, Financial ${financial_total:.2f}[/]"
|
198
|
+
)
|
199
|
+
|
186
200
|
return DualMetricResult(
|
187
201
|
unblended_costs=unblended_costs,
|
188
202
|
amortized_costs=amortized_costs,
|
@@ -193,14 +207,14 @@ class DualMetricCostProcessor:
|
|
193
207
|
period_start=start_date,
|
194
208
|
period_end=end_date,
|
195
209
|
service_breakdown_unblended=service_breakdown_unblended,
|
196
|
-
service_breakdown_amortized=service_breakdown_amortized
|
210
|
+
service_breakdown_amortized=service_breakdown_amortized,
|
197
211
|
)
|
198
|
-
|
212
|
+
|
199
213
|
except Exception as e:
|
200
214
|
console.log(f"[red]❌ Dual-metric collection failed: {str(e)}[/]")
|
201
215
|
if "AccessDeniedException" in str(e) and "ce:GetCostAndUsage" in str(e):
|
202
216
|
handle_cost_explorer_error(e, self.profile_name)
|
203
|
-
|
217
|
+
|
204
218
|
# Return empty result structure
|
205
219
|
return DualMetricResult(
|
206
220
|
unblended_costs={},
|
@@ -212,37 +226,34 @@ class DualMetricCostProcessor:
|
|
212
226
|
period_start=start_date,
|
213
227
|
period_end=end_date,
|
214
228
|
service_breakdown_unblended=[],
|
215
|
-
service_breakdown_amortized=[]
|
229
|
+
service_breakdown_amortized=[],
|
216
230
|
)
|
217
231
|
|
218
232
|
|
219
233
|
def get_equal_period_cost_data(
|
220
|
-
session: Session,
|
221
|
-
profile_name: Optional[str] = None,
|
222
|
-
account_id: Optional[str] = None,
|
223
|
-
months_back: int = 3
|
234
|
+
session: Session, profile_name: Optional[str] = None, account_id: Optional[str] = None, months_back: int = 3
|
224
235
|
) -> Dict[str, Any]:
|
225
236
|
"""
|
226
237
|
Get equal-period cost data for accurate trend analysis.
|
227
|
-
|
228
|
-
Addresses the mathematical error where partial current month (e.g., Sept 1-2)
|
238
|
+
|
239
|
+
Addresses the mathematical error where partial current month (e.g., Sept 1-2)
|
229
240
|
was compared against full previous month (Aug 1-31), resulting in misleading trends.
|
230
|
-
|
241
|
+
|
231
242
|
Args:
|
232
243
|
session: AWS boto3 session
|
233
244
|
profile_name: AWS profile name for error handling
|
234
245
|
account_id: Optional account ID for filtering
|
235
246
|
months_back: Number of complete months to analyze
|
236
|
-
|
247
|
+
|
237
248
|
Returns:
|
238
249
|
Dict containing monthly cost data with equal periods for accurate trends
|
239
250
|
"""
|
240
251
|
ce = session.client("ce")
|
241
252
|
today = date.today()
|
242
|
-
|
253
|
+
|
243
254
|
# Calculate complete months for comparison
|
244
255
|
monthly_data = []
|
245
|
-
|
256
|
+
|
246
257
|
# Get last N complete months (not including current partial month)
|
247
258
|
for i in range(1, months_back + 1): # Start from 1 to skip current month
|
248
259
|
# Calculate the start and end of each complete month
|
@@ -253,25 +264,25 @@ def get_equal_period_cost_data(
|
|
253
264
|
# Handle year boundary
|
254
265
|
target_month = 12 + (today.month - i)
|
255
266
|
target_year = today.year - 1
|
256
|
-
|
267
|
+
|
257
268
|
# First day of target month
|
258
269
|
month_start = date(target_year, target_month, 1)
|
259
|
-
|
270
|
+
|
260
271
|
# Last day of target month
|
261
272
|
if target_month == 12:
|
262
273
|
month_end = date(target_year + 1, 1, 1) - timedelta(days=1)
|
263
274
|
else:
|
264
275
|
month_end = date(target_year, target_month + 1, 1) - timedelta(days=1)
|
265
|
-
|
276
|
+
|
266
277
|
# Build filter for account if provided
|
267
278
|
filter_param = None
|
268
279
|
if account_id:
|
269
280
|
filter_param = {"Dimensions": {"Key": "LINKED_ACCOUNT", "Values": [account_id]}}
|
270
|
-
|
281
|
+
|
271
282
|
kwargs = {}
|
272
283
|
if filter_param:
|
273
284
|
kwargs["Filter"] = filter_param
|
274
|
-
|
285
|
+
|
275
286
|
try:
|
276
287
|
response = ce.get_cost_and_usage(
|
277
288
|
TimePeriod={
|
@@ -282,41 +293,46 @@ def get_equal_period_cost_data(
|
|
282
293
|
Metrics=["UnblendedCost"],
|
283
294
|
**kwargs,
|
284
295
|
)
|
285
|
-
|
296
|
+
|
286
297
|
# Extract cost data
|
287
298
|
total_cost = 0.0
|
288
299
|
for result in response.get("ResultsByTime", []):
|
289
300
|
if "Total" in result and "UnblendedCost" in result["Total"]:
|
290
301
|
total_cost = float(result["Total"]["UnblendedCost"]["Amount"])
|
291
|
-
|
292
|
-
monthly_data.append(
|
293
|
-
|
294
|
-
|
295
|
-
|
296
|
-
|
297
|
-
|
298
|
-
|
299
|
-
|
302
|
+
|
303
|
+
monthly_data.append(
|
304
|
+
{
|
305
|
+
"month": month_start.strftime("%b %Y"),
|
306
|
+
"start_date": month_start.isoformat(),
|
307
|
+
"end_date": month_end.isoformat(),
|
308
|
+
"days": (month_end - month_start).days + 1,
|
309
|
+
"cost": total_cost,
|
310
|
+
}
|
311
|
+
)
|
312
|
+
|
300
313
|
except Exception as e:
|
301
314
|
console.log(f"[yellow]Error getting cost data for {month_start.strftime('%b %Y')}: {e}[/]")
|
302
315
|
if "AccessDeniedException" in str(e) and "ce:GetCostAndUsage" in str(e):
|
303
316
|
from .iam_guidance import handle_cost_explorer_error
|
317
|
+
|
304
318
|
handle_cost_explorer_error(e, profile_name)
|
305
|
-
|
319
|
+
|
306
320
|
# Add empty data to maintain structure
|
307
|
-
monthly_data.append(
|
308
|
-
|
309
|
-
|
310
|
-
|
311
|
-
|
312
|
-
|
313
|
-
|
314
|
-
|
321
|
+
monthly_data.append(
|
322
|
+
{
|
323
|
+
"month": month_start.strftime("%b %Y"),
|
324
|
+
"start_date": month_start.isoformat(),
|
325
|
+
"end_date": month_end.isoformat(),
|
326
|
+
"days": (month_end - month_start).days + 1,
|
327
|
+
"cost": 0.0,
|
328
|
+
}
|
329
|
+
)
|
330
|
+
|
315
331
|
return {
|
316
332
|
"account_id": get_account_id(session) or "unknown",
|
317
333
|
"monthly_costs": monthly_data,
|
318
334
|
"analysis_type": "equal_period",
|
319
|
-
"profile": session.profile_name or profile_name or "default"
|
335
|
+
"profile": session.profile_name or profile_name or "default",
|
320
336
|
}
|
321
337
|
|
322
338
|
|
@@ -568,37 +584,41 @@ def get_cost_data(
|
|
568
584
|
# CRITICAL MATHEMATICAL FIX: Equal period comparisons for accurate trends
|
569
585
|
# Problem: Partial current month vs full previous month = misleading trends
|
570
586
|
# Solution: Same-day comparisons or complete month comparisons
|
571
|
-
|
587
|
+
|
572
588
|
start_date = today.replace(day=1)
|
573
589
|
end_date = today
|
574
|
-
|
590
|
+
|
575
591
|
# Detect if we're dealing with a partial month that could cause misleading trends
|
576
592
|
days_into_month = today.day
|
577
593
|
is_partial_month = days_into_month <= 5 # First 5 days are considered "partial"
|
578
|
-
|
594
|
+
|
579
595
|
if is_partial_month:
|
580
596
|
console.log(f"[yellow]⚠️ Partial month detected ({days_into_month} days into {today.strftime('%B')})[/]")
|
581
|
-
console.log(
|
597
|
+
console.log(
|
598
|
+
f"[dim yellow] Trend calculations may show extreme percentages due to limited current data[/]"
|
599
|
+
)
|
582
600
|
console.log(f"[dim yellow] Consider using full month comparisons for accurate trend analysis[/]")
|
583
|
-
|
601
|
+
|
584
602
|
# Current period: start of month to today (include today with +1 day for AWS CE)
|
585
603
|
end_date = today + timedelta(days=1) # AWS Cost Explorer end date is exclusive
|
586
|
-
|
604
|
+
|
587
605
|
# Previous period: Use same day-of-month from previous month for better comparison
|
588
606
|
# This provides more meaningful trends when current month is partial
|
589
607
|
if is_partial_month and days_into_month > 1:
|
590
608
|
# For partial months, compare same number of days from previous month
|
591
609
|
previous_month_same_day = today.replace(day=1) - timedelta(days=1) # Last day of prev month
|
592
610
|
previous_month_start = previous_month_same_day.replace(day=1)
|
593
|
-
|
611
|
+
|
594
612
|
# Calculate same day of previous month, handling month boundaries
|
595
613
|
try:
|
596
614
|
previous_month_target_day = previous_month_start.replace(day=today.day)
|
597
615
|
previous_period_start = previous_month_start
|
598
616
|
previous_period_end = previous_month_target_day + timedelta(days=1) # Exclusive end
|
599
|
-
|
600
|
-
console.log(
|
601
|
-
|
617
|
+
|
618
|
+
console.log(
|
619
|
+
f"[cyan]📊 Using equal-day comparison: {days_into_month} days from current vs previous month[/]"
|
620
|
+
)
|
621
|
+
|
602
622
|
except ValueError:
|
603
623
|
# Handle cases where previous month doesn't have the same day (e.g., Feb 30)
|
604
624
|
previous_period_end = previous_month_same_day + timedelta(days=1)
|
@@ -608,7 +628,14 @@ def get_cost_data(
|
|
608
628
|
previous_period_end = start_date - timedelta(days=1)
|
609
629
|
previous_period_start = previous_period_end.replace(day=1)
|
610
630
|
|
611
|
-
|
631
|
+
# Get account ID with enhanced error handling for AWS-2 accuracy validation
|
632
|
+
try:
|
633
|
+
account_id = get_account_id(session)
|
634
|
+
if not account_id:
|
635
|
+
account_id = "unknown"
|
636
|
+
except Exception as account_error:
|
637
|
+
console.print(f"[yellow]Warning: Could not retrieve account ID: {account_error}[/yellow]")
|
638
|
+
account_id = "unknown"
|
612
639
|
|
613
640
|
try:
|
614
641
|
this_period = ce.get_cost_and_usage(
|
@@ -707,7 +734,7 @@ def get_cost_data(
|
|
707
734
|
previous_period_days = (previous_period_end - previous_period_start).days
|
708
735
|
days_difference = abs(current_period_days - previous_period_days)
|
709
736
|
is_partial_comparison = days_difference > 5
|
710
|
-
|
737
|
+
|
711
738
|
# ENHANCED RELIABILITY ASSESSMENT: Consider MCP validation success in trend reliability
|
712
739
|
trend_reliability = "high"
|
713
740
|
if is_partial_comparison:
|
@@ -718,17 +745,24 @@ def get_cost_data(
|
|
718
745
|
else:
|
719
746
|
# Moderate difference - reliability depends on validation accuracy
|
720
747
|
trend_reliability = "medium_with_validation_support"
|
721
|
-
|
748
|
+
|
722
749
|
# Enhanced period information for trend analysis
|
750
|
+
# Calculate is_partial_month for metadata (AWS-2 accuracy enhancement)
|
751
|
+
today = date.today()
|
752
|
+
days_into_month = today.day
|
753
|
+
is_partial_month = days_into_month <= 5 # First 5 days are considered "partial"
|
754
|
+
|
723
755
|
period_metadata = {
|
724
756
|
"current_days": current_period_days,
|
725
757
|
"previous_days": previous_period_days,
|
726
758
|
"days_difference": days_difference,
|
727
759
|
"is_partial_comparison": is_partial_comparison,
|
728
|
-
"comparison_type": "equal_day_comparison" if
|
760
|
+
"comparison_type": "equal_day_comparison" if is_partial_comparison else "standard_month_comparison",
|
729
761
|
"trend_reliability": trend_reliability,
|
730
|
-
"period_alignment_strategy": "equal_days"
|
731
|
-
|
762
|
+
"period_alignment_strategy": "equal_days"
|
763
|
+
if is_partial_comparison and days_into_month > 1
|
764
|
+
else "standard_monthly",
|
765
|
+
"supports_mcp_validation": True, # This data structure supports MCP cross-validation
|
732
766
|
}
|
733
767
|
|
734
768
|
return {
|
@@ -757,48 +791,48 @@ def get_quarterly_cost_data(
|
|
757
791
|
) -> Dict[str, float]:
|
758
792
|
"""
|
759
793
|
Get quarterly cost data for enhanced FinOps trend analysis.
|
760
|
-
|
794
|
+
|
761
795
|
Retrieves cost data for the last complete quarter (3 months) to provide
|
762
796
|
strategic quarterly context for financial planning and trend analysis.
|
763
|
-
|
797
|
+
|
764
798
|
Args:
|
765
799
|
session: The boto3 session to use
|
766
800
|
profile_name: Optional AWS profile name for enhanced error messaging
|
767
801
|
account_id: Optional account ID to filter costs to specific account
|
768
|
-
|
802
|
+
|
769
803
|
Returns:
|
770
804
|
Dictionary with service names as keys and quarterly costs as values
|
771
805
|
"""
|
772
806
|
ce = session.client("ce")
|
773
807
|
today = date.today()
|
774
|
-
|
808
|
+
|
775
809
|
# Calculate last quarter date range
|
776
810
|
# Go back 3 months for quarterly analysis
|
777
811
|
quarterly_end_date = today.replace(day=1) - timedelta(days=1) # Last day of previous month
|
778
812
|
quarterly_start_date = (quarterly_end_date.replace(day=1) - timedelta(days=90)).replace(day=1)
|
779
|
-
|
813
|
+
|
780
814
|
# Build filters for quarterly analysis
|
781
815
|
filters = []
|
782
816
|
if account_id:
|
783
817
|
account_filter = {"Dimensions": {"Key": "LINKED_ACCOUNT", "Values": [account_id]}}
|
784
818
|
filters.append(account_filter)
|
785
|
-
|
819
|
+
|
786
820
|
# Combine filters if needed
|
787
821
|
filter_param: Optional[Dict[str, Any]] = None
|
788
822
|
if len(filters) == 1:
|
789
823
|
filter_param = filters[0]
|
790
824
|
elif len(filters) > 1:
|
791
825
|
filter_param = {"And": filters}
|
792
|
-
|
826
|
+
|
793
827
|
kwargs = {}
|
794
828
|
if filter_param:
|
795
829
|
kwargs["Filter"] = filter_param
|
796
|
-
|
830
|
+
|
797
831
|
try:
|
798
832
|
quarterly_period_cost_by_service = ce.get_cost_and_usage(
|
799
833
|
TimePeriod={
|
800
|
-
"Start": quarterly_start_date.isoformat(),
|
801
|
-
"End": (quarterly_end_date + timedelta(days=1)).isoformat() # Exclusive end
|
834
|
+
"Start": quarterly_start_date.isoformat(),
|
835
|
+
"End": (quarterly_end_date + timedelta(days=1)).isoformat(), # Exclusive end
|
802
836
|
},
|
803
837
|
Granularity="MONTHLY",
|
804
838
|
Metrics=["UnblendedCost"],
|
@@ -810,22 +844,22 @@ def get_quarterly_cost_data(
|
|
810
844
|
if "AccessDeniedException" in str(e) and "ce:GetCostAndUsage" in str(e):
|
811
845
|
handle_cost_explorer_error(e, profile_name)
|
812
846
|
return {}
|
813
|
-
|
847
|
+
|
814
848
|
# Aggregate quarterly costs by service across the 3-month period
|
815
849
|
quarterly_service_costs: Dict[str, float] = defaultdict(float)
|
816
|
-
|
850
|
+
|
817
851
|
for result in quarterly_period_cost_by_service.get("ResultsByTime", []):
|
818
852
|
for group in result.get("Groups", []):
|
819
853
|
service = group["Keys"][0]
|
820
854
|
amount = float(group["Metrics"]["UnblendedCost"]["Amount"])
|
821
855
|
quarterly_service_costs[service] += amount
|
822
|
-
|
856
|
+
|
823
857
|
# Filter out negligible costs and convert to regular dict
|
824
858
|
filtered_quarterly_costs = {}
|
825
859
|
for service, amount in quarterly_service_costs.items():
|
826
860
|
if amount > 0.001: # Filter out negligible costs
|
827
861
|
filtered_quarterly_costs[service] = amount
|
828
|
-
|
862
|
+
|
829
863
|
console.log(f"[cyan]📊 Retrieved quarterly cost data for {len(filtered_quarterly_costs)} services[/]")
|
830
864
|
return filtered_quarterly_costs
|
831
865
|
|
@@ -856,15 +890,15 @@ def process_service_costs(
|
|
856
890
|
|
857
891
|
|
858
892
|
def format_budget_info(budgets: List[BudgetInfo]) -> List[str]:
|
859
|
-
"""Format budget information for display with enhanced error handling."""
|
893
|
+
"""Format budget information for display with enhanced error handling and concise icons."""
|
860
894
|
budget_info: List[str] = []
|
861
|
-
|
895
|
+
|
862
896
|
# Check if this is an access denied case (common with read-only profiles)
|
863
897
|
if budgets and len(budgets) == 1:
|
864
898
|
first_budget = budgets[0]
|
865
899
|
if isinstance(first_budget, dict):
|
866
900
|
# Check for access denied pattern
|
867
|
-
if first_budget.get(
|
901
|
+
if first_budget.get("name", "").lower() in ["access denied", "permission denied", "n/a"]:
|
868
902
|
budget_info.append("ℹ️ Budget data unavailable")
|
869
903
|
budget_info.append("(Read-only profile)")
|
870
904
|
budget_info.append("")
|
@@ -872,13 +906,39 @@ def format_budget_info(budgets: List[BudgetInfo]) -> List[str]:
|
|
872
906
|
budget_info.append("Add budgets:ViewBudget")
|
873
907
|
budget_info.append("policy to profile")
|
874
908
|
return budget_info
|
875
|
-
|
876
|
-
#
|
909
|
+
|
910
|
+
# Enhanced budget formatting with concise icons and status
|
877
911
|
for budget in budgets:
|
878
|
-
|
879
|
-
|
880
|
-
|
881
|
-
|
912
|
+
# Calculate budget utilization for status determination
|
913
|
+
utilization = (budget["actual"] / budget["limit"]) * 100 if budget["limit"] > 0 else 0
|
914
|
+
|
915
|
+
# Determine status icon and color based on utilization
|
916
|
+
if utilization >= 100:
|
917
|
+
status_icon = "🚨" # Over budget - critical
|
918
|
+
status_color = "bright_red"
|
919
|
+
elif utilization >= 85:
|
920
|
+
status_icon = "⚠️" # Near limit - warning
|
921
|
+
status_color = "orange1"
|
922
|
+
elif utilization >= 70:
|
923
|
+
status_icon = "🟡" # Moderate usage - caution
|
924
|
+
status_color = "yellow"
|
925
|
+
else:
|
926
|
+
status_icon = "✅" # Under budget - good
|
927
|
+
status_color = "green"
|
928
|
+
|
929
|
+
# Format budget name (shortened for display)
|
930
|
+
display_name = budget["name"].replace(" Budget", "").replace("Budget", "").strip()
|
931
|
+
if len(display_name) > 15:
|
932
|
+
display_name = display_name[:12] + "..."
|
933
|
+
|
934
|
+
# Concise budget display with icons
|
935
|
+
budget_info.append(f"{status_icon} [{status_color}]{display_name}[/]")
|
936
|
+
budget_info.append(f"💰 ${budget['actual']:.0f}/${budget['limit']:.0f} ({utilization:.0f}%)")
|
937
|
+
|
938
|
+
# Add forecast only if significantly different from actual
|
939
|
+
if budget["forecast"] is not None and abs(budget["forecast"] - budget["actual"]) > (budget["actual"] * 0.1):
|
940
|
+
trend_icon = "📈" if budget["forecast"] > budget["actual"] else "📉"
|
941
|
+
budget_info.append(f"{trend_icon} Est: ${budget['forecast']:.0f}")
|
882
942
|
|
883
943
|
if not budget_info:
|
884
944
|
budget_info.append("ℹ️ No budgets configured")
|
@@ -893,7 +953,7 @@ def calculate_quarterly_enhanced_trend(
|
|
893
953
|
previous: float,
|
894
954
|
quarterly: float,
|
895
955
|
current_days: Optional[int] = None,
|
896
|
-
previous_days: Optional[int] = None
|
956
|
+
previous_days: Optional[int] = None,
|
897
957
|
) -> str:
|
898
958
|
"""
|
899
959
|
Calculate trend with quarterly financial intelligence for strategic decision making.
|
@@ -963,11 +1023,11 @@ def calculate_quarterly_enhanced_trend(
|
|
963
1023
|
def format_cost_with_precision(amount: float, context: str = "dashboard") -> str:
|
964
1024
|
"""
|
965
1025
|
Format cost with context-aware precision for consistent display.
|
966
|
-
|
1026
|
+
|
967
1027
|
Args:
|
968
1028
|
amount: Cost amount to format
|
969
1029
|
context: Display context ('executive', 'detailed', 'dashboard')
|
970
|
-
|
1030
|
+
|
971
1031
|
Returns:
|
972
1032
|
Formatted cost string with appropriate precision
|
973
1033
|
"""
|
@@ -982,22 +1042,22 @@ def format_cost_with_precision(amount: float, context: str = "dashboard") -> str
|
|
982
1042
|
return f"${amount:,.2f}"
|
983
1043
|
|
984
1044
|
|
985
|
-
def calculate_trend_with_context(
|
986
|
-
|
987
|
-
|
1045
|
+
def calculate_trend_with_context(
|
1046
|
+
current: float, previous: float, current_days: Optional[int] = None, previous_days: Optional[int] = None
|
1047
|
+
) -> str:
|
988
1048
|
"""
|
989
1049
|
Calculate trend with statistical context and confidence, handling partial period comparisons.
|
990
|
-
|
991
|
-
CRITICAL MATHEMATICAL FIX: Addresses the business-critical issue where partial current month
|
1050
|
+
|
1051
|
+
CRITICAL MATHEMATICAL FIX: Addresses the business-critical issue where partial current month
|
992
1052
|
(e.g., September 1-2: $2.50) was compared against full previous month (August 1-31: $155.00),
|
993
1053
|
resulting in misleading -98.4% trend calculations that could cause incorrect business decisions.
|
994
|
-
|
1054
|
+
|
995
1055
|
Args:
|
996
1056
|
current: Current period cost
|
997
1057
|
previous: Previous period cost
|
998
1058
|
current_days: Number of days in current period (for partial period detection)
|
999
1059
|
previous_days: Number of days in previous period (for partial period detection)
|
1000
|
-
|
1060
|
+
|
1001
1061
|
Returns:
|
1002
1062
|
Trend string with appropriate context and partial period warnings
|
1003
1063
|
"""
|
@@ -1006,28 +1066,71 @@ def calculate_trend_with_context(current: float, previous: float,
|
|
1006
1066
|
return "No change (both periods $0)"
|
1007
1067
|
else:
|
1008
1068
|
return "New spend (no historical data)"
|
1009
|
-
|
1010
|
-
# Detect partial period issues
|
1069
|
+
|
1070
|
+
# Detect partial period issues and apply smart normalization
|
1011
1071
|
partial_period_issue = False
|
1072
|
+
normalized_change_percent = None
|
1073
|
+
normalization_applied = False
|
1074
|
+
|
1012
1075
|
if current_days and previous_days:
|
1013
1076
|
if abs(current_days - previous_days) > 5: # More than 5 days difference
|
1014
1077
|
partial_period_issue = True
|
1015
|
-
|
1016
|
-
|
1017
|
-
|
1078
|
+
|
1079
|
+
# Apply smart normalization for partial month comparisons
|
1080
|
+
if current_days < previous_days:
|
1081
|
+
# Current month is partial, previous is full - normalize previous month
|
1082
|
+
normalization_factor = current_days / previous_days
|
1083
|
+
adjusted_previous = previous * normalization_factor
|
1084
|
+
if adjusted_previous > 0:
|
1085
|
+
normalized_change_percent = ((current - adjusted_previous) / adjusted_previous) * 100
|
1086
|
+
normalization_applied = True
|
1087
|
+
from ..common.rich_utils import console
|
1088
|
+
|
1089
|
+
console.log(
|
1090
|
+
f"[dim yellow]📊 Trend normalization: partial current ({current_days} days) vs full previous ({previous_days} days)[/]"
|
1091
|
+
)
|
1092
|
+
console.log(
|
1093
|
+
f"[dim yellow] Adjusted comparison: ${current:.2f} vs ${adjusted_previous:.2f} (factor: {normalization_factor:.2f})[/]"
|
1094
|
+
)
|
1095
|
+
|
1096
|
+
elif current_days > previous_days:
|
1097
|
+
# Previous month is partial, current is full - normalize current month
|
1098
|
+
normalization_factor = previous_days / current_days
|
1099
|
+
adjusted_current = current * normalization_factor
|
1100
|
+
if previous > 0:
|
1101
|
+
normalized_change_percent = ((adjusted_current - previous) / previous) * 100
|
1102
|
+
normalization_applied = True
|
1103
|
+
from ..common.rich_utils import console
|
1104
|
+
|
1105
|
+
console.log(
|
1106
|
+
f"[dim yellow]📊 Trend normalization: full current ({current_days} days) vs partial previous ({previous_days} days)[/]"
|
1107
|
+
)
|
1108
|
+
console.log(
|
1109
|
+
f"[dim yellow] Adjusted comparison: ${adjusted_current:.2f} vs ${previous:.2f} (factor: {normalization_factor:.2f})[/]"
|
1110
|
+
)
|
1111
|
+
|
1112
|
+
# Use normalized change if available, otherwise calculate basic percentage change
|
1113
|
+
if normalization_applied and normalized_change_percent is not None:
|
1114
|
+
change_percent = normalized_change_percent
|
1115
|
+
# Add indicator that normalization was applied
|
1116
|
+
normalization_indicator = " 📏"
|
1117
|
+
else:
|
1118
|
+
change_percent = ((current - previous) / previous) * 100
|
1119
|
+
normalization_indicator = ""
|
1018
1120
|
|
1019
1121
|
# FIXED: Show meaningful percentage trends instead of generic messages
|
1020
1122
|
if abs(change_percent) < 0.01: # Less than 0.01%
|
1021
1123
|
if current == previous:
|
1022
|
-
return "→ 0.0%" # Show actual zero change percentage
|
1124
|
+
return f"→ 0.0%{normalization_indicator}" # Show actual zero change percentage
|
1023
1125
|
elif abs(current - previous) < 0.01: # Very small absolute difference
|
1024
|
-
return "→ <0.1%" # Show near-zero change with percentage
|
1126
|
+
return f"→ <0.1%{normalization_indicator}" # Show near-zero change with percentage
|
1025
1127
|
else:
|
1026
1128
|
# Show actual small change with precise percentage
|
1027
|
-
return f"{'↑' if change_percent > 0 else '↓'} {abs(change_percent):.2f}%"
|
1129
|
+
return f"{'↑' if change_percent > 0 else '↓'} {abs(change_percent):.2f}%{normalization_indicator}"
|
1028
1130
|
|
1029
1131
|
# Handle partial period comparisons with clean display
|
1030
|
-
if partial_period_issue:
|
1132
|
+
if partial_period_issue and not normalization_applied:
|
1133
|
+
# Only show warnings if normalization wasn't applied (fallback case)
|
1031
1134
|
if abs(change_percent) > 50:
|
1032
1135
|
return "⚠️ Trend not reliable (partial data)"
|
1033
1136
|
else:
|
@@ -1037,48 +1140,48 @@ def calculate_trend_with_context(current: float, previous: float,
|
|
1037
1140
|
# Standard trend analysis for equal periods
|
1038
1141
|
if abs(change_percent) > 90:
|
1039
1142
|
if change_percent > 0:
|
1040
|
-
return f"↑ {change_percent:.1f}% (significant increase - verify)"
|
1143
|
+
return f"↑ {change_percent:.1f}% (significant increase - verify){normalization_indicator}"
|
1041
1144
|
else:
|
1042
|
-
return f"↓ {abs(change_percent):.1f}% (significant decrease - verify)"
|
1145
|
+
return f"↓ {abs(change_percent):.1f}% (significant decrease - verify){normalization_indicator}"
|
1043
1146
|
elif abs(change_percent) < 1:
|
1044
|
-
return "→ Stable (< 1% change)"
|
1147
|
+
return f"→ Stable (< 1% change){normalization_indicator}"
|
1045
1148
|
else:
|
1046
1149
|
if change_percent > 0:
|
1047
|
-
return f"↑ {change_percent:.1f}%"
|
1150
|
+
return f"↑ {change_percent:.1f}%{normalization_indicator}"
|
1048
1151
|
else:
|
1049
|
-
return f"↓ {abs(change_percent):.1f}%"
|
1152
|
+
return f"↓ {abs(change_percent):.1f}%{normalization_indicator}"
|
1050
1153
|
|
1051
1154
|
|
1052
1155
|
def format_ec2_summary(ec2_data: EC2Summary) -> List[str]:
|
1053
1156
|
"""Format EC2 instance summary with enhanced visual hierarchy."""
|
1054
1157
|
ec2_summary_text: List[str] = []
|
1055
|
-
|
1158
|
+
|
1056
1159
|
# Enhanced state formatting with icons and context
|
1057
1160
|
state_config = {
|
1058
1161
|
"running": {"color": "bright_green", "icon": "🟢", "priority": 1},
|
1059
1162
|
"stopped": {"color": "bright_yellow", "icon": "🟡", "priority": 2},
|
1060
1163
|
"terminated": {"color": "dim red", "icon": "🔴", "priority": 4},
|
1061
1164
|
"pending": {"color": "bright_cyan", "icon": "🔵", "priority": 3},
|
1062
|
-
"stopping": {"color": "yellow", "icon": "🟠", "priority": 3}
|
1165
|
+
"stopping": {"color": "yellow", "icon": "🟠", "priority": 3},
|
1063
1166
|
}
|
1064
|
-
|
1167
|
+
|
1065
1168
|
# Sort by priority and then by state name
|
1066
1169
|
sorted_states = sorted(
|
1067
1170
|
[(state, count) for state, count in ec2_data.items() if count > 0],
|
1068
|
-
key=lambda x: (state_config.get(x[0], {"priority": 99})["priority"], x[0])
|
1171
|
+
key=lambda x: (state_config.get(x[0], {"priority": 99})["priority"], x[0]),
|
1069
1172
|
)
|
1070
|
-
|
1173
|
+
|
1071
1174
|
total_instances = sum(count for _, count in sorted_states)
|
1072
|
-
|
1175
|
+
|
1073
1176
|
if sorted_states:
|
1074
1177
|
# Header with total count
|
1075
1178
|
ec2_summary_text.append(f"[bright_cyan]📊 EC2 Instances ({total_instances} total)[/bright_cyan]")
|
1076
|
-
|
1179
|
+
|
1077
1180
|
# Individual states with enhanced styling
|
1078
1181
|
for state, count in sorted_states:
|
1079
1182
|
config = state_config.get(state, {"color": "white", "icon": "⚪", "priority": 99})
|
1080
1183
|
percentage = (count / total_instances * 100) if total_instances > 0 else 0
|
1081
|
-
|
1184
|
+
|
1082
1185
|
ec2_summary_text.append(
|
1083
1186
|
f" {config['icon']} [{config['color']}]{state.title()}: {count}[/{config['color']}] "
|
1084
1187
|
f"[dim]({percentage:.1f}%)[/dim]"
|
@@ -1130,25 +1233,29 @@ def export_to_csv(
|
|
1130
1233
|
previous_period_header,
|
1131
1234
|
current_period_header,
|
1132
1235
|
]
|
1133
|
-
|
1236
|
+
|
1134
1237
|
# Add dual-metric columns if requested
|
1135
1238
|
if include_dual_metrics:
|
1136
|
-
fieldnames.extend(
|
1137
|
-
|
1138
|
-
|
1139
|
-
|
1140
|
-
|
1141
|
-
|
1142
|
-
|
1143
|
-
|
1239
|
+
fieldnames.extend(
|
1240
|
+
[
|
1241
|
+
f"AmortizedCost {current_period_header}",
|
1242
|
+
f"AmortizedCost {previous_period_header}",
|
1243
|
+
"Metric Variance ($)",
|
1244
|
+
"Metric Variance (%)",
|
1245
|
+
"Cost By Service (UnblendedCost)",
|
1246
|
+
"Cost By Service (AmortizedCost)",
|
1247
|
+
]
|
1248
|
+
)
|
1144
1249
|
else:
|
1145
1250
|
fieldnames.append("Cost By Service")
|
1146
|
-
|
1147
|
-
fieldnames.extend(
|
1148
|
-
|
1149
|
-
|
1150
|
-
|
1151
|
-
|
1251
|
+
|
1252
|
+
fieldnames.extend(
|
1253
|
+
[
|
1254
|
+
"Budget Status",
|
1255
|
+
"EC2 Instances",
|
1256
|
+
]
|
1257
|
+
)
|
1258
|
+
|
1152
1259
|
writer = csv.DictWriter(csvfile, fieldnames=fieldnames)
|
1153
1260
|
writer.writeheader()
|
1154
1261
|
for row in data:
|
@@ -1196,7 +1303,7 @@ def export_to_csv(
|
|
1196
1303
|
previous_period_header: row.get("previous_month_formatted", "N/A"),
|
1197
1304
|
current_period_header: row.get("current_month_formatted", "N/A"),
|
1198
1305
|
}
|
1199
|
-
|
1306
|
+
|
1200
1307
|
# Add dual-metric data if requested
|
1201
1308
|
if include_dual_metrics:
|
1202
1309
|
# Calculate variance for dual-metric display
|
@@ -1205,32 +1312,35 @@ def export_to_csv(
|
|
1205
1312
|
previous_amortized = row.get("previous_month_amortized", row.get("previous_month", 0))
|
1206
1313
|
variance = abs(current_unblended - current_amortized)
|
1207
1314
|
variance_pct = (variance / current_amortized * 100) if current_amortized > 0 else 0
|
1208
|
-
|
1315
|
+
|
1209
1316
|
# Format amortized service costs
|
1210
1317
|
amortized_services_data = "No amortized service costs"
|
1211
1318
|
if row.get("service_costs_amortized"):
|
1212
|
-
amortized_services_data = "\n".join(
|
1213
|
-
f"{service}: ${cost:.2f}"
|
1214
|
-
|
1215
|
-
|
1216
|
-
|
1217
|
-
|
1218
|
-
|
1219
|
-
|
1220
|
-
|
1221
|
-
|
1222
|
-
|
1223
|
-
|
1224
|
-
|
1319
|
+
amortized_services_data = "\n".join(
|
1320
|
+
[f"{service}: ${cost:.2f}" for service, cost in row["service_costs_amortized"]]
|
1321
|
+
)
|
1322
|
+
|
1323
|
+
row_data.update(
|
1324
|
+
{
|
1325
|
+
f"AmortizedCost {current_period_header}": f"${current_amortized:.2f}",
|
1326
|
+
f"AmortizedCost {previous_period_header}": f"${previous_amortized:.2f}",
|
1327
|
+
"Metric Variance ($)": f"${variance:.2f}",
|
1328
|
+
"Metric Variance (%)": f"{variance_pct:.2f}%",
|
1329
|
+
"Cost By Service (UnblendedCost)": services_data or "No costs",
|
1330
|
+
"Cost By Service (AmortizedCost)": amortized_services_data,
|
1331
|
+
}
|
1332
|
+
)
|
1225
1333
|
else:
|
1226
1334
|
row_data["Cost By Service"] = services_data or "No costs"
|
1227
|
-
|
1335
|
+
|
1228
1336
|
# Add common fields
|
1229
|
-
row_data.update(
|
1230
|
-
|
1231
|
-
|
1232
|
-
|
1233
|
-
|
1337
|
+
row_data.update(
|
1338
|
+
{
|
1339
|
+
"Budget Status": budgets_data or "No budgets",
|
1340
|
+
"EC2 Instances": ec2_data_summary or "No instances",
|
1341
|
+
}
|
1342
|
+
)
|
1343
|
+
|
1234
1344
|
writer.writerow(row_data)
|
1235
1345
|
except (KeyError, TypeError) as e:
|
1236
1346
|
console.print(f"[yellow]Warning: Could not write CSV row: {e}[/]")
|
@@ -1238,7 +1348,7 @@ def export_to_csv(
|
|
1238
1348
|
writer.writerow(
|
1239
1349
|
{
|
1240
1350
|
"CLI Profile": "Error",
|
1241
|
-
"AWS Account ID": "Error",
|
1351
|
+
"AWS Account ID": "Error",
|
1242
1352
|
previous_period_header: "Error",
|
1243
1353
|
current_period_header: "Error",
|
1244
1354
|
"Cost By Service": f"Row processing error: {e}",
|
@@ -1253,7 +1363,9 @@ def export_to_csv(
|
|
1253
1363
|
return None
|
1254
1364
|
|
1255
1365
|
|
1256
|
-
def export_to_json(
|
1366
|
+
def export_to_json(
|
1367
|
+
data: List[ProfileData], filename: str, output_dir: Optional[str] = None, include_dual_metrics: bool = False
|
1368
|
+
) -> Optional[str]:
|
1257
1369
|
"""Export dashboard data to a JSON file."""
|
1258
1370
|
try:
|
1259
1371
|
timestamp = datetime.now().strftime("%Y%m%d_%H%M")
|
@@ -1271,47 +1383,53 @@ def export_to_json(data: List[ProfileData], filename: str, output_dir: Optional[
|
|
1271
1383
|
if include_dual_metrics:
|
1272
1384
|
# Enhanced data structure for dual metrics
|
1273
1385
|
enhanced_item = dict(item) # Copy base data
|
1274
|
-
|
1386
|
+
|
1275
1387
|
# Calculate variance metrics
|
1276
1388
|
current_unblended = item.get("current_month", 0)
|
1277
1389
|
current_amortized = item.get("current_month_amortized", current_unblended)
|
1278
1390
|
variance = abs(current_unblended - current_amortized)
|
1279
1391
|
variance_pct = (variance / current_amortized * 100) if current_amortized > 0 else 0
|
1280
|
-
|
1392
|
+
|
1281
1393
|
# Add dual-metric metadata
|
1282
|
-
enhanced_item.update(
|
1283
|
-
|
1284
|
-
"
|
1285
|
-
"
|
1286
|
-
|
1287
|
-
|
1288
|
-
|
1394
|
+
enhanced_item.update(
|
1395
|
+
{
|
1396
|
+
"dual_metric_analysis": {
|
1397
|
+
"unblended_cost": {
|
1398
|
+
"current": current_unblended,
|
1399
|
+
"previous": item.get("previous_month", 0),
|
1400
|
+
"metric_type": "technical",
|
1401
|
+
"description": "UnblendedCost - for DevOps/SRE teams",
|
1402
|
+
},
|
1403
|
+
"amortized_cost": {
|
1404
|
+
"current": current_amortized,
|
1405
|
+
"previous": item.get("previous_month_amortized", item.get("previous_month", 0)),
|
1406
|
+
"metric_type": "financial",
|
1407
|
+
"description": "AmortizedCost - for Finance/Executive teams",
|
1408
|
+
},
|
1409
|
+
"variance_analysis": {
|
1410
|
+
"absolute_variance": variance,
|
1411
|
+
"percentage_variance": variance_pct,
|
1412
|
+
"variance_level": "low"
|
1413
|
+
if variance_pct < 1.0
|
1414
|
+
else "moderate"
|
1415
|
+
if variance_pct < 5.0
|
1416
|
+
else "high",
|
1417
|
+
},
|
1289
1418
|
},
|
1290
|
-
"
|
1291
|
-
"
|
1292
|
-
"
|
1293
|
-
"
|
1294
|
-
|
1419
|
+
"export_metadata": {
|
1420
|
+
"export_type": "dual_metric",
|
1421
|
+
"export_timestamp": datetime.now().isoformat(),
|
1422
|
+
"metric_explanation": {
|
1423
|
+
"unblended_cost": "Actual costs without Reserved Instance or Savings Plan allocations",
|
1424
|
+
"amortized_cost": "Costs with Reserved Instance and Savings Plan benefits applied",
|
1425
|
+
},
|
1295
1426
|
},
|
1296
|
-
"variance_analysis": {
|
1297
|
-
"absolute_variance": variance,
|
1298
|
-
"percentage_variance": variance_pct,
|
1299
|
-
"variance_level": "low" if variance_pct < 1.0 else "moderate" if variance_pct < 5.0 else "high"
|
1300
|
-
}
|
1301
|
-
},
|
1302
|
-
"export_metadata": {
|
1303
|
-
"export_type": "dual_metric",
|
1304
|
-
"export_timestamp": datetime.now().isoformat(),
|
1305
|
-
"metric_explanation": {
|
1306
|
-
"unblended_cost": "Actual costs without Reserved Instance or Savings Plan allocations",
|
1307
|
-
"amortized_cost": "Costs with Reserved Instance and Savings Plan benefits applied"
|
1308
|
-
}
|
1309
1427
|
}
|
1310
|
-
|
1428
|
+
)
|
1311
1429
|
export_data.append(enhanced_item)
|
1312
1430
|
else:
|
1313
1431
|
export_data.append(item)
|
1314
|
-
|
1432
|
+
|
1315
1433
|
with open(output_filename, "w") as jsonfile:
|
1316
1434
|
json.dump(export_data, jsonfile, indent=4)
|
1317
1435
|
|