Created IAC reverse generator

This commit is contained in:
p2913020
2026-05-22 00:19:30 -04:00
parent d04c2c6e4b
commit 1a11244fff
161 changed files with 26806 additions and 51 deletions

View 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