feat: read-only IPMI driver; report a missing Redfish license (0.2.0)

A Supermicro X10DRL-i refuses Redfish without a license (SUM, DCMS or
OOB), even the service root. IPMI answers without one.

- New entry point `ipmi` (napalm_redfish.ipmi.IpmiDriver) on
  ManagementControllerDriver, over pyghmi: facts and inventory from the
  FRU (serial number, system UUID), the BMC's LAN interface, sensors
  (temperatures, fans, power supplies), the power state, the event log
  and the BMC firmware. Read-only: no power control, no BMC reset.
  Tested against recordings of the X10 with serials, the UUID, MACs and
  IPs replaced.
- RedfishDriver.open() no longer fails on a license refusal: get_facts
  returns what is known without Redfish, get_device_warnings reports
  bmc_license_required with the licenses the BMC names, every other
  getter raises. netOrk then shows the reason instead of an offline BMC.
- IpmiDriver is not exported from the package: NAPALM's own
  get_network_driver("redfish") takes the first driver class of
  napalm_redfish.
This commit is contained in:
christianmanivong committed 2026-10-13 10:53:51 +02:00
1 parent bfbeb19255
commit 332e6090a7
17 files changed
+1194 -19

No files matched your search

+7 -2
View File
@@ -1,6 +1,11 @@
"""NAPALM driver for server BMCs over DMTF Redfish."""
"""NAPALM drivers for server BMCs: DMTF Redfish, and read-only IPMI as the fallback.
Only ``RedfishDriver`` is exported here: NAPALM's own ``get_network_driver("redfish")``
takes the first driver class of this package. ``IpmiDriver`` lives in
``napalm_redfish.ipmi`` and is registered as the ``ipmi`` entry point.
"""
from napalm_redfish.driver import RedfishDriver
__version__ = "0.1.2"
__version__ = "0.2.0"
__all__ = ["RedfishDriver"]
+30 -1
View File
@@ -65,7 +65,12 @@ from napalm_redfish.parsers import (
to_napalm_users,
)
from napalm_redfish.parsers.common import link, link_list, odata_id
from napalm_redfish.transport import RedfishError, RedfishNotFound, RedfishTransport
from napalm_redfish.transport import (
RedfishError,
RedfishLicenseError,
RedfishNotFound,
RedfishTransport,
)
LOG = logging.getLogger(__name__)
@@ -152,12 +157,20 @@ class RedfishDriver(ManagementControllerDriver):
retries=self.optional_args.get("retries", 1),
)
self._cache: _JsonDict = {}
self._license_refused: RedfishLicenseError | None = None
# --- connection ----------------------------------------------------------------------
def open(self) -> None:
"""Connect. A BMC that refuses Redfish for want of a license is reachable all the
same: ``open`` succeeds, ``get_facts`` returns what is known without Redfish, and
``get_device_warnings`` says which license is missing. Every other getter raises.
"""
self._license_refused = None
try:
self.transport.open()
except RedfishLicenseError as exc:
self._license_refused = exc
except RedfishError as exc:
raise ConnectionException(f"Cannot connect to Redfish at {self.hostname}: {exc}") \
from exc
@@ -167,6 +180,8 @@ class RedfishDriver(ManagementControllerDriver):
self._cache = {}
def is_alive(self) -> _JsonDict:
if self._license_refused is not None:
return {"is_alive": True}
try:
self.transport.get("/redfish/v1/")
except Exception:
@@ -181,6 +196,8 @@ class RedfishDriver(ManagementControllerDriver):
return self._cache[key]
def _root(self) -> _JsonDict:
if self._license_refused is not None:
raise ConnectionException(f"{self.hostname}: {self._license_refused}")
return self._cached("root", lambda: parse_service_root(self.transport.root))
def _first(self, collection: str) -> _JsonDict:
@@ -233,6 +250,10 @@ class RedfishDriver(ManagementControllerDriver):
# --- NAPALM getters ----------------------------------------------------------------
def get_facts(self) -> _JsonDict:
if self._license_refused is not None:
return {"uptime": -1.0, "vendor": "", "model": "", "hostname": self.hostname,
"fqdn": self.hostname, "os_version": "", "serial_number": "",
"interface_list": []}
system = parse_system(self._system_raw())
manager = parse_manager(self._manager_raw())
interfaces = self._manager_interfaces()
@@ -329,6 +350,14 @@ class RedfishDriver(ManagementControllerDriver):
communities[community["name"]] = {"acl": "", "mode": mode}
return {"chassis_id": "", "community": communities, "contact": "", "location": ""}
def get_device_warnings(self) -> list[_JsonDict]:
"""Raw signals for netOrk's warning catalog; presentation is netOrk's."""
refused = self._license_refused
if refused is None:
return []
return [{"code": "bmc_license_required",
"meta": {"licenses": refused.licenses, "message": refused.detail}}]
# --- role: power -------------------------------------------------------------------
def _accepted_actions(self, system: _JsonDict) -> list[str]:
+221
View File
@@ -0,0 +1,221 @@
"""Read-only NAPALM driver for BMCs over IPMI 2.0, for boards that refuse or lack Redfish.
IPMI over LAN gives a password hash to anyone who names a valid user (CVE-2013-4786), so
this is the fallback, not the main path. It only reads: facts and inventory from the FRU,
sensors, the power state and the system event log. It neither switches the server's power
nor resets the BMC; those go through Redfish.
"""
from __future__ import annotations
from typing import Any
from napalm.base.exceptions import ConnectionException
from napalm_device_types import ManagementControllerDriver
from pyghmi.ipmi.command import Command
_JsonDict = dict[str, Any]
#: pyghmi's health bits (pyghmi.constants.Health).
_WARNING, _CRITICAL, _FAILED = 1, 2, 4
#: The only interface a BMC reports over IPMI: its own LAN channel.
_BMC = "bmc"
def _severity(health: int) -> str:
if health & (_CRITICAL | _FAILED):
return "Critical"
if health & _WARNING:
return "Warning"
return "OK"
def _supply_present(reading: Any) -> bool:
return "Present" in (getattr(reading, "states", None) or [])
class IpmiDriver(ManagementControllerDriver):
"""NAPALM driver for BMCs over IPMI 2.0 (lanplus), read-only."""
platform = "ipmi"
DRIVER_NAME = "ipmi"
#: IPMI is UDP; discovery probes no UDP, so this driver has no fingerprint.
default_port = 623
def __init__(
self,
hostname: str,
username: str,
password: str,
timeout: int = 60,
optional_args: _JsonDict | None = None,
) -> None:
self.hostname = hostname
self.username = username
self.password = password
self.timeout = timeout
self.optional_args: _JsonDict = optional_args or {}
self.port = int(self.optional_args.get("port") or self.default_port)
self._ipmi: Any = None
# --- connection ----------------------------------------------------------------------
def open(self) -> None:
try:
self._ipmi = Command(
bmc=self.hostname,
userid=self.username,
password=self.password,
port=self.port,
keepalive=False,
)
except Exception as exc:
raise ConnectionException(f"Cannot connect to IPMI at {self.hostname}: {exc}") \
from exc
def close(self) -> None:
ipmi, self._ipmi = self._ipmi, None
if ipmi is not None:
try:
ipmi.ipmi_session.logout()
except Exception: # a session left open times out on the BMC
pass
def is_alive(self) -> _JsonDict:
return {"is_alive": self._ipmi is not None}
def _cmd(self) -> Any:
if self._ipmi is None:
raise ConnectionException(f"{self.hostname}: not connected, call open() first")
return self._ipmi
# --- reads ---------------------------------------------------------------------------
def _system(self) -> _JsonDict:
"""The "System" FRU record: manufacturer, product, serial number, UUID."""
for name, record in self._cmd().get_inventory():
if name == "System":
return record or {}
return {}
def _bmc_version(self) -> str:
for name, info in self._cmd().get_firmware():
if name == "BMC Version":
return str(info.get("version") or "")
return ""
def _sensors(self) -> list[Any]:
return list(self._cmd().get_sensor_data())
# --- NAPALM getters ------------------------------------------------------------------
def get_facts(self) -> _JsonDict:
system = self._system()
return {
"uptime": -1.0, # IPMI has no uptime
"vendor": system.get("Manufacturer") or "",
"model": system.get("Product name") or system.get("Model") or "",
"hostname": self.hostname,
"fqdn": self.hostname,
"os_version": self._bmc_version(),
"serial_number": system.get("Serial Number") or "",
"interface_list": [_BMC],
}
def get_interfaces(self) -> _JsonDict:
net = self._cmd().get_net_configuration()
source = net.get("ipv4_configuration") or ""
return {
_BMC: {
"is_up": True,
"is_enabled": True,
"description": f"BMC LAN ({source})" if source else "BMC LAN",
"last_flapped": -1.0,
"speed": -1.0,
"mtu": -1,
"mac_address": (net.get("mac_address") or "").lower(),
}
}
def get_interfaces_ip(self) -> _JsonDict:
address = self._cmd().get_net_configuration().get("ipv4_address") or ""
if "/" not in address:
return {}
ip, prefix = address.split("/", 1)
return {_BMC: {"ipv4": {ip: {"prefix_length": int(prefix)}}}}
def get_environment(self) -> _JsonDict:
"""Fans, temperatures and present power supplies from the sensor records; ``cpu`` and
``memory`` are empty, since a BMC does not know the host OS's load."""
fans: _JsonDict = {}
temperature: _JsonDict = {}
power: _JsonDict = {}
for reading in self._sensors():
health = int(getattr(reading, "health", 0) or 0)
value = getattr(reading, "value", None)
if reading.type == "Temperature" and value is not None:
temperature[reading.name] = {
"temperature": float(value),
"is_alert": bool(health),
"is_critical": bool(health & (_CRITICAL | _FAILED)),
}
elif reading.type == "Fan" and value is not None:
fans[reading.name] = {"status": value > 0 and not health}
elif reading.type == "Power Supply" and _supply_present(reading):
power[reading.name] = {"status": not health, "capacity": -1.0, "output": -1.0}
return {"fans": fans, "temperature": temperature, "power": power, "cpu": {}, "memory": {}}
# --- role methods --------------------------------------------------------------------
def get_power_state(self) -> _JsonDict:
state = str(self._cmd().get_power().get("powerstate") or "")
# Read-only: no action is offered.
return {"state": state.capitalize(), "actions": []}
def get_hardware_inventory(self) -> _JsonDict:
"""What the FRU and the sensor records tell; IPMI lists no CPUs, DIMMs or drives."""
system = self._system()
supplies = [
{
"slot": reading.name.removesuffix(" Status"),
"model": "",
"serial_number": "",
"capacity_watts": None,
"status": _severity(int(reading.health or 0))
if _supply_present(reading) else "Absent",
}
for reading in self._sensors()
if reading.type == "Power Supply"
]
return {
"manufacturer": system.get("Manufacturer") or "",
"model": system.get("Product name") or system.get("Model") or "",
"serial_number": system.get("Serial Number") or "",
"system_uuid": str(system.get("UUID") or "").lower(),
"cpus": [],
"memory": [],
"drives": [],
"power_supplies": supplies,
"nics": [],
}
def get_event_log(self, limit: int = 500) -> list[_JsonDict]:
"""The system event log, newest first."""
entries = []
for event in self._cmd().get_event_log():
component = event.get("component") or ""
text = event.get("event") or ""
timecode = event.get("timecode")
entries.append({
"id": str(event.get("record_id", "")),
"timestamp": float(timecode) if timecode else None,
"severity": _severity(int(event.get("severity") or 0)),
"message": f"{component}: {text}" if component and text else component or text,
})
entries.sort(key=lambda e: (e["timestamp"] is not None, e["timestamp"] or 0.0),
reverse=True)
return entries[:limit]
def get_firmware_inventory(self) -> list[_JsonDict]:
version = self._bmc_version()
return [{"name": "BMC", "version": version, "updateable": None}] if version else []
+23 -1
View File
@@ -61,6 +61,13 @@ class RedfishError(Exception):
class RedfishLicenseError(RedfishError):
"""The service refuses Redfish until a vendor license is activated."""
def __init__(self, message: str, *, licenses: str = "", detail: str = ""):
super().__init__(message)
#: The licenses the service names, e.g. "SUM DCMS OOB"; empty when it names none.
self.licenses = licenses
#: The service's own message.
self.detail = detail
class RedfishAuthError(RedfishError):
"""Login failed, or the account may not read the resource (HTTP 401/403)."""
@@ -74,6 +81,19 @@ class RedfishNotFound(RedfishHTTPError):
"""The resource does not exist (HTTP 404)."""
def _message_args(body: Any) -> list[str]:
"""The MessageArgs of a Redfish error body's extended info, in order."""
error = body.get("error") if isinstance(body, Mapping) else None
if not isinstance(error, Mapping):
return []
return [
str(arg)
for info in error.get("@Message.ExtendedInfo") or []
if isinstance(info, Mapping)
for arg in info.get("MessageArgs") or []
]
def _message_ids(body: Any) -> list[str]:
"""The MessageIds of a Redfish error body's extended info."""
error = body.get("error") if isinstance(body, Mapping) else None
@@ -202,7 +222,9 @@ class RedfishTransport:
detail = _error_message(body)
if any(message_id.endswith(LICENSE_REFUSED) for message_id in _message_ids(body)):
return RedfishLicenseError(
f"{self.base_url}: Redfish needs a vendor license on this BMC: {detail}"
f"{self.base_url}: Redfish needs a vendor license on this BMC: {detail}",
licenses=" ".join(" ".join(_message_args(body)).split()),
detail=detail,
)
return RedfishError(f"{self.base_url}: {reason}" + (f": {detail}" if detail else ""))