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

+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 ""))