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