The second IPMI poll of a BMC in the same worker process failed with "'<' not supported between instances of 'float' and 'NoneType'". pyghmi files a session in Session.initting_sessions under the credentials as str and deletes it only under bytes, so the entry stays and the next Command got the old, logged-out session back. The driver drops that entry itself before open() and after close(). Reproduced and verified live against the Supermicro X10DRL-i: three polls in one process now succeed. IpmiDriver and RedfishDriver mix in HealthMetricsMixin, so netOrk's SNMP health poll reads the BMC's own load, memory and interfaces where the BMC runs net-snmp with the UCD-MIB, as the X10's does.
283 lines
9.8 KiB
Python
283 lines
9.8 KiB
Python
"""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, **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
|
|
|
|
|
|
def test_each_open_and_close_forgets_pyghmis_cached_session(monkeypatch):
|
|
# pyghmi files a session in Session.initting_sessions under the credentials as str
|
|
# and deletes it only under bytes, so it stays; the next Command for the BMC in the
|
|
# same process got the old, logged-out one back and failed with "'<' not supported
|
|
# between float and NoneType". Reproduced live against the X10, fixed by this.
|
|
cache = {}
|
|
monkeypatch.setattr(ipmi, "Session", SimpleNamespace(initting_sessions=cache))
|
|
key = ("192.0.2.150", "netork", "s3cret", 623, None)
|
|
|
|
cache[key] = "stale session of an earlier poll"
|
|
d = IpmiDriver("192.0.2.150", "netork", "s3cret")
|
|
d.open()
|
|
assert key not in cache
|
|
|
|
cache[key] = "this poll's session"
|
|
d.close()
|
|
assert key not in cache
|
|
assert FakeCommand.instances[0].logged_out
|
|
|
|
|
|
def test_both_drivers_collect_the_bmcs_own_snmp_health():
|
|
# Many BMCs run net-snmp with the UCD-MIB (the X10's answers load, memory, CPU idle and
|
|
# five interfaces), so netOrk's SNMP health poll covers the BMC itself. The managed
|
|
# server's sensors stay with get_environment.
|
|
from napalm_device_types import HealthMetricsMixin
|
|
from napalm_device_types.roles import role_keys_of
|
|
|
|
from napalm_redfish import RedfishDriver
|
|
|
|
for cls in (IpmiDriver, RedfishDriver):
|
|
assert issubclass(cls, HealthMetricsMixin), cls.__name__
|
|
assert role_keys_of(cls) == ["management_controller"]
|