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

+18
View File
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [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
### Fixed
+12 -6
View File
@@ -5,11 +5,17 @@ NAPALM driver for server **baseboard management controllers (BMCs)** over
boards that predate Redfish. Built on
[napalm-device-types](https://git.netork.io/NAPALM/napalm-device-types). Written for netOrk.
**Status: early development.** The `redfish` driver (`napalm_redfish.RedfishDriver`, a
`ManagementControllerDriver` from napalm-device-types) implements the NAPALM getters and
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
Supermicro X10DRL-i (BMC firmware 3.91, Redfish). The `ipmi` fallback does not exist yet.
**Status: early development.** Two drivers, both `ManagementControllerDriver`s from
napalm-device-types:
- `redfish` (`napalm_redfish.RedfishDriver`) implements the NAPALM getters and the role
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
@@ -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
[docs/security.md](docs/security.md).
## Layout (planned)
## Layout
| Entry point | Transport | Scope |
|---|---|---|
+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 ""))
+3 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "napalm-redfish"
version = "0.1.2"
version = "0.2.0"
description = "NAPALM driver for server baseboard management controllers over DMTF Redfish"
readme = "README.md"
license = { file = "LICENSE" }
@@ -14,6 +14,7 @@ dependencies = [
# Not on PyPI: install from git before this package (see README).
"napalm-device-types>=4.8",
"redfish>=3.2",
"pyghmi>=1.5",
]
classifiers = [
"License :: OSI Approved :: Apache Software License",
@@ -30,6 +31,7 @@ test = [
[project.entry-points."napalm.drivers"]
redfish = "napalm_redfish:RedfishDriver"
ipmi = "napalm_redfish.ipmi:IpmiDriver"
[tool.setuptools.packages.find]
include = ["napalm_redfish*"]
+6
View File
@@ -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
landing_page.html GET / -> HTTP 200, the BMC web login page (trimmed to
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"
}
]
+37 -8
View File
@@ -490,14 +490,43 @@ def test_get_management_protocols_missing(driver, server):
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.
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, body)
server.override("GET", "/redfish/v1/", 403,
json.loads((x10 / "service_root_403.json").read_text()))
d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False})
with pytest.raises(ConnectionException) as exc:
d.open()
message = str(exc.value)
assert "license" in message.lower()
assert "SUM DCMS OOB" in message
d.open()
return d
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() == []
+248
View File
@@ -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