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:
1 parent
bfbeb19255
commit
332e6090a7
17 files changed
+1193
-18
No files matched your search
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
|
## [0.2.0] - 2026-10-13
|
||||||
|
|
||||||
|
### Added
|
||||||
|
- `ipmi` NAPALM driver (`IpmiDriver`) on `ManagementControllerDriver`, read-only, over IPMI
|
||||||
|
2.0 with `pyghmi`: facts and inventory from the FRU (manufacturer, product, serial number,
|
||||||
|
system UUID), the BMC's LAN interface, temperatures, fans and power supplies from the
|
||||||
|
sensor records, the power state, the system event log and the BMC firmware version. No
|
||||||
|
power control and no BMC reset. For BMCs that refuse or lack Redfish; IPMI over LAN leaks
|
||||||
|
password hashes (CVE-2013-4786), so it is the fallback, not the main path.
|
||||||
|
- Recorded IPMI fixtures from a Supermicro X10DRL-i (BMC 3.91).
|
||||||
|
|
||||||
|
### Changed
|
||||||
|
- A BMC that refuses Redfish for want of a vendor license no longer fails `open()`.
|
||||||
|
`get_facts` returns what is known without Redfish, `get_device_warnings` reports
|
||||||
|
`bmc_license_required` with the licenses the BMC names, and every other getter raises
|
||||||
|
`ConnectionException`. A poll therefore reports the missing license instead of an
|
||||||
|
offline BMC.
|
||||||
|
|
||||||
## [0.1.2] - 2026-10-13
|
## [0.1.2] - 2026-10-13
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|||||||
@@ -5,11 +5,17 @@ NAPALM driver for server **baseboard management controllers (BMCs)** over
|
|||||||
boards that predate Redfish. Built on
|
boards that predate Redfish. Built on
|
||||||
[napalm-device-types](https://git.netork.io/NAPALM/napalm-device-types). Written for netOrk.
|
[napalm-device-types](https://git.netork.io/NAPALM/napalm-device-types). Written for netOrk.
|
||||||
|
|
||||||
**Status: early development.** The `redfish` driver (`napalm_redfish.RedfishDriver`, a
|
**Status: early development.** Two drivers, both `ManagementControllerDriver`s from
|
||||||
`ManagementControllerDriver` from napalm-device-types) implements the NAPALM getters and
|
napalm-device-types:
|
||||||
the role methods in [docs/redfish-map.md](docs/redfish-map.md), tested end to end against
|
|
||||||
DMTF mockups. It has not yet run against real hardware; the first test device is a
|
- `redfish` (`napalm_redfish.RedfishDriver`) implements the NAPALM getters and the role
|
||||||
Supermicro X10DRL-i (BMC firmware 3.91, Redfish). The `ipmi` fallback does not exist yet.
|
methods in [docs/redfish-map.md](docs/redfish-map.md), tested end to end against DMTF
|
||||||
|
mockups. A BMC that refuses Redfish for want of a vendor license (a Supermicro X10
|
||||||
|
without SUM, DCMS or OOB) still opens: the driver reports `bmc_license_required` through
|
||||||
|
`get_device_warnings` instead of failing.
|
||||||
|
- `ipmi` (`napalm_redfish.ipmi.IpmiDriver`) reads over IPMI 2.0 only: FRU facts and
|
||||||
|
inventory, sensors, power state, event log, BMC firmware. Tested against recordings of a
|
||||||
|
Supermicro X10DRL-i (BMC 3.91), which refuses Redfish without a license.
|
||||||
|
|
||||||
## Why Redfish, and why not one driver per vendor
|
## Why Redfish, and why not one driver per vendor
|
||||||
|
|
||||||
@@ -23,7 +29,7 @@ RAKP handshake hands the password hash to anyone who asks (CVE-2013-4786), so ne
|
|||||||
not depend on it. Finding IPMI over LAN enabled is itself worth reporting, see
|
not depend on it. Finding IPMI over LAN enabled is itself worth reporting, see
|
||||||
[docs/security.md](docs/security.md).
|
[docs/security.md](docs/security.md).
|
||||||
|
|
||||||
## Layout (planned)
|
## Layout
|
||||||
|
|
||||||
| Entry point | Transport | Scope |
|
| Entry point | Transport | Scope |
|
||||||
|---|---|---|
|
|---|---|---|
|
||||||
|
|||||||
@@ -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
|
from napalm_redfish.driver import RedfishDriver
|
||||||
|
|
||||||
__version__ = "0.1.2"
|
__version__ = "0.2.0"
|
||||||
__all__ = ["RedfishDriver"]
|
__all__ = ["RedfishDriver"]
|
||||||
@@ -65,7 +65,12 @@ from napalm_redfish.parsers import (
|
|||||||
to_napalm_users,
|
to_napalm_users,
|
||||||
)
|
)
|
||||||
from napalm_redfish.parsers.common import link, link_list, odata_id
|
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__)
|
LOG = logging.getLogger(__name__)
|
||||||
|
|
||||||
@@ -152,12 +157,20 @@ class RedfishDriver(ManagementControllerDriver):
|
|||||||
retries=self.optional_args.get("retries", 1),
|
retries=self.optional_args.get("retries", 1),
|
||||||
)
|
)
|
||||||
self._cache: _JsonDict = {}
|
self._cache: _JsonDict = {}
|
||||||
|
self._license_refused: RedfishLicenseError | None = None
|
||||||
|
|
||||||
# --- connection ----------------------------------------------------------------------
|
# --- connection ----------------------------------------------------------------------
|
||||||
|
|
||||||
def open(self) -> None:
|
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:
|
try:
|
||||||
self.transport.open()
|
self.transport.open()
|
||||||
|
except RedfishLicenseError as exc:
|
||||||
|
self._license_refused = exc
|
||||||
except RedfishError as exc:
|
except RedfishError as exc:
|
||||||
raise ConnectionException(f"Cannot connect to Redfish at {self.hostname}: {exc}") \
|
raise ConnectionException(f"Cannot connect to Redfish at {self.hostname}: {exc}") \
|
||||||
from exc
|
from exc
|
||||||
@@ -167,6 +180,8 @@ class RedfishDriver(ManagementControllerDriver):
|
|||||||
self._cache = {}
|
self._cache = {}
|
||||||
|
|
||||||
def is_alive(self) -> _JsonDict:
|
def is_alive(self) -> _JsonDict:
|
||||||
|
if self._license_refused is not None:
|
||||||
|
return {"is_alive": True}
|
||||||
try:
|
try:
|
||||||
self.transport.get("/redfish/v1/")
|
self.transport.get("/redfish/v1/")
|
||||||
except Exception:
|
except Exception:
|
||||||
@@ -181,6 +196,8 @@ class RedfishDriver(ManagementControllerDriver):
|
|||||||
return self._cache[key]
|
return self._cache[key]
|
||||||
|
|
||||||
def _root(self) -> _JsonDict:
|
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))
|
return self._cached("root", lambda: parse_service_root(self.transport.root))
|
||||||
|
|
||||||
def _first(self, collection: str) -> _JsonDict:
|
def _first(self, collection: str) -> _JsonDict:
|
||||||
@@ -233,6 +250,10 @@ class RedfishDriver(ManagementControllerDriver):
|
|||||||
# --- NAPALM getters ----------------------------------------------------------------
|
# --- NAPALM getters ----------------------------------------------------------------
|
||||||
|
|
||||||
def get_facts(self) -> _JsonDict:
|
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())
|
system = parse_system(self._system_raw())
|
||||||
manager = parse_manager(self._manager_raw())
|
manager = parse_manager(self._manager_raw())
|
||||||
interfaces = self._manager_interfaces()
|
interfaces = self._manager_interfaces()
|
||||||
@@ -329,6 +350,14 @@ class RedfishDriver(ManagementControllerDriver):
|
|||||||
communities[community["name"]] = {"acl": "", "mode": mode}
|
communities[community["name"]] = {"acl": "", "mode": mode}
|
||||||
return {"chassis_id": "", "community": communities, "contact": "", "location": ""}
|
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 -------------------------------------------------------------------
|
# --- role: power -------------------------------------------------------------------
|
||||||
|
|
||||||
def _accepted_actions(self, system: _JsonDict) -> list[str]:
|
def _accepted_actions(self, system: _JsonDict) -> list[str]:
|
||||||
|
|||||||
@@ -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 []
|
||||||
@@ -61,6 +61,13 @@ class RedfishError(Exception):
|
|||||||
class RedfishLicenseError(RedfishError):
|
class RedfishLicenseError(RedfishError):
|
||||||
"""The service refuses Redfish until a vendor license is activated."""
|
"""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):
|
class RedfishAuthError(RedfishError):
|
||||||
"""Login failed, or the account may not read the resource (HTTP 401/403)."""
|
"""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)."""
|
"""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]:
|
def _message_ids(body: Any) -> list[str]:
|
||||||
"""The MessageIds of a Redfish error body's extended info."""
|
"""The MessageIds of a Redfish error body's extended info."""
|
||||||
error = body.get("error") if isinstance(body, Mapping) else None
|
error = body.get("error") if isinstance(body, Mapping) else None
|
||||||
@@ -202,7 +222,9 @@ class RedfishTransport:
|
|||||||
detail = _error_message(body)
|
detail = _error_message(body)
|
||||||
if any(message_id.endswith(LICENSE_REFUSED) for message_id in _message_ids(body)):
|
if any(message_id.endswith(LICENSE_REFUSED) for message_id in _message_ids(body)):
|
||||||
return RedfishLicenseError(
|
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 ""))
|
return RedfishError(f"{self.base_url}: {reason}" + (f": {detail}" if detail else ""))
|
||||||
|
|
||||||
|
|||||||
+3
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "napalm-redfish"
|
name = "napalm-redfish"
|
||||||
version = "0.1.2"
|
version = "0.2.0"
|
||||||
description = "NAPALM driver for server baseboard management controllers over DMTF Redfish"
|
description = "NAPALM driver for server baseboard management controllers over DMTF Redfish"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
@@ -14,6 +14,7 @@ dependencies = [
|
|||||||
# Not on PyPI: install from git before this package (see README).
|
# Not on PyPI: install from git before this package (see README).
|
||||||
"napalm-device-types>=4.8",
|
"napalm-device-types>=4.8",
|
||||||
"redfish>=3.2",
|
"redfish>=3.2",
|
||||||
|
"pyghmi>=1.5",
|
||||||
]
|
]
|
||||||
classifiers = [
|
classifiers = [
|
||||||
"License :: OSI Approved :: Apache Software License",
|
"License :: OSI Approved :: Apache Software License",
|
||||||
@@ -30,6 +31,7 @@ test = [
|
|||||||
|
|
||||||
[project.entry-points."napalm.drivers"]
|
[project.entry-points."napalm.drivers"]
|
||||||
redfish = "napalm_redfish:RedfishDriver"
|
redfish = "napalm_redfish:RedfishDriver"
|
||||||
|
ipmi = "napalm_redfish.ipmi:IpmiDriver"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["napalm_redfish*"]
|
include = ["napalm_redfish*"]
|
||||||
|
|||||||
@@ -4,3 +4,9 @@ without a Supermicro license. Requests were made without credentials.
|
|||||||
service_root_403.json GET /redfish/v1/ -> HTTP 403, Content-Type application/json
|
service_root_403.json GET /redfish/v1/ -> HTTP 403, Content-Type application/json
|
||||||
landing_page.html GET / -> HTTP 200, the BMC web login page (trimmed to
|
landing_page.html GET / -> HTTP 200, the BMC web login page (trimmed to
|
||||||
the head and the login form action)
|
the head and the login form action)
|
||||||
|
|
||||||
|
ipmi/ Read over IPMI 2.0 (pyghmi 1.6, lanplus) with a BMC account, 2026-10-13. One file per
|
||||||
|
pyghmi call: power (get_power), net_configuration (get_net_configuration),
|
||||||
|
firmware (get_firmware), inventory (get_inventory), sensors (get_sensor_data,
|
||||||
|
each SensorReading's attributes), health (get_health), event_log (get_event_log).
|
||||||
|
Serial numbers, the system UUID, MAC addresses and IPs are replaced by placeholders.
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"record_id": 1,
|
||||||
|
"timecode": 1790788051,
|
||||||
|
"component_id": "32.201.0",
|
||||||
|
"component": "PS2 Status",
|
||||||
|
"deassertion": true,
|
||||||
|
"component_type_id": 8,
|
||||||
|
"event_id": "111.1",
|
||||||
|
"component_type": "Power Supply",
|
||||||
|
"event": "Recovered",
|
||||||
|
"severity": 0,
|
||||||
|
"oem_handler": null,
|
||||||
|
"timestamp": "2026-09-30T15:07:31"
|
||||||
|
}
|
||||||
|
]
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
[
|
||||||
|
[
|
||||||
|
"BMC Version",
|
||||||
|
{
|
||||||
|
"version": "3.91"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"badreadings": [],
|
||||||
|
"health": 0
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
[
|
||||||
|
[
|
||||||
|
"System",
|
||||||
|
{
|
||||||
|
"Board manufacture date": null,
|
||||||
|
"Board manufacturer": "Supermicro",
|
||||||
|
"Board product name": "X10DRL-i",
|
||||||
|
"Board serial number": "ZM000S000001",
|
||||||
|
"Board model": "X10DRL-i",
|
||||||
|
"Board FRU Id": null,
|
||||||
|
"board_extra": [],
|
||||||
|
"Manufacturer": "Supermicro",
|
||||||
|
"Product name": "X10DRL-i",
|
||||||
|
"Model": "X10DRL-i",
|
||||||
|
"Hardware Version": null,
|
||||||
|
"Serial Number": "ZM000S000001",
|
||||||
|
"Asset Number": null,
|
||||||
|
"FRU ID": null,
|
||||||
|
"product_extra": [],
|
||||||
|
"firmware_version": "3.91",
|
||||||
|
"Device ID": 32,
|
||||||
|
"Device Revision": 1,
|
||||||
|
"Manufacturer ID": 10876,
|
||||||
|
"Product ID": 2097,
|
||||||
|
"oem_parser": null,
|
||||||
|
"UUID": "00000000-534D-C40C-0000-000000000001"
|
||||||
|
}
|
||||||
|
],
|
||||||
|
[
|
||||||
|
"BMC FRU",
|
||||||
|
{
|
||||||
|
"Board manufacture date": null,
|
||||||
|
"Board manufacturer": "Supermicro",
|
||||||
|
"Board product name": "X10DRL-i",
|
||||||
|
"Board serial number": "ZM000S000001",
|
||||||
|
"Board model": "X10DRL-i",
|
||||||
|
"Board FRU Id": null,
|
||||||
|
"board_extra": [],
|
||||||
|
"Manufacturer": "Supermicro",
|
||||||
|
"Product name": "X10DRL-i",
|
||||||
|
"Model": "X10DRL-i",
|
||||||
|
"Hardware Version": null,
|
||||||
|
"Serial Number": "ZM000S000001",
|
||||||
|
"Asset Number": null,
|
||||||
|
"FRU ID": null,
|
||||||
|
"product_extra": [],
|
||||||
|
"oem_parser": null
|
||||||
|
}
|
||||||
|
]
|
||||||
|
]
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"ipv4_address": "192.0.2.150/24",
|
||||||
|
"ipv4_configuration": "DHCP",
|
||||||
|
"mac_address": "0c:c4:7a:00:00:01",
|
||||||
|
"ipv4_gateway": "192.0.2.1",
|
||||||
|
"ipv4_backup_gateway": null,
|
||||||
|
"ipv4_gateway_mac": "02:00:00:00:00:01",
|
||||||
|
"ipv4_backup_gateway_mac": null,
|
||||||
|
"vlan_id": "off"
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
{
|
||||||
|
"powerstate": "on"
|
||||||
|
}
|
||||||
@@ -0,0 +1,498 @@
|
|||||||
|
[
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 34.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "CPU1 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 37.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "CPU2 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 28.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "PCH Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 18.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "System Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 38.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "Peripheral Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 29.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "Vcpu1VRM Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 33.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "Vcpu2VRM Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 24.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "VmemABVRM Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 22.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "VmemCDVRM Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 35.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "VmemEFVRM Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 30.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "VmemGHVRM Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 25.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "P1-DIMMA1 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 24.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "P1-DIMMB1 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 25.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "P1-DIMMC1 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 22.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "P1-DIMMD1 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 26.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "P2-DIMME1 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 26.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "P2-DIMMF1 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 27.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "P2-DIMMG1 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Temperature",
|
||||||
|
"value": 27.0,
|
||||||
|
"imprecision": 0.5,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "°C",
|
||||||
|
"name": "P2-DIMMH1 Temp"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Fan",
|
||||||
|
"value": 6300.0,
|
||||||
|
"imprecision": 50.0,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "RPM",
|
||||||
|
"name": "FAN2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Fan",
|
||||||
|
"value": 6900.0,
|
||||||
|
"imprecision": 50.0,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "RPM",
|
||||||
|
"name": "FAN3"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Fan",
|
||||||
|
"value": 6400.0,
|
||||||
|
"imprecision": 50.0,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "RPM",
|
||||||
|
"name": "FAN4"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Fan",
|
||||||
|
"value": 5100.0,
|
||||||
|
"imprecision": 50.0,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "RPM",
|
||||||
|
"name": "FANB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 12.0,
|
||||||
|
"imprecision": 0.03149999999999942,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "12V"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 4.922000000000001,
|
||||||
|
"imprecision": 0.013000000000000789,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "5VCC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 3.35,
|
||||||
|
"imprecision": 0.008500000000000174,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "3.3VCC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 2.69,
|
||||||
|
"imprecision": 0.0129999999999999,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "VBAT"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 1.818,
|
||||||
|
"imprecision": 0.0044999999999999485,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "Vcpu1"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 1.818,
|
||||||
|
"imprecision": 0.0044999999999999485,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "Vcpu2"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 1.2000000000000002,
|
||||||
|
"imprecision": 0.0045000000000001705,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "VDIMMAB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 1.2000000000000002,
|
||||||
|
"imprecision": 0.0045000000000001705,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "VDIMMCD"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 1.2000000000000002,
|
||||||
|
"imprecision": 0.0045000000000001705,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "VDIMMEF"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 1.2000000000000002,
|
||||||
|
"imprecision": 0.0045000000000001705,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "VDIMMGH"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 4.896,
|
||||||
|
"imprecision": 0.0129999999999999,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "5VSB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 3.197,
|
||||||
|
"imprecision": 0.008500000000000174,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "3.3VSB"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 1.5,
|
||||||
|
"imprecision": 0.0044999999999999485,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "1.5V PCH"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 1.209,
|
||||||
|
"imprecision": 0.0044999999999999485,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "1.2V BMC"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Voltage",
|
||||||
|
"value": 1.05,
|
||||||
|
"imprecision": 0.0044999999999999485,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "V",
|
||||||
|
"name": "1.05V PCH"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Physical Security",
|
||||||
|
"value": null,
|
||||||
|
"imprecision": null,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "",
|
||||||
|
"name": "Chassis Intru"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Power Supply",
|
||||||
|
"value": null,
|
||||||
|
"imprecision": null,
|
||||||
|
"states": [
|
||||||
|
"Present"
|
||||||
|
],
|
||||||
|
"state_ids": [
|
||||||
|
552704
|
||||||
|
],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "",
|
||||||
|
"name": "PS1 Status"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"broken_sensor_ids": {},
|
||||||
|
"health": 0,
|
||||||
|
"type": "Power Supply",
|
||||||
|
"value": null,
|
||||||
|
"imprecision": null,
|
||||||
|
"states": [],
|
||||||
|
"state_ids": [],
|
||||||
|
"unavailable": 0,
|
||||||
|
"units": "",
|
||||||
|
"name": "PS2 Status"
|
||||||
|
}
|
||||||
|
]
|
||||||
+36
-7
@@ -490,14 +490,43 @@ def test_get_management_protocols_missing(driver, server):
|
|||||||
assert protocols["ssh"] == {"enabled": False, "port": None}
|
assert protocols["ssh"] == {"enabled": False, "port": None}
|
||||||
|
|
||||||
|
|
||||||
def test_unlicensed_bmc_says_so(server):
|
def _unlicensed(server):
|
||||||
# A Supermicro X10 refuses Redfish entirely until a license is activated.
|
# A Supermicro X10 refuses Redfish entirely until a license is activated.
|
||||||
x10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91"
|
x10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91"
|
||||||
body = json.loads((x10 / "service_root_403.json").read_text())
|
server.override("GET", "/redfish/v1/", 403,
|
||||||
server.override("GET", "/redfish/v1/", 403, body)
|
json.loads((x10 / "service_root_403.json").read_text()))
|
||||||
d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False})
|
d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False})
|
||||||
with pytest.raises(ConnectionException) as exc:
|
|
||||||
d.open()
|
d.open()
|
||||||
message = str(exc.value)
|
return d
|
||||||
assert "license" in message.lower()
|
|
||||||
assert "SUM DCMS OOB" in message
|
|
||||||
|
def test_unlicensed_bmc_still_opens(server):
|
||||||
|
# Reachable, just refusing: the poll goes through and says why instead of
|
||||||
|
# reporting the BMC offline.
|
||||||
|
d = _unlicensed(server)
|
||||||
|
assert d.is_alive() == {"is_alive": True}
|
||||||
|
assert d.get_facts() == {
|
||||||
|
"uptime": -1.0, "vendor": "", "model": "", "hostname": "bmc.example.net",
|
||||||
|
"fqdn": "bmc.example.net", "os_version": "", "serial_number": "", "interface_list": [],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlicensed_bmc_reports_the_missing_license(server):
|
||||||
|
(warning,) = _unlicensed(server).get_device_warnings()
|
||||||
|
assert warning["code"] == "bmc_license_required"
|
||||||
|
assert warning["meta"]["licenses"] == "SUM DCMS OOB"
|
||||||
|
assert "Not licensed" in warning["meta"]["message"]
|
||||||
|
# Raw signal only: presentation is netOrk's.
|
||||||
|
assert set(warning) == {"code", "meta"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_unlicensed_bmc_refuses_everything_else(server):
|
||||||
|
d = _unlicensed(server)
|
||||||
|
with pytest.raises(ConnectionException, match="license"):
|
||||||
|
d.get_interfaces()
|
||||||
|
with pytest.raises(ConnectionException, match="license"):
|
||||||
|
d.get_hardware_inventory()
|
||||||
|
|
||||||
|
|
||||||
|
def test_licensed_bmc_has_no_warnings(driver):
|
||||||
|
assert driver.get_device_warnings() == []
|
||||||
@@ -0,0 +1,248 @@
|
|||||||
|
"""IpmiDriver against recorded IPMI answers of a Supermicro X10DRL-i (BMC 3.91).
|
||||||
|
|
||||||
|
The board refuses Redfish without a license; IPMI answers without one. The driver
|
||||||
|
is read-only: it neither switches the server's power nor resets the BMC.
|
||||||
|
"""
|
||||||
|
|
||||||
|
import json
|
||||||
|
from pathlib import Path
|
||||||
|
from types import SimpleNamespace
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
from napalm.base.exceptions import ConnectionException
|
||||||
|
from napalm_device_types import ManagementControllerDriver, models
|
||||||
|
from napalm_device_types.roles import primary_role_of
|
||||||
|
|
||||||
|
import napalm_redfish.ipmi as ipmi
|
||||||
|
from napalm_redfish.ipmi import IpmiDriver
|
||||||
|
|
||||||
|
X10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91" / "ipmi"
|
||||||
|
|
||||||
|
|
||||||
|
def fixture(name):
|
||||||
|
return json.loads((X10 / f"{name}.json").read_text())
|
||||||
|
|
||||||
|
|
||||||
|
class FakeCommand:
|
||||||
|
"""pyghmi's Command, answering from the recorded files."""
|
||||||
|
|
||||||
|
instances: list = []
|
||||||
|
|
||||||
|
def __init__(self, bmc=None, userid=None, password=None, port=623, keepalive=True, **kw):
|
||||||
|
self.args = {"bmc": bmc, "userid": userid, "password": password, "port": port}
|
||||||
|
self.logged_out = False
|
||||||
|
self.ipmi_session = SimpleNamespace(logout=self._logout)
|
||||||
|
FakeCommand.instances.append(self)
|
||||||
|
|
||||||
|
def _logout(self):
|
||||||
|
self.logged_out = True
|
||||||
|
|
||||||
|
def get_power(self):
|
||||||
|
return fixture("power")
|
||||||
|
|
||||||
|
def get_net_configuration(self):
|
||||||
|
return fixture("net_configuration")
|
||||||
|
|
||||||
|
def get_firmware(self):
|
||||||
|
return iter(tuple(item) for item in fixture("firmware"))
|
||||||
|
|
||||||
|
def get_inventory(self):
|
||||||
|
return iter(tuple(item) for item in fixture("inventory"))
|
||||||
|
|
||||||
|
def get_sensor_data(self):
|
||||||
|
return iter(SimpleNamespace(**s) for s in fixture("sensors"))
|
||||||
|
|
||||||
|
def get_event_log(self):
|
||||||
|
return iter(fixture("event_log"))
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(autouse=True)
|
||||||
|
def fake_pyghmi(monkeypatch):
|
||||||
|
FakeCommand.instances = []
|
||||||
|
monkeypatch.setattr(ipmi, "Command", FakeCommand)
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def driver():
|
||||||
|
d = IpmiDriver("192.0.2.150", "netork", "s3cret")
|
||||||
|
d.open()
|
||||||
|
yield d
|
||||||
|
d.close()
|
||||||
|
|
||||||
|
|
||||||
|
def assert_shape(value, typed_dict):
|
||||||
|
assert isinstance(value, dict)
|
||||||
|
assert set(value) == set(typed_dict.__annotations__), typed_dict.__name__
|
||||||
|
|
||||||
|
|
||||||
|
# --- class and connection ------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_role_and_read_only():
|
||||||
|
assert issubclass(IpmiDriver, ManagementControllerDriver)
|
||||||
|
assert primary_role_of(IpmiDriver) == "management_controller"
|
||||||
|
assert IpmiDriver.USES_SSH is False
|
||||||
|
assert IpmiDriver.DRIVER_NAME == "ipmi"
|
||||||
|
# Read-only: no power control, no BMC reset over IPMI.
|
||||||
|
assert not hasattr(IpmiDriver, "set_power_state")
|
||||||
|
assert not hasattr(IpmiDriver, "reboot_host")
|
||||||
|
|
||||||
|
|
||||||
|
def test_entry_point():
|
||||||
|
from importlib.metadata import entry_points
|
||||||
|
|
||||||
|
(ep,) = [e for e in entry_points(group="napalm.drivers") if e.name == "ipmi"]
|
||||||
|
assert ep.load() is IpmiDriver
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_logs_in_on_the_ipmi_port_and_close_logs_out():
|
||||||
|
d = IpmiDriver("192.0.2.150", "netork", "s3cret", optional_args={"port": 6230})
|
||||||
|
d.open()
|
||||||
|
(cmd,) = FakeCommand.instances
|
||||||
|
assert cmd.args == {"bmc": "192.0.2.150", "userid": "netork", "password": "s3cret",
|
||||||
|
"port": 6230}
|
||||||
|
assert d.is_alive() == {"is_alive": True}
|
||||||
|
d.close()
|
||||||
|
assert cmd.logged_out
|
||||||
|
assert d.is_alive() == {"is_alive": False}
|
||||||
|
|
||||||
|
|
||||||
|
def test_default_port_is_623():
|
||||||
|
IpmiDriver("192.0.2.150", "u", "p").open()
|
||||||
|
assert FakeCommand.instances[0].args["port"] == 623
|
||||||
|
|
||||||
|
|
||||||
|
def test_open_failure_is_connection_exception(monkeypatch):
|
||||||
|
def refuse(**kwargs):
|
||||||
|
raise RuntimeError("Incorrect password provided")
|
||||||
|
|
||||||
|
monkeypatch.setattr(ipmi, "Command", refuse)
|
||||||
|
with pytest.raises(ConnectionException, match="Incorrect password"):
|
||||||
|
IpmiDriver("192.0.2.150", "netork", "wrong").open()
|
||||||
|
|
||||||
|
|
||||||
|
# --- NAPALM getters ------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_facts(driver):
|
||||||
|
facts = driver.get_facts()
|
||||||
|
assert facts == {
|
||||||
|
"uptime": -1.0,
|
||||||
|
"vendor": "Supermicro",
|
||||||
|
"model": "X10DRL-i",
|
||||||
|
"hostname": "192.0.2.150",
|
||||||
|
"fqdn": "192.0.2.150",
|
||||||
|
"os_version": "3.91",
|
||||||
|
"serial_number": "ZM000S000001",
|
||||||
|
"interface_list": ["bmc"],
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_interfaces(driver):
|
||||||
|
assert driver.get_interfaces() == {
|
||||||
|
"bmc": {
|
||||||
|
"is_up": True,
|
||||||
|
"is_enabled": True,
|
||||||
|
"description": "BMC LAN (DHCP)",
|
||||||
|
"last_flapped": -1.0,
|
||||||
|
"speed": -1.0,
|
||||||
|
"mtu": -1,
|
||||||
|
"mac_address": "0c:c4:7a:00:00:01",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_interfaces_ip(driver):
|
||||||
|
assert driver.get_interfaces_ip() == {"bmc": {"ipv4": {"192.0.2.150": {"prefix_length": 24}}}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_environment(driver):
|
||||||
|
env = driver.get_environment()
|
||||||
|
assert set(env) == {"fans", "temperature", "power", "cpu", "memory"}
|
||||||
|
assert env["cpu"] == {} and env["memory"] == {}
|
||||||
|
assert len(env["temperature"]) == 19
|
||||||
|
assert env["temperature"]["CPU1 Temp"] == {"temperature": 34.0, "is_alert": False,
|
||||||
|
"is_critical": False}
|
||||||
|
assert env["fans"] == {name: {"status": True} for name in ("FAN2", "FAN3", "FAN4", "FANB")}
|
||||||
|
# Only supplies that report themselves present.
|
||||||
|
assert env["power"] == {"PS1 Status": {"status": True, "capacity": -1.0, "output": -1.0}}
|
||||||
|
|
||||||
|
|
||||||
|
def test_sensor_health_flags(driver, monkeypatch):
|
||||||
|
readings = [
|
||||||
|
{"name": "Hot", "type": "Temperature", "value": 91.0, "units": "°C", "health": 1,
|
||||||
|
"states": [], "unavailable": 0},
|
||||||
|
{"name": "Melting", "type": "Temperature", "value": 105.0, "units": "°C", "health": 2,
|
||||||
|
"states": [], "unavailable": 0},
|
||||||
|
{"name": "Gone", "type": "Temperature", "value": None, "units": "°C", "health": 0,
|
||||||
|
"states": [], "unavailable": 1},
|
||||||
|
{"name": "FAN1", "type": "Fan", "value": 0.0, "units": "RPM", "health": 2,
|
||||||
|
"states": [], "unavailable": 0},
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(FakeCommand, "get_sensor_data",
|
||||||
|
lambda self: iter(SimpleNamespace(**r) for r in readings))
|
||||||
|
env = driver.get_environment()
|
||||||
|
assert env["temperature"] == {
|
||||||
|
"Hot": {"temperature": 91.0, "is_alert": True, "is_critical": False},
|
||||||
|
"Melting": {"temperature": 105.0, "is_alert": True, "is_critical": True},
|
||||||
|
}
|
||||||
|
assert env["fans"] == {"FAN1": {"status": False}}
|
||||||
|
|
||||||
|
|
||||||
|
# --- role methods --------------------------------------------------------------------------
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_power_state(driver):
|
||||||
|
state = driver.get_power_state()
|
||||||
|
assert_shape(state, models.PowerStateDict)
|
||||||
|
assert state == {"state": "On", "actions": []}
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_hardware_inventory(driver):
|
||||||
|
inv = driver.get_hardware_inventory()
|
||||||
|
assert_shape(inv, models.HardwareInventoryDict)
|
||||||
|
assert inv["manufacturer"] == "Supermicro"
|
||||||
|
assert inv["model"] == "X10DRL-i"
|
||||||
|
assert inv["serial_number"] == "ZM000S000001"
|
||||||
|
assert inv["system_uuid"] == "00000000-534d-c40c-0000-000000000001"
|
||||||
|
assert inv["power_supplies"] == [
|
||||||
|
{"slot": "PS1", "model": "", "serial_number": "", "capacity_watts": None, "status": "OK"},
|
||||||
|
{"slot": "PS2", "model": "", "serial_number": "", "capacity_watts": None,
|
||||||
|
"status": "Absent"},
|
||||||
|
]
|
||||||
|
assert inv["cpus"] == [] and inv["memory"] == [] and inv["drives"] == [] and inv["nics"] == []
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_event_log(driver):
|
||||||
|
(entry,) = driver.get_event_log()
|
||||||
|
assert_shape(entry, models.EventLogEntryDict)
|
||||||
|
assert entry == {"id": "1", "timestamp": 1790788051.0, "severity": "OK",
|
||||||
|
"message": "PS2 Status: Recovered"}
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_log_newest_first_and_limited(driver, monkeypatch):
|
||||||
|
sel = [
|
||||||
|
{"record_id": i, "timecode": 1000 + i, "component": "FAN1", "event": "Lower critical",
|
||||||
|
"severity": 2, "deassertion": False}
|
||||||
|
for i in range(1, 6)
|
||||||
|
]
|
||||||
|
monkeypatch.setattr(FakeCommand, "get_event_log", lambda self: iter(sel))
|
||||||
|
entries = driver.get_event_log(limit=2)
|
||||||
|
assert [e["id"] for e in entries] == ["5", "4"]
|
||||||
|
assert entries[0]["severity"] == "Critical"
|
||||||
|
assert entries[0]["message"] == "FAN1: Lower critical"
|
||||||
|
|
||||||
|
|
||||||
|
def test_get_firmware_inventory(driver):
|
||||||
|
(fw,) = driver.get_firmware_inventory()
|
||||||
|
assert_shape(fw, models.FirmwareDict)
|
||||||
|
assert fw == {"name": "BMC", "version": "3.91", "updateable": None}
|
||||||
|
|
||||||
|
|
||||||
|
def test_napalm_still_finds_redfish_by_name():
|
||||||
|
# NAPALM takes the first driver class of the package napalm_<name>.
|
||||||
|
import napalm
|
||||||
|
|
||||||
|
from napalm_redfish import RedfishDriver
|
||||||
|
|
||||||
|
assert napalm.get_network_driver("redfish") is RedfishDriver
|
||||||
Reference in new issue
Block a user