Created IAC reverse generator
This commit is contained in:
287
src/iac_reverse/scanner/scanner.py
Normal file
287
src/iac_reverse/scanner/scanner.py
Normal file
@@ -0,0 +1,287 @@
|
||||
"""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
|
||||
Reference in New Issue
Block a user