"""Bare Metal provider plugin using Redfish/IPMI API. Discovers hardware inventory, BMC configurations, network interfaces, and RAID configurations from physical servers via the Redfish REST API (standard BMC management interface). """ import logging from typing import Callable from urllib.parse import urljoin import requests from iac_reverse.models import ( CpuArchitecture, DiscoveredResource, PlatformCategory, ProviderType, ScanProgress, ScanResult, ) from iac_reverse.plugin_base import ProviderPlugin from iac_reverse.scanner.scanner import AuthenticationError logger = logging.getLogger(__name__) class BareMetalPlugin(ProviderPlugin): """Provider plugin for bare metal servers using Redfish/IPMI API. Connects to a server's BMC (Baseboard Management Controller) via the Redfish REST API to discover hardware inventory, BMC configuration, network interfaces, and RAID configurations. Expected credentials dict keys: host: BMC hostname or IP address (required) username: BMC username (required) password: BMC password (required) port: BMC port (optional, default 443) use_ssl: Whether to use HTTPS (optional, default "true") """ SUPPORTED_RESOURCE_TYPES = [ "bare_metal_hardware", "bare_metal_bmc_config", "bare_metal_network_interface", "bare_metal_raid_config", ] def __init__(self) -> None: self._session: requests.Session | None = None self._base_url: str = "" self._host: str = "" def authenticate(self, credentials: dict[str, str]) -> None: """Authenticate with the BMC via Redfish session creation. Args: credentials: Dict with keys: host, username, password, and optionally port (default 443) and use_ssl (default "true"). Raises: AuthenticationError: If connection or login fails. """ host = credentials.get("host", "") username = credentials.get("username", "") password = credentials.get("password", "") port = credentials.get("port", "443") use_ssl = credentials.get("use_ssl", "true").lower() == "true" if not host or not username or not password: raise AuthenticationError( provider_name="bare_metal", reason="Missing required credentials: host, username, and password are required", ) scheme = "https" if use_ssl else "http" self._base_url = f"{scheme}://{host}:{port}" self._host = host session = requests.Session() session.verify = False # BMC certs are typically self-signed session.headers.update({ "Content-Type": "application/json", "Accept": "application/json", }) # Attempt Redfish session-based authentication session_url = f"{self._base_url}/redfish/v1/SessionService/Sessions" payload = {"UserName": username, "Password": password} try: response = session.post(session_url, json=payload, timeout=30) if response.status_code in (200, 201): # Extract session token from response headers token = response.headers.get("X-Auth-Token", "") if token: session.headers["X-Auth-Token"] = token elif response.status_code == 401: raise AuthenticationError( provider_name="bare_metal", reason="Invalid credentials (HTTP 401)", ) else: raise AuthenticationError( provider_name="bare_metal", reason=f"Unexpected response status {response.status_code}", ) except requests.exceptions.ConnectionError as exc: raise AuthenticationError( provider_name="bare_metal", reason=f"Cannot connect to BMC at {self._base_url}: {exc}", ) from exc except requests.exceptions.Timeout as exc: raise AuthenticationError( provider_name="bare_metal", reason=f"Connection to BMC timed out: {exc}", ) from exc except AuthenticationError: raise except Exception as exc: raise AuthenticationError( provider_name="bare_metal", reason=f"Unexpected error during authentication: {exc}", ) from exc self._session = session def get_platform_category(self) -> PlatformCategory: """Return PlatformCategory.BARE_METAL.""" return PlatformCategory.BARE_METAL def list_endpoints(self) -> list[str]: """Return the BMC host as the single endpoint.""" return [self._host] if self._host else [] def list_supported_resource_types(self) -> list[str]: """Return supported bare metal resource types.""" return list(self.SUPPORTED_RESOURCE_TYPES) def detect_architecture(self, endpoint: str) -> CpuArchitecture: """Detect CPU architecture from Redfish system hardware info. Queries /redfish/v1/Systems/1/Processors to determine the processor architecture. Args: endpoint: The BMC host address. Returns: CpuArchitecture enum value based on processor info. """ if self._session is None: return CpuArchitecture.AMD64 processors_url = f"{self._base_url}/redfish/v1/Systems/1/Processors" try: response = self._session.get(processors_url, timeout=30) if response.status_code == 200: data = response.json() members = data.get("Members", []) if members: # Query first processor for architecture details proc_uri = members[0].get("@odata.id", "") if proc_uri: proc_url = f"{self._base_url}{proc_uri}" proc_response = self._session.get(proc_url, timeout=30) if proc_response.status_code == 200: proc_data = proc_response.json() return self._parse_architecture(proc_data) except Exception as exc: logger.warning("Failed to detect architecture: %s", exc) return CpuArchitecture.AMD64 def discover_resources( self, endpoints: list[str], resource_types: list[str], progress_callback: Callable[[ScanProgress], None], ) -> ScanResult: """Discover bare metal resources via Redfish API. Args: endpoints: List of BMC host addresses to scan. resource_types: Resource types to discover. progress_callback: Progress reporting callback. Returns: ScanResult with discovered resources. """ resources: list[DiscoveredResource] = [] warnings: list[str] = [] errors: list[str] = [] total_types = len(resource_types) types_completed = 0 for endpoint in endpoints: architecture = self.detect_architecture(endpoint) for resource_type in resource_types: try: discovered = self._discover_resource_type( endpoint, resource_type, architecture ) resources.extend(discovered) except Exception as exc: error_msg = ( f"Error discovering {resource_type} on {endpoint}: {exc}" ) errors.append(error_msg) logger.error(error_msg) types_completed += 1 progress_callback( ScanProgress( current_resource_type=resource_type, resources_discovered=len(resources), resource_types_completed=types_completed, total_resource_types=total_types, ) ) return ScanResult( resources=resources, warnings=warnings, errors=errors, scan_timestamp="", profile_hash="", ) # ----------------------------------------------------------------------- # Private helpers # ----------------------------------------------------------------------- def _discover_resource_type( self, endpoint: str, resource_type: str, architecture: CpuArchitecture, ) -> list[DiscoveredResource]: """Dispatch discovery to the appropriate handler.""" handlers = { "bare_metal_hardware": self._discover_hardware, "bare_metal_bmc_config": self._discover_bmc_config, "bare_metal_network_interface": self._discover_network_interfaces, "bare_metal_raid_config": self._discover_raid_config, } handler = handlers.get(resource_type) if handler is None: return [] return handler(endpoint, architecture) def _discover_hardware( self, endpoint: str, architecture: CpuArchitecture ) -> list[DiscoveredResource]: """Discover hardware inventory via /redfish/v1/Systems/1.""" if self._session is None: return [] url = f"{self._base_url}/redfish/v1/Systems/1" try: response = self._session.get(url, timeout=30) if response.status_code != 200: return [] data = response.json() except Exception as exc: logger.warning("Failed to discover hardware: %s", exc) return [] system_id = data.get("Id", "System.1") return [ DiscoveredResource( resource_type="bare_metal_hardware", unique_id=f"{endpoint}:{system_id}", name=data.get("Name", f"System {system_id}"), provider=ProviderType.BARE_METAL, platform_category=PlatformCategory.BARE_METAL, architecture=architecture, endpoint=endpoint, attributes={ "manufacturer": data.get("Manufacturer", ""), "model": data.get("Model", ""), "serial_number": data.get("SerialNumber", ""), "sku": data.get("SKU", ""), "bios_version": data.get("BiosVersion", ""), "total_memory_gib": data.get("MemorySummary", {}).get( "TotalSystemMemoryGiB", 0 ), "processor_count": data.get("ProcessorSummary", {}).get( "Count", 0 ), "processor_model": data.get("ProcessorSummary", {}).get( "Model", "" ), "power_state": data.get("PowerState", ""), "status": data.get("Status", {}), }, ) ] def _discover_bmc_config( self, endpoint: str, architecture: CpuArchitecture ) -> list[DiscoveredResource]: """Discover BMC configuration via /redfish/v1/Managers/1.""" if self._session is None: return [] url = f"{self._base_url}/redfish/v1/Managers/1" try: response = self._session.get(url, timeout=30) if response.status_code != 200: return [] data = response.json() except Exception as exc: logger.warning("Failed to discover BMC config: %s", exc) return [] manager_id = data.get("Id", "BMC.1") return [ DiscoveredResource( resource_type="bare_metal_bmc_config", unique_id=f"{endpoint}:{manager_id}", name=data.get("Name", f"BMC {manager_id}"), provider=ProviderType.BARE_METAL, platform_category=PlatformCategory.BARE_METAL, architecture=architecture, endpoint=endpoint, attributes={ "manager_type": data.get("ManagerType", ""), "firmware_version": data.get("FirmwareVersion", ""), "model": data.get("Model", ""), "status": data.get("Status", {}), "uuid": data.get("UUID", ""), }, ) ] def _discover_network_interfaces( self, endpoint: str, architecture: CpuArchitecture ) -> list[DiscoveredResource]: """Discover network interfaces via /redfish/v1/Systems/1/EthernetInterfaces.""" if self._session is None: return [] url = f"{self._base_url}/redfish/v1/Systems/1/EthernetInterfaces" try: response = self._session.get(url, timeout=30) if response.status_code != 200: return [] data = response.json() except Exception as exc: logger.warning("Failed to discover network interfaces: %s", exc) return [] resources: list[DiscoveredResource] = [] for member in data.get("Members", []): nic_uri = member.get("@odata.id", "") if not nic_uri: continue try: nic_url = f"{self._base_url}{nic_uri}" nic_response = self._session.get(nic_url, timeout=30) if nic_response.status_code != 200: continue nic_data = nic_response.json() except Exception as exc: logger.warning("Failed to get NIC details at %s: %s", nic_uri, exc) continue nic_id = nic_data.get("Id", "") resources.append( DiscoveredResource( resource_type="bare_metal_network_interface", unique_id=f"{endpoint}:{nic_id}", name=nic_data.get("Name", f"NIC {nic_id}"), provider=ProviderType.BARE_METAL, platform_category=PlatformCategory.BARE_METAL, architecture=architecture, endpoint=endpoint, attributes={ "mac_address": nic_data.get("MACAddress", ""), "speed_mbps": nic_data.get("SpeedMbps", 0), "status": nic_data.get("Status", {}), "ipv4_addresses": nic_data.get("IPv4Addresses", []), "ipv6_addresses": nic_data.get("IPv6Addresses", []), "vlan": nic_data.get("VLAN", {}), "link_status": nic_data.get("LinkStatus", ""), "auto_neg": nic_data.get("AutoNeg", False), }, ) ) return resources def _discover_raid_config( self, endpoint: str, architecture: CpuArchitecture ) -> list[DiscoveredResource]: """Discover RAID configuration via /redfish/v1/Systems/1/Storage.""" if self._session is None: return [] url = f"{self._base_url}/redfish/v1/Systems/1/Storage" try: response = self._session.get(url, timeout=30) if response.status_code != 200: return [] data = response.json() except Exception as exc: logger.warning("Failed to discover RAID config: %s", exc) return [] resources: list[DiscoveredResource] = [] for member in data.get("Members", []): storage_uri = member.get("@odata.id", "") if not storage_uri: continue try: storage_url = f"{self._base_url}{storage_uri}" storage_response = self._session.get(storage_url, timeout=30) if storage_response.status_code != 200: continue storage_data = storage_response.json() except Exception as exc: logger.warning( "Failed to get storage details at %s: %s", storage_uri, exc ) continue storage_id = storage_data.get("Id", "") drives = [] for drive in storage_data.get("Drives", []): drive_uri = drive.get("@odata.id", "") if drive_uri: drives.append(drive_uri) volumes = [] volumes_link = storage_data.get("Volumes", {}).get("@odata.id", "") if volumes_link: try: vol_url = f"{self._base_url}{volumes_link}" vol_response = self._session.get(vol_url, timeout=30) if vol_response.status_code == 200: vol_data = vol_response.json() for vol_member in vol_data.get("Members", []): vol_uri = vol_member.get("@odata.id", "") if vol_uri: volumes.append(vol_uri) except Exception as exc: logger.warning("Failed to get volumes: %s", exc) resources.append( DiscoveredResource( resource_type="bare_metal_raid_config", unique_id=f"{endpoint}:{storage_id}", name=storage_data.get("Name", f"Storage {storage_id}"), provider=ProviderType.BARE_METAL, platform_category=PlatformCategory.BARE_METAL, architecture=architecture, endpoint=endpoint, attributes={ "storage_controllers": [ ctrl.get("Name", "") for ctrl in storage_data.get( "StorageControllers", [] ) ], "drive_count": len(drives), "drives": drives, "volumes": volumes, "status": storage_data.get("Status", {}), }, ) ) return resources @staticmethod def _parse_architecture(proc_data: dict) -> CpuArchitecture: """Parse CPU architecture from Redfish processor data. Examines InstructionSet and Model fields to determine architecture. """ instruction_set = proc_data.get("InstructionSet", "").lower() model = proc_data.get("Model", "").lower() if "aarch64" in instruction_set or "arm" in instruction_set: return CpuArchitecture.AARCH64 if "arm" in model: if "64" in model or "aarch64" in model or "v8" in model: return CpuArchitecture.AARCH64 return CpuArchitecture.ARM # Default to AMD64 for x86/x86_64/IA-32e return CpuArchitecture.AMD64