fix: read a Supermicro X10 with only the OOB license over Redfish (0.3.1)

First run against real hardware over Redfish: an X10DRL-i (BMC 3.91) with
SFT-OOB-LIC. Its Redfish tree is recorded under
tests/fixtures/supermicro-x10drl-i-3.91/redfish, scrubbed.

- A resource refused for want of a further license (403 OemLicenseNotPassed)
  no longer fails the getter. Storage then falls back to SimpleStorage. The
  firmware list falls back to the BMC and BIOS versions when UpdateService
  is withheld.
- get_device_warnings reports bmc_license_partial with the licenses and the
  withheld parts (drives, firmware inventory).
- A placeholder system model ("Super Server") is replaced by the board
  (PartNumber, else the chassis model).
This commit is contained in:
Christian Manivong committed 2026-10-17 09:32:09 +02:00
1 parent 6c60f7ac60
commit adf152ee12
78 files changed
+3237 -24

No files matched your search

+1 -1
View File
@@ -7,5 +7,5 @@ takes the first driver class of this package. ``IpmiDriver`` lives in
from napalm_redfish.driver import RedfishDriver
__version__ = "0.3.0"
__version__ = "0.3.1"
__all__ = ["RedfishDriver"]
+94 -19
View File
@@ -67,6 +67,7 @@ from napalm_redfish.parsers import (
from napalm_redfish.parsers.common import link, link_list, odata_id
from napalm_redfish.transport import (
RedfishError,
RedfishFeatureLicenseError,
RedfishLicenseError,
RedfishNotFound,
RedfishTransport,
@@ -87,6 +88,11 @@ _PROTOCOLS = {
"virtual_media": "VirtualMedia",
}
_SEVERITIES = ("OK", "Warning", "Critical")
#: The parts a BMC may withhold for want of a license, in the order they are named.
_WITHHELD_ORDER = ("drives", "firmware inventory")
#: Models a system reports when it has none of its own. Supermicro's Redfish calls every
#: system "Super Server"; the board is the system's PartNumber and the chassis model.
_PLACEHOLDER_MODELS = frozenset({"super server", "default string", "to be filled by o.e.m."})
def _timestamp(created: str | None) -> float | None:
@@ -162,6 +168,8 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
)
self._cache: _JsonDict = {}
self._license_refused: RedfishLicenseError | None = None
#: Parts the BMC withholds for want of a license, with the licenses it names.
self._withheld: dict[str, str] = {}
# --- connection ----------------------------------------------------------------------
@@ -171,6 +179,7 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
``get_device_warnings`` says which license is missing. Every other getter raises.
"""
self._license_refused = None
self._withheld = {}
try:
self.transport.open()
except RedfishLicenseError as exc:
@@ -234,6 +243,15 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
def _chassis_raw(self) -> _JsonDict:
return self._linked_or_first("chassis", "Chassis", "Chassis")
def _server_model(self) -> str:
"""The system's model; for a placeholder, the board (PartNumber or chassis model)."""
raw = self._system_raw()
model = (parse_system(raw)["model"] or "").strip()
if model.lower() not in _PLACEHOLDER_MODELS:
return model
board = raw.get("PartNumber") or self._chassis_raw().get("Model") or ""
return str(board).strip() or model
def _members_of(self, resource: _JsonDict, key: str) -> list[_JsonDict]:
"""Members of the collection ``resource[key]`` links; [] if not linked."""
path = link(resource, key)
@@ -271,7 +289,7 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
return {
"uptime": -1.0, # Redfish has no uptime
"vendor": system["manufacturer"] or "",
"model": system["model"] or "",
"model": self._server_model(),
"hostname": hostname,
"fqdn": fqdn,
"os_version": manager["firmware_version"] or "",
@@ -355,12 +373,41 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
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."""
"""Raw signals for netOrk's warning catalog; presentation is netOrk's.
``bmc_license_required`` when the BMC refuses Redfish altogether;
``bmc_license_partial`` when it answers but withholds parts (drives, the firmware
inventory) for want of a license. The parts are probed here, so the warning does
not depend on which getters ran before.
"""
refused = self._license_refused
if refused is None:
if refused is not None:
return [{"code": "bmc_license_required",
"meta": {"licenses": refused.licenses, "message": refused.detail}}]
self._probe_withheld()
if not self._withheld:
return []
return [{"code": "bmc_license_required",
"meta": {"licenses": refused.licenses, "message": refused.detail}}]
licenses = sorted({name for names in self._withheld.values() for name in names.split()})
parts = [part for part in _WITHHELD_ORDER if part in self._withheld]
return [{"code": "bmc_license_partial",
"meta": {"licenses": " ".join(licenses), "parts": parts}}]
def _withhold(self, part: str, exc: RedfishFeatureLicenseError) -> None:
LOG.info("%s: %s withheld by the BMC: %s", self.hostname, part, exc)
self._withheld[part] = exc.licenses
def _probe_withheld(self) -> None:
"""Ask for the license-gated resources once, so ``_withheld`` is complete."""
storage = link(self._system_raw(), "Storage")
if storage and "drives" not in self._withheld:
try:
self.transport.get(storage)
except RedfishFeatureLicenseError as exc:
self._withhold("drives", exc)
except RedfishError:
pass
if "firmware inventory" not in self._withheld:
self._update_service()
# --- role: power -------------------------------------------------------------------
@@ -434,7 +481,7 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
system = parse_system(raw)
return {
"manufacturer": system["manufacturer"] or "",
"model": system["model"] or "",
"model": self._server_model(),
"serial_number": system["serial_number"] or "",
"system_uuid": (system["uuid"] or "").lower(),
"cpus": self._cpus(raw),
@@ -471,18 +518,31 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
})
return dimms
def _drives(self, system: _JsonDict) -> list[_JsonDict]:
def _storage_drives(self, system: _JsonDict) -> list[_JsonDict] | None:
"""Drives from ``Storage``; ``None`` when it is not linked or withheld."""
if not link(system, "Storage"):
return None
try:
storages = self._members_of(system, "Storage")
except RedfishFeatureLicenseError as exc:
self._withhold("drives", exc)
return None
parsed: list[_JsonDict] = []
if link(system, "Storage"):
seen: set[str] = set()
for storage in self._members_of(system, "Storage"):
for path in parse_storage(storage)["drive_paths"]:
if path not in seen:
seen.add(path)
drive = self.transport.get_optional(path)
if drive is not None:
parsed.append(parse_drive(drive))
else:
seen: set[str] = set()
for storage in storages:
for path in parse_storage(storage)["drive_paths"]:
if path not in seen:
seen.add(path)
drive = self.transport.get_optional(path)
if drive is not None:
parsed.append(parse_drive(drive))
return parsed
def _drives(self, system: _JsonDict) -> list[_JsonDict]:
parsed = self._storage_drives(system)
if parsed is None:
# Older services, and a Supermicro X10 that withholds Storage without DCMS.
parsed = []
for storage in self._members_of(system, "SimpleStorage"):
parsed.extend(parse_simple_storage(storage)["drives"])
return [{
@@ -569,10 +629,25 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
reverse=True)
return entries[:limit]
def _update_service(self) -> _JsonDict | None:
try:
return self._service("UpdateService")
except RedfishFeatureLicenseError as exc:
self._withhold("firmware inventory", exc)
return None
def get_firmware_inventory(self) -> list[_JsonDict]:
service = self._service("UpdateService")
"""``UpdateService/FirmwareInventory``. Where the BMC withholds it for want of a
license, the BMC's own firmware and the BIOS, which the manager and the system
state themselves."""
service = self._update_service()
if service is None:
return []
if "firmware inventory" not in self._withheld:
return []
bmc = parse_manager(self._manager_raw())["firmware_version"]
bios = self._system_raw().get("BiosVersion")
return [{"name": name, "version": version, "updateable": None}
for name, version in (("BMC", bmc), ("BIOS", bios)) if version]
items = [parse_firmware(raw) for raw in self._members_of(service, "FirmwareInventory")]
return [{"name": fw["name"] or fw["id"] or "", "version": fw["version"] or "",
"updateable": fw["updateable"]} for fw in items]
+18
View File
@@ -73,6 +73,20 @@ class RedfishAuthError(RedfishError):
"""Login failed, or the account may not read the resource (HTTP 401/403)."""
class RedfishFeatureLicenseError(RedfishAuthError):
"""One resource is refused until a vendor license is activated, the rest answers.
A Supermicro X10 with only the OOB license reads ``Systems`` and ``Managers`` but
refuses ``Systems/1/Storage`` and ``UpdateService`` for want of DCMS.
"""
def __init__(self, message: str, *, licenses: str = "", status: int | None = None,
path: str | None = None):
super().__init__(message, status=status, path=path)
#: The licenses the service names, e.g. "DCMS"; empty when it names none.
self.licenses = licenses
class RedfishHTTPError(RedfishError):
"""The service answered with an HTTP error status."""
@@ -336,6 +350,10 @@ class RedfishTransport:
return data
detail = _error_message(data)
message = f"{method} {path}: HTTP {status}" + (f": {detail}" if detail else "")
if status == 403 and any(i.endswith(LICENSE_REFUSED) for i in _message_ids(data)):
raise RedfishFeatureLicenseError(
message, licenses=" ".join(" ".join(_message_args(data)).split()),
status=status, path=path)
if status in (401, 403):
raise RedfishAuthError(message, status=status, path=path)
if status == 404: