498 lines
19 KiB
Python
498 lines
19 KiB
Python
"""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
|