"""Scanner orchestrator for infrastructure discovery. Coordinates provider plugins to discover infrastructure resources, handling authentication, retries, progress reporting, and error recovery. """ import hashlib import logging import time from datetime import datetime, timezone from typing import Callable, Optional from iac_reverse.models import ( ScanProfile, ScanProgress, ScanResult, ) from iac_reverse.plugin_base import ProviderPlugin logger = logging.getLogger(__name__) # --------------------------------------------------------------------------- # Custom Exceptions # --------------------------------------------------------------------------- class AuthenticationError(Exception): """Raised when authentication with a provider fails.""" def __init__(self, provider_name: str, reason: str): self.provider_name = provider_name self.reason = reason super().__init__( f"Authentication failed for provider '{provider_name}': {reason}" ) class ConnectionLostError(Exception): """Raised when the provider connection is lost during a scan.""" def __init__(self, partial_result: ScanResult): self.partial_result = partial_result super().__init__("Connection lost during scan; partial results available") class ScanTimeoutError(Exception): """Raised when a scan operation exceeds the allowed timeout.""" def __init__(self, message: str = "Scan operation timed out"): super().__init__(message) # --------------------------------------------------------------------------- # Scanner Orchestrator # --------------------------------------------------------------------------- # Default constants CONNECTION_TIMEOUT_SECONDS = 30 MAX_RETRIES = 3 INITIAL_BACKOFF_SECONDS = 1.0 class Scanner: """Orchestrates infrastructure discovery using a provider plugin. Accepts a ScanProfile and an optional ProviderPlugin instance. Handles authentication, progress reporting, retry logic with exponential backoff, and graceful degradation on errors. """ def __init__( self, profile: ScanProfile, plugin: Optional[ProviderPlugin] = None, ): self.profile = profile self.plugin = plugin def scan( self, progress_callback: Optional[Callable[[ScanProgress], None]] = None, ) -> ScanResult: """Execute a full infrastructure scan. Args: progress_callback: Optional callable invoked per resource type completion with a ScanProgress update. Returns: ScanResult containing discovered resources, warnings, and errors. Raises: AuthenticationError: If authentication with the provider fails. ScanTimeoutError: If the connection attempt exceeds 30 seconds. ValueError: If the scan profile is invalid. """ # 1. Validate the scan profile (critical fields only) validation_errors = self._validate_profile() if validation_errors: raise ValueError( f"Invalid scan profile: {'; '.join(validation_errors)}" ) if self.plugin is None: raise ValueError("No provider plugin configured for scanning") # 2. Authenticate with the provider (30 second timeout) self._authenticate() # 3. Determine resource types to scan supported_types = self.plugin.list_supported_resource_types() resource_types, warnings = self._resolve_resource_types(supported_types) # 4. Determine endpoints endpoints = self.profile.endpoints or self.plugin.list_endpoints() # 5. Discover resources with retry logic scan_result = self._discover_with_retries( endpoints=endpoints, resource_types=resource_types, progress_callback=progress_callback, ) # Merge any warnings from unsupported resource type filtering scan_result.warnings = warnings + scan_result.warnings # Set metadata scan_result.scan_timestamp = datetime.now(timezone.utc).isoformat() scan_result.profile_hash = self._compute_profile_hash() return scan_result def _authenticate(self) -> None: """Authenticate with the provider plugin, enforcing a 30s timeout.""" provider_name = self.profile.provider.value start_time = time.monotonic() try: self.plugin.authenticate(self.profile.credentials) except Exception as exc: elapsed = time.monotonic() - start_time if elapsed >= CONNECTION_TIMEOUT_SECONDS: raise ScanTimeoutError( f"Authentication with provider '{provider_name}' " f"timed out after {CONNECTION_TIMEOUT_SECONDS} seconds" ) # Wrap any auth exception in our AuthenticationError if isinstance(exc, AuthenticationError): raise raise AuthenticationError( provider_name=provider_name, reason=str(exc), ) from exc elapsed = time.monotonic() - start_time if elapsed >= CONNECTION_TIMEOUT_SECONDS: raise ScanTimeoutError( f"Authentication with provider '{provider_name}' " f"timed out after {CONNECTION_TIMEOUT_SECONDS} seconds" ) def _resolve_resource_types( self, supported_types: list[str] ) -> tuple[list[str], list[str]]: """Determine which resource types to scan and log warnings for unsupported ones. Returns: Tuple of (resource_types_to_scan, warnings_list) """ warnings: list[str] = [] if self.profile.resource_type_filters is None: # No filters: scan all supported types return supported_types, warnings # Filter requested types against supported types valid_types: list[str] = [] for rt in self.profile.resource_type_filters: if rt in supported_types: valid_types.append(rt) else: warning_msg = ( f"Unsupported resource type '{rt}' for provider " f"'{self.profile.provider.value}'; skipping" ) warnings.append(warning_msg) logger.warning(warning_msg) return valid_types, warnings def _discover_with_retries( self, endpoints: list[str], resource_types: list[str], progress_callback: Optional[Callable[[ScanProgress], None]], ) -> ScanResult: """Call the plugin's discover_resources with retry logic. Retries up to MAX_RETRIES times with exponential backoff for transient errors. On connection loss, returns partial inventory. """ last_exception: Optional[Exception] = None for attempt in range(MAX_RETRIES + 1): try: result = self.plugin.discover_resources( endpoints=endpoints, resource_types=resource_types, progress_callback=progress_callback or self._noop_callback, ) return result except ConnectionLostError: # Connection lost: return partial results immediately raise except ConnectionError as exc: # Connection lost during scan: build partial result logger.warning( "Connection lost during scan (attempt %d/%d): %s", attempt + 1, MAX_RETRIES + 1, exc, ) partial = ScanResult( resources=[], warnings=[f"Connection lost: {exc}"], errors=[str(exc)], scan_timestamp=datetime.now(timezone.utc).isoformat(), profile_hash=self._compute_profile_hash(), is_partial=True, ) raise ConnectionLostError(partial_result=partial) from exc except Exception as exc: last_exception = exc if attempt < MAX_RETRIES: backoff = INITIAL_BACKOFF_SECONDS * (2**attempt) logger.warning( "Transient error during scan (attempt %d/%d), " "retrying in %.1fs: %s", attempt + 1, MAX_RETRIES + 1, backoff, exc, ) time.sleep(backoff) else: logger.error( "Scan failed after %d attempts: %s", MAX_RETRIES + 1, exc, ) # All retries exhausted — return error result return ScanResult( resources=[], warnings=[], errors=[f"Scan failed after {MAX_RETRIES + 1} attempts: {last_exception}"], scan_timestamp=datetime.now(timezone.utc).isoformat(), profile_hash=self._compute_profile_hash(), is_partial=True, ) def _validate_profile(self) -> list[str]: """Validate critical scan profile fields. Only checks fields that prevent scanning entirely (e.g., missing credentials). Unsupported resource types are handled as warnings during the scan per Requirement 1.4. """ errors: list[str] = [] if not self.profile.credentials: errors.append("credentials must not be empty") return errors def _compute_profile_hash(self) -> str: """Compute a stable hash of the scan profile for snapshot matching.""" content = ( f"{self.profile.provider.value}:" f"{sorted(self.profile.credentials.items())}:" f"{self.profile.endpoints}:" f"{self.profile.resource_type_filters}" ) return hashlib.sha256(content.encode()).hexdigest()[:16] @staticmethod def _noop_callback(progress: ScanProgress) -> None: """No-op progress callback used when none is provided.""" pass