288 lines
10 KiB
Python
288 lines
10 KiB
Python
"""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
|