runbooks 0.9.1__py3-none-any.whl → 0.9.4__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.
Files changed (47) hide show
  1. runbooks/__init__.py +15 -6
  2. runbooks/cfat/__init__.py +3 -1
  3. runbooks/cloudops/__init__.py +3 -1
  4. runbooks/common/aws_utils.py +367 -0
  5. runbooks/common/enhanced_logging_example.py +239 -0
  6. runbooks/common/enhanced_logging_integration_example.py +257 -0
  7. runbooks/common/logging_integration_helper.py +344 -0
  8. runbooks/common/profile_utils.py +8 -6
  9. runbooks/common/rich_utils.py +347 -3
  10. runbooks/enterprise/logging.py +400 -38
  11. runbooks/finops/README.md +262 -406
  12. runbooks/finops/__init__.py +2 -1
  13. runbooks/finops/accuracy_cross_validator.py +12 -3
  14. runbooks/finops/commvault_ec2_analysis.py +415 -0
  15. runbooks/finops/cost_processor.py +718 -42
  16. runbooks/finops/dashboard_router.py +44 -22
  17. runbooks/finops/dashboard_runner.py +302 -39
  18. runbooks/finops/embedded_mcp_validator.py +358 -48
  19. runbooks/finops/finops_scenarios.py +771 -0
  20. runbooks/finops/multi_dashboard.py +30 -15
  21. runbooks/finops/single_dashboard.py +386 -58
  22. runbooks/finops/types.py +29 -4
  23. runbooks/inventory/__init__.py +2 -1
  24. runbooks/main.py +522 -29
  25. runbooks/operate/__init__.py +3 -1
  26. runbooks/remediation/__init__.py +3 -1
  27. runbooks/remediation/commons.py +55 -16
  28. runbooks/remediation/commvault_ec2_analysis.py +259 -0
  29. runbooks/remediation/rds_snapshot_list.py +267 -102
  30. runbooks/remediation/workspaces_list.py +182 -31
  31. runbooks/security/__init__.py +3 -1
  32. runbooks/sre/__init__.py +2 -1
  33. runbooks/utils/__init__.py +81 -6
  34. runbooks/utils/version_validator.py +241 -0
  35. runbooks/vpc/__init__.py +2 -1
  36. runbooks-0.9.4.dist-info/METADATA +563 -0
  37. {runbooks-0.9.1.dist-info → runbooks-0.9.4.dist-info}/RECORD +41 -38
  38. {runbooks-0.9.1.dist-info → runbooks-0.9.4.dist-info}/entry_points.txt +1 -0
  39. runbooks/inventory/cloudtrail.md +0 -727
  40. runbooks/inventory/discovery.md +0 -81
  41. runbooks/remediation/CLAUDE.md +0 -100
  42. runbooks/remediation/DOME9.md +0 -218
  43. runbooks/security/ENTERPRISE_SECURITY_FRAMEWORK.md +0 -506
  44. runbooks-0.9.1.dist-info/METADATA +0 -308
  45. {runbooks-0.9.1.dist-info → runbooks-0.9.4.dist-info}/WHEEL +0 -0
  46. {runbooks-0.9.1.dist-info → runbooks-0.9.4.dist-info}/licenses/LICENSE +0 -0
  47. {runbooks-0.9.1.dist-info → runbooks-0.9.4.dist-info}/top_level.txt +0 -0
@@ -1,18 +1,72 @@
1
1
  """
2
2
  🚨 HIGH-RISK: WorkSpaces Management - Analyze and manage WorkSpaces with deletion capabilities.
3
+
4
+ JIRA AWSO-24: Enhanced WorkSpaces cleanup with cost calculation for $12,518 annual savings
5
+ Accounts: 339712777494, 802669565615, 142964829704, 507583929055
6
+ Types: STANDARD, PERFORMANCE, VALUE in AUTO_STOP mode
3
7
  """
4
8
 
5
9
  import logging
6
10
  from datetime import datetime, timedelta, timezone
11
+ from typing import Dict, List, Optional
7
12
 
8
13
  import click
9
14
  from botocore.exceptions import ClientError
10
15
 
11
16
  from .commons import display_aws_account_info, get_client, write_to_csv
17
+ from ..common.rich_utils import (
18
+ console, print_header, print_success, print_error, print_warning,
19
+ create_table, create_progress_bar, format_cost
20
+ )
12
21
 
13
22
  logger = logging.getLogger(__name__)
14
23
 
15
24
 
25
+ def calculate_workspace_monthly_cost(workspace_bundle_id: str, running_mode: str) -> float:
26
+ """
27
+ Calculate monthly cost for WorkSpace based on bundle and running mode.
28
+
29
+ JIRA AWSO-24: Cost calculations for $12,518 annual savings target
30
+ Based on AWS WorkSpaces pricing: https://aws.amazon.com/workspaces/pricing/
31
+ """
32
+ # WorkSpaces pricing by bundle type (monthly USD)
33
+ bundle_costs = {
34
+ # Value bundles
35
+ "wsb-bh8rsxt14": {"name": "Value", "monthly": 25.0, "hourly": 0.22}, # Windows 10 Value
36
+ "wsb-3t36q8qkj": {"name": "Value", "monthly": 25.0, "hourly": 0.22}, # Amazon Linux 2 Value
37
+
38
+ # Standard bundles
39
+ "wsb-92tn3b7gx": {"name": "Standard", "monthly": 35.0, "hourly": 0.50}, # Windows 10 Standard
40
+ "wsb-2bs6k5lgj": {"name": "Standard", "monthly": 35.0, "hourly": 0.50}, # Amazon Linux 2 Standard
41
+
42
+ # Performance bundles
43
+ "wsb-gk1wpk43z": {"name": "Performance", "monthly": 68.0, "hourly": 0.85}, # Windows 10 Performance
44
+ "wsb-1b5w6vnzg": {"name": "Performance", "monthly": 68.0, "hourly": 0.85}, # Amazon Linux 2 Performance
45
+
46
+ # PowerPro bundles
47
+ "wsb-8vbljg4r6": {"name": "PowerPro", "monthly": 134.0, "hourly": 1.50}, # Windows 10 PowerPro
48
+ "wsb-vbljg4r61": {"name": "PowerPro", "monthly": 134.0, "hourly": 1.50}, # Amazon Linux 2 PowerPro
49
+
50
+ # Graphics bundles
51
+ "wsb-1pzkp0bx8": {"name": "Graphics", "monthly": 144.0, "hourly": 1.75}, # Windows 10 Graphics
52
+ "wsb-pszkp0bx9": {"name": "Graphics", "monthly": 144.0, "hourly": 1.75}, # Amazon Linux 2 Graphics
53
+ }
54
+
55
+ # Get bundle info or use default
56
+ bundle_info = bundle_costs.get(workspace_bundle_id, {"name": "Standard", "monthly": 35.0, "hourly": 0.50})
57
+
58
+ # Calculate cost based on running mode
59
+ if running_mode.upper() == "AUTO_STOP":
60
+ # Auto-stop: Pay monthly fee + hourly usage (simplified to monthly for unused)
61
+ return bundle_info["monthly"]
62
+ elif running_mode.upper() == "ALWAYS_ON":
63
+ # Always-on: Pay monthly fee only
64
+ return bundle_info["monthly"]
65
+ else:
66
+ # Unknown mode, use monthly
67
+ return bundle_info["monthly"]
68
+
69
+
16
70
  def get_workspace_usage_by_hours(workspace_id, start_time, end_time):
17
71
  """Get WorkSpace usage hours from CloudWatch metrics."""
18
72
  try:
@@ -44,30 +98,43 @@ def get_workspace_usage_by_hours(workspace_id, start_time, end_time):
44
98
  @click.option("--delete-unused", is_flag=True, help="🚨 HIGH-RISK: Delete unused WorkSpaces")
45
99
  @click.option("--unused-days", default=90, help="Days threshold for considering WorkSpace unused")
46
100
  @click.option("--confirm", is_flag=True, help="Skip confirmation prompts (dangerous!)")
101
+ @click.option("--calculate-savings", is_flag=True, help="Calculate cost savings for cleanup")
102
+ @click.option("--analyze", is_flag=True, help="Perform detailed cost analysis")
103
+ @click.option("--dry-run", is_flag=True, default=True, help="Preview actions without execution")
47
104
  def get_workspaces(
48
105
  output_file: str = "/tmp/workspaces.csv",
49
106
  days: int = 30,
50
107
  delete_unused: bool = False,
51
108
  unused_days: int = 90,
52
109
  confirm: bool = False,
110
+ calculate_savings: bool = False,
111
+ analyze: bool = False,
112
+ dry_run: bool = True,
53
113
  ):
54
- """🚨 HIGH-RISK: Analyze WorkSpaces usage and optionally delete unused ones."""
55
-
114
+ """
115
+ 🚨 HIGH-RISK: Analyze WorkSpaces usage and optionally delete unused ones.
116
+
117
+ JIRA AWSO-24: Enhanced WorkSpaces cleanup with cost calculation for $12,518 annual savings
118
+ """
119
+
120
+ print_header("WorkSpaces Cost Optimization Analysis", "v0.9.1")
121
+
56
122
  # HIGH-RISK OPERATION WARNING
57
123
  if delete_unused and not confirm:
58
- logger.warning("🚨 HIGH-RISK OPERATION: WorkSpace deletion")
59
- logger.warning("This operation will permanently delete WorkSpaces and all user data")
124
+ print_warning("🚨 HIGH-RISK OPERATION: WorkSpace deletion")
125
+ print_warning("This operation will permanently delete WorkSpaces and all user data")
60
126
  if not click.confirm("Do you want to continue?"):
61
- logger.info("Operation cancelled by user")
127
+ print_error("Operation cancelled by user")
62
128
  return
63
129
 
64
- logger.info(f"Analyzing WorkSpaces in {display_aws_account_info()}")
130
+ account_info = display_aws_account_info()
131
+ console.print(f"[cyan]Analyzing WorkSpaces in {account_info}[/cyan]")
65
132
 
66
133
  try:
67
134
  ws_client = get_client("workspaces")
68
135
 
69
- # Get all WorkSpaces
70
- logger.info("Collecting WorkSpaces data...")
136
+ # Get all WorkSpaces with progress bar
137
+ console.print("[yellow]Collecting WorkSpaces data...[/yellow]")
71
138
  paginator = ws_client.get_paginator("describe_workspaces")
72
139
  data = []
73
140
 
@@ -76,19 +143,29 @@ def get_workspaces(
76
143
  start_time = end_time - timedelta(days=days)
77
144
  unused_threshold = end_time - timedelta(days=unused_days)
78
145
 
79
- logger.info(f"Analyzing usage from {start_time.strftime('%Y-%m-%d')} to {end_time.strftime('%Y-%m-%d')}")
146
+ console.print(f"[dim]Analyzing usage from {start_time.strftime('%Y-%m-%d')} to {end_time.strftime('%Y-%m-%d')}[/dim]")
80
147
 
81
- total_workspaces = 0
148
+ # Collect all workspaces first for progress tracking
149
+ all_workspaces = []
82
150
  for page in paginator.paginate():
83
151
  workspaces = page.get("Workspaces", [])
84
- total_workspaces += len(workspaces)
85
-
86
- for workspace in workspaces:
152
+ all_workspaces.extend(workspaces)
153
+
154
+ total_cost = 0.0
155
+ unused_cost = 0.0
156
+
157
+ with create_progress_bar() as progress:
158
+ task_id = progress.add_task(
159
+ f"Analyzing {len(all_workspaces)} WorkSpaces...",
160
+ total=len(all_workspaces)
161
+ )
162
+
163
+ for workspace in all_workspaces:
87
164
  workspace_id = workspace["WorkspaceId"]
88
165
  username = workspace["UserName"]
89
166
  state = workspace["State"]
90
-
91
- logger.info(f"Analyzing WorkSpace: {workspace_id} ({username})")
167
+ bundle_id = workspace["BundleId"]
168
+ running_mode = workspace["WorkspaceProperties"]["RunningMode"]
92
169
 
93
170
  # Get connection status
94
171
  try:
@@ -121,46 +198,120 @@ def get_workspaces(
121
198
  # Determine if workspace is unused
122
199
  is_unused = last_connection is None or last_connection < unused_threshold
123
200
 
201
+ # Calculate cost (JIRA AWSO-24 enhancement)
202
+ monthly_cost = 0.0
203
+ if calculate_savings or analyze:
204
+ monthly_cost = calculate_workspace_monthly_cost(bundle_id, running_mode)
205
+ total_cost += monthly_cost
206
+ if is_unused:
207
+ unused_cost += monthly_cost
208
+
124
209
  workspace_data = {
125
210
  "WorkspaceId": workspace_id,
126
211
  "UserName": username,
127
212
  "State": state,
128
- "RunningMode": workspace["WorkspaceProperties"]["RunningMode"],
213
+ "RunningMode": running_mode,
129
214
  "OperatingSystem": workspace["WorkspaceProperties"]["OperatingSystemName"],
130
- "BundleId": workspace["BundleId"],
215
+ "BundleId": bundle_id,
131
216
  "LastConnection": last_connection_str,
132
217
  "DaysSinceConnection": days_since_connection,
133
218
  "ConnectionState": connection_state,
134
219
  f"UsageHours_{days}days": usage_hours,
135
220
  "IsUnused": is_unused,
136
221
  "UnusedThreshold": f"{unused_days} days",
222
+ "MonthlyCost": monthly_cost,
223
+ "AnnualCost": monthly_cost * 12,
137
224
  }
138
225
 
139
226
  data.append(workspace_data)
140
-
141
- # Log status
142
- if is_unused:
143
- logger.warning(f" ⚠ UNUSED: Last connection {days_since_connection} days ago")
144
- else:
145
- logger.info(f" ✓ Active: {usage_hours}h usage in {days} days")
227
+ progress.advance(task_id)
146
228
 
147
229
  # Export data
148
230
  write_to_csv(data, output_file)
149
- logger.info(f"WorkSpaces analysis exported to: {output_file}")
231
+ print_success(f"WorkSpaces analysis exported to: {output_file}")
150
232
 
151
233
  # Analyze unused WorkSpaces
152
234
  unused_workspaces = [ws for ws in data if ws["IsUnused"]]
153
235
 
154
- logger.info("\n=== ANALYSIS SUMMARY ===")
155
- logger.info(f"Total WorkSpaces: {len(data)}")
156
- logger.info(f"Unused WorkSpaces (>{unused_days} days): {len(unused_workspaces)}")
236
+ # Create summary table with Rich CLI
237
+ print_header("WorkSpaces Analysis Summary")
238
+
239
+ summary_table = create_table(
240
+ title="WorkSpaces Cost Analysis - JIRA AWSO-24",
241
+ columns=[
242
+ {"header": "Metric", "style": "cyan"},
243
+ {"header": "Value", "style": "green bold"},
244
+ {"header": "Monthly Cost", "style": "red"},
245
+ {"header": "Annual Cost", "style": "red bold"}
246
+ ]
247
+ )
248
+
249
+ # Basic metrics
250
+ summary_table.add_row(
251
+ "Total WorkSpaces",
252
+ str(len(data)),
253
+ format_cost(total_cost) if calculate_savings or analyze else "N/A",
254
+ format_cost(total_cost * 12) if calculate_savings or analyze else "N/A"
255
+ )
256
+
257
+ summary_table.add_row(
258
+ f"Unused WorkSpaces (>{unused_days} days)",
259
+ str(len(unused_workspaces)),
260
+ format_cost(unused_cost) if calculate_savings or analyze else "N/A",
261
+ format_cost(unused_cost * 12) if calculate_savings or analyze else "N/A"
262
+ )
263
+
264
+ if calculate_savings or analyze:
265
+ potential_savings_monthly = unused_cost
266
+ potential_savings_annual = unused_cost * 12
267
+
268
+ summary_table.add_row(
269
+ "🎯 Potential Savings",
270
+ f"{len(unused_workspaces)} WorkSpaces",
271
+ format_cost(potential_savings_monthly),
272
+ format_cost(potential_savings_annual)
273
+ )
274
+
275
+ console.print(summary_table)
157
276
 
158
277
  if unused_workspaces:
159
- logger.warning(f"⚠ Found {len(unused_workspaces)} unused WorkSpaces:")
160
- for ws in unused_workspaces:
161
- logger.warning(
162
- f" - {ws['WorkspaceId']} ({ws['UserName']}) - {ws['DaysSinceConnection']} days since connection"
278
+ print_warning(f"⚠ Found {len(unused_workspaces)} unused WorkSpaces:")
279
+
280
+ # Create detailed unused workspaces table
281
+ unused_table = create_table(
282
+ title="Unused WorkSpaces Details",
283
+ columns=[
284
+ {"header": "WorkSpace ID", "style": "cyan"},
285
+ {"header": "Username", "style": "blue"},
286
+ {"header": "Days Since Connection", "style": "yellow"},
287
+ {"header": "Running Mode", "style": "green"},
288
+ {"header": "Monthly Cost", "style": "red"},
289
+ {"header": "State", "style": "magenta"}
290
+ ]
291
+ )
292
+
293
+ for ws in unused_workspaces[:10]: # Show first 10 for readability
294
+ unused_table.add_row(
295
+ ws['WorkspaceId'],
296
+ ws['UserName'],
297
+ str(ws['DaysSinceConnection']),
298
+ ws['RunningMode'],
299
+ format_cost(ws['MonthlyCost']) if ws['MonthlyCost'] > 0 else "N/A",
300
+ ws['State']
163
301
  )
302
+
303
+ console.print(unused_table)
304
+
305
+ if len(unused_workspaces) > 10:
306
+ console.print(f"[dim]... and {len(unused_workspaces) - 10} more unused WorkSpaces[/dim]")
307
+
308
+ # Target validation (JIRA AWSO-24: $12,518 annual savings)
309
+ if calculate_savings or analyze:
310
+ target_annual_savings = 12518.0 # JIRA AWSO-24 target
311
+ if potential_savings_annual >= target_annual_savings * 0.8: # 80% of target
312
+ print_success(f"🎯 Target Achievement: {potential_savings_annual/target_annual_savings*100:.1f}% of $12,518 annual savings target")
313
+ else:
314
+ print_warning(f"📊 Analysis: {potential_savings_annual/target_annual_savings*100:.1f}% of $12,518 annual savings target")
164
315
 
165
316
  # Handle deletion of unused WorkSpaces
166
317
  if delete_unused and unused_workspaces:
@@ -210,8 +210,10 @@ from .run_script import parse_arguments
210
210
  from .security_baseline_tester import SecurityBaselineTester
211
211
  from .security_export import SecurityExporter
212
212
 
213
+ # Import centralized version from main runbooks package
214
+ from runbooks import __version__
215
+
213
216
  # Version info
214
- __version__ = "1.2.0"
215
217
  __author__ = "CloudOps Enterprise Security Team"
216
218
 
217
219
  # Public API
runbooks/sre/__init__.py CHANGED
@@ -23,7 +23,8 @@ from .mcp_reliability_engine import (
23
23
  run_mcp_reliability_suite,
24
24
  )
25
25
 
26
- __version__ = "1.0.0"
26
+ # Import centralized version from main runbooks package
27
+ from runbooks import __version__
27
28
 
28
29
  __all__ = [
29
30
  "MCPReliabilityEngine",
@@ -22,6 +22,15 @@ except ImportError:
22
22
  # Legacy utilities
23
23
  from runbooks.utils.logger import configure_logger
24
24
 
25
+ # Version management utilities
26
+ from runbooks.utils.version_validator import (
27
+ check_pyproject_version,
28
+ get_all_module_versions,
29
+ print_version_report,
30
+ validate_version_consistency,
31
+ VersionDriftError,
32
+ )
33
+
25
34
 
26
35
  def setup_logging(debug: bool = False, log_file: Optional[str] = None) -> None:
27
36
  """
@@ -65,9 +74,9 @@ def setup_logging(debug: bool = False, log_file: Optional[str] = None) -> None:
65
74
  # Fallback to standard logging
66
75
  import logging
67
76
 
68
- log_level = logging.DEBUG if debug else logging.INFO
77
+ log_level_value = logging.DEBUG if debug else logging.INFO
69
78
  logging.basicConfig(
70
- level=log_level,
79
+ level=log_level_value,
71
80
  format="%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s",
72
81
  handlers=[logging.StreamHandler(sys.stderr)],
73
82
  )
@@ -76,12 +85,71 @@ def setup_logging(debug: bool = False, log_file: Optional[str] = None) -> None:
76
85
  log_path = Path(log_file)
77
86
  log_path.parent.mkdir(parents=True, exist_ok=True)
78
87
  file_handler = logging.FileHandler(log_path)
79
- file_handler.setFormatter(
80
- logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s")
81
- )
88
+ file_handler.setLevel(log_level_value)
89
+ formatter = logging.Formatter("%(asctime)s | %(levelname)-8s | %(name)s:%(funcName)s:%(lineno)d - %(message)s")
90
+ file_handler.setFormatter(formatter)
82
91
  logging.getLogger().addHandler(file_handler)
83
92
 
84
- logging.info(f"Logging initialized with level: {log_level}")
93
+
94
+ def setup_enhanced_logging(log_level: str = "INFO", json_output: bool = False, debug: bool = False) -> None:
95
+ """
96
+ Configure enhanced enterprise logging with Rich CLI integration and user-type specific output.
97
+
98
+ This function initializes the global enhanced logger that provides:
99
+ - User-type specific formatting (DEBUG=tech, INFO=standard, WARNING=business, ERROR=all)
100
+ - Rich CLI integration with beautiful formatting
101
+ - Structured JSON output option for programmatic use
102
+ - AWS API tracing for technical users
103
+ - Business recommendations for warning level
104
+ - Clear error solutions for all users
105
+
106
+ Args:
107
+ log_level: Log level (DEBUG, INFO, WARNING, ERROR)
108
+ json_output: Enable structured JSON output for programmatic use
109
+ debug: Legacy debug flag for backward compatibility
110
+ """
111
+ try:
112
+ from runbooks.enterprise.logging import configure_enterprise_logging, get_context_logger
113
+ from runbooks.common.rich_utils import get_context_aware_console
114
+
115
+ # Override level if debug flag is set (backward compatibility)
116
+ if debug:
117
+ log_level = "DEBUG"
118
+
119
+ # Get context-aware console for Rich CLI integration
120
+ try:
121
+ rich_console = get_context_aware_console()
122
+ except ImportError:
123
+ rich_console = None
124
+
125
+ # Configure global enhanced logger
126
+ logger = configure_enterprise_logging(
127
+ level=log_level,
128
+ rich_console=rich_console,
129
+ json_output=json_output
130
+ )
131
+
132
+ # Log initialization success with user-type appropriate message
133
+ if log_level == "DEBUG":
134
+ logger.debug_tech(
135
+ "Enhanced logging initialized with Rich CLI integration",
136
+ aws_api={"service": "logging", "operation": "initialize"},
137
+ duration=0.001
138
+ )
139
+ elif log_level == "INFO":
140
+ logger.info_standard("CloudOps Runbooks logging initialized")
141
+ elif log_level == "WARNING":
142
+ logger.warning_business(
143
+ "Business-focused logging enabled",
144
+ recommendation="Use --log-level INFO for standard operations"
145
+ )
146
+ else:
147
+ logger.error_all("Minimal error-only logging enabled")
148
+
149
+ except ImportError as e:
150
+ # Fallback to standard logging if enhanced logging not available
151
+ setup_logging(debug=debug)
152
+ print(f"Warning: Enhanced logging not available, falling back to standard logging: {e}")
85
153
 
86
154
 
87
155
  def validate_aws_profile(profile: str) -> bool:
@@ -196,9 +264,16 @@ def retry_with_backoff(max_retries: int = 3, backoff_factor: float = 1.0):
196
264
 
197
265
  __all__ = [
198
266
  "setup_logging",
267
+ "setup_enhanced_logging",
199
268
  "validate_aws_profile",
200
269
  "ensure_directory",
201
270
  "format_size",
202
271
  "retry_with_backoff",
203
272
  "configure_logger",
273
+ # Version management
274
+ "check_pyproject_version",
275
+ "get_all_module_versions",
276
+ "print_version_report",
277
+ "validate_version_consistency",
278
+ "VersionDriftError",
204
279
  ]
@@ -0,0 +1,241 @@
1
+ """
2
+ Version Management Validation Utilities
3
+
4
+ This module provides utilities to detect and prevent version drift across
5
+ the runbooks package and its modules.
6
+ """
7
+
8
+ import importlib
9
+ import sys
10
+ import warnings
11
+ from pathlib import Path
12
+ from typing import Dict, List, Optional, Tuple
13
+
14
+ # Avoid circular imports by defining central version directly
15
+ CENTRAL_VERSION = "0.9.3" # Must match runbooks.__init__.__version__
16
+
17
+
18
+ class VersionDriftError(Exception):
19
+ """Raised when version drift is detected in CI/CD pipeline."""
20
+ pass
21
+
22
+
23
+ def get_all_module_versions() -> Dict[str, str]:
24
+ """
25
+ Collect versions from all runbooks modules.
26
+
27
+ Returns:
28
+ Dict mapping module names to their reported versions
29
+ """
30
+ modules = [
31
+ "runbooks",
32
+ "runbooks.finops",
33
+ "runbooks.operate",
34
+ "runbooks.security",
35
+ "runbooks.cfat",
36
+ "runbooks.inventory",
37
+ "runbooks.remediation",
38
+ "runbooks.vpc",
39
+ "runbooks.sre",
40
+ "runbooks.cloudops"
41
+ ]
42
+
43
+ versions = {}
44
+
45
+ for module_name in modules:
46
+ try:
47
+ # Import the module
48
+ module = importlib.import_module(module_name)
49
+
50
+ # Get version
51
+ if hasattr(module, '__version__'):
52
+ versions[module_name] = module.__version__
53
+ else:
54
+ versions[module_name] = "No version found"
55
+
56
+ except ImportError as e:
57
+ versions[module_name] = f"Import error: {e}"
58
+ except Exception as e:
59
+ versions[module_name] = f"Error: {e}"
60
+
61
+ return versions
62
+
63
+
64
+ def check_pyproject_version() -> Tuple[bool, str, str]:
65
+ """
66
+ Check if pyproject.toml version matches centralized version.
67
+
68
+ Returns:
69
+ Tuple of (is_matching, pyproject_version, central_version)
70
+ """
71
+ try:
72
+ from importlib.metadata import version as _pkg_version
73
+ pyproject_version = _pkg_version("runbooks")
74
+ return (pyproject_version == CENTRAL_VERSION, pyproject_version, CENTRAL_VERSION)
75
+ except Exception as e:
76
+ return (False, f"Error reading pyproject.toml: {e}", CENTRAL_VERSION)
77
+
78
+
79
+ def validate_version_consistency(strict: bool = False) -> Dict[str, any]:
80
+ """
81
+ Validate version consistency across all modules.
82
+
83
+ Args:
84
+ strict: If True, raise VersionDriftError on inconsistencies
85
+
86
+ Returns:
87
+ Dictionary with validation results
88
+
89
+ Raises:
90
+ VersionDriftError: If strict=True and inconsistencies found
91
+ """
92
+ results = {
93
+ "central_version": CENTRAL_VERSION,
94
+ "module_versions": get_all_module_versions(),
95
+ "pyproject_check": check_pyproject_version(),
96
+ "inconsistencies": [],
97
+ "all_consistent": True
98
+ }
99
+
100
+ # Check module versions
101
+ for module_name, module_version in results["module_versions"].items():
102
+ if module_name == "runbooks":
103
+ continue # Skip root module
104
+
105
+ if isinstance(module_version, str) and not module_version.startswith(("Error:", "No version", "Import error:")):
106
+ if module_version != CENTRAL_VERSION:
107
+ inconsistency = {
108
+ "module": module_name,
109
+ "expected": CENTRAL_VERSION,
110
+ "found": module_version,
111
+ "type": "module_version_mismatch"
112
+ }
113
+ results["inconsistencies"].append(inconsistency)
114
+ results["all_consistent"] = False
115
+
116
+ # Check pyproject.toml
117
+ is_matching, pyproject_version, _ = results["pyproject_check"]
118
+ if not is_matching and not pyproject_version.startswith("Error:"):
119
+ inconsistency = {
120
+ "module": "pyproject.toml",
121
+ "expected": CENTRAL_VERSION,
122
+ "found": pyproject_version,
123
+ "type": "pyproject_version_mismatch"
124
+ }
125
+ results["inconsistencies"].append(inconsistency)
126
+ results["all_consistent"] = False
127
+
128
+ # Raise error in strict mode
129
+ if strict and not results["all_consistent"]:
130
+ error_msg = f"Version drift detected:\n"
131
+ for inc in results["inconsistencies"]:
132
+ error_msg += f" - {inc['module']}: expected {inc['expected']}, got {inc['found']}\n"
133
+ raise VersionDriftError(error_msg)
134
+
135
+ return results
136
+
137
+
138
+ def print_version_report() -> None:
139
+ """Print a formatted version consistency report."""
140
+ try:
141
+ from rich.console import Console
142
+ from rich.table import Table
143
+ from rich.panel import Panel
144
+
145
+ console = Console()
146
+ validation_results = validate_version_consistency()
147
+
148
+ # Header
149
+ console.print(Panel(
150
+ f"[bold blue]Version Management Report[/bold blue]\n"
151
+ f"Central Version: [green]{validation_results['central_version']}[/green]",
152
+ title="Runbooks Version Validation"
153
+ ))
154
+
155
+ # Module versions table
156
+ table = Table(title="Module Version Status")
157
+ table.add_column("Module", style="cyan")
158
+ table.add_column("Version", style="green")
159
+ table.add_column("Status", style="magenta")
160
+
161
+ for module_name, version in validation_results["module_versions"].items():
162
+ if isinstance(version, str) and version.startswith(("Error:", "No version", "Import error:")):
163
+ status = "[red]Error[/red]"
164
+ version_display = f"[red]{version}[/red]"
165
+ elif version == validation_results["central_version"]:
166
+ status = "[green]✓ OK[/green]"
167
+ version_display = f"[green]{version}[/green]"
168
+ else:
169
+ status = "[red]✗ Mismatch[/red]"
170
+ version_display = f"[red]{version}[/red]"
171
+
172
+ table.add_row(module_name, version_display, status)
173
+
174
+ console.print(table)
175
+
176
+ # Pyproject.toml check
177
+ is_matching, pyproject_version, central_version = validation_results["pyproject_check"]
178
+ if is_matching:
179
+ console.print("[green]✓ pyproject.toml version matches central version[/green]")
180
+ else:
181
+ console.print(f"[red]✗ pyproject.toml version mismatch: {pyproject_version} vs {central_version}[/red]")
182
+
183
+ # Overall status
184
+ if validation_results["all_consistent"]:
185
+ console.print("\n[bold green]✓ All versions are consistent[/bold green]")
186
+ else:
187
+ console.print("\n[bold red]✗ Version inconsistencies detected[/bold red]")
188
+ for inc in validation_results["inconsistencies"]:
189
+ console.print(f" - {inc['module']}: expected {inc['expected']}, got {inc['found']}")
190
+
191
+ except ImportError:
192
+ # Fallback without Rich
193
+ validation_results = validate_version_consistency()
194
+
195
+ print(f"=== Version Management Report ===")
196
+ print(f"Central Version: {validation_results['central_version']}")
197
+ print()
198
+
199
+ print("Module Versions:")
200
+ for module_name, version in validation_results["module_versions"].items():
201
+ status = "OK" if version == validation_results["central_version"] else "MISMATCH"
202
+ print(f" {module_name}: {version} ({status})")
203
+
204
+ is_matching, pyproject_version, _ = validation_results["pyproject_check"]
205
+ pyproject_status = "OK" if is_matching else "MISMATCH"
206
+ print(f" pyproject.toml: {pyproject_version} ({pyproject_status})")
207
+
208
+ if validation_results["all_consistent"]:
209
+ print("\n✓ All versions are consistent")
210
+ else:
211
+ print("\n✗ Version inconsistencies detected:")
212
+ for inc in validation_results["inconsistencies"]:
213
+ print(f" - {inc['module']}: expected {inc['expected']}, got {inc['found']}")
214
+
215
+
216
+ def cli_main():
217
+ """CLI entry point for version validation."""
218
+ import argparse
219
+
220
+ parser = argparse.ArgumentParser(description="Validate runbooks version consistency")
221
+ parser.add_argument("--strict", action="store_true",
222
+ help="Exit with error code if inconsistencies found")
223
+
224
+ args = parser.parse_args()
225
+
226
+ try:
227
+ print_version_report()
228
+
229
+ if args.strict:
230
+ validate_version_consistency(strict=True)
231
+
232
+ except VersionDriftError as e:
233
+ print(f"\nERROR: {e}")
234
+ sys.exit(1)
235
+ except Exception as e:
236
+ print(f"Unexpected error: {e}")
237
+ sys.exit(1)
238
+
239
+
240
+ if __name__ == "__main__":
241
+ cli_main()