"""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 reads, and switches the server's power; it does not reset 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.powerstate = fixture("power")["powerstate"] self.power_requests: list = [] self.ipmi_session = SimpleNamespace(logout=self._logout) FakeCommand.instances.append(self) def _logout(self): self.logged_out = True def get_power(self): return {"powerstate": self.powerstate} def set_power(self, powerstate, wait=False): self.power_requests.append((powerstate, wait)) return {"pendingpowerstate": powerstate} 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_power_control_and_no_bmc_reset(): assert issubclass(IpmiDriver, ManagementControllerDriver) assert primary_role_of(IpmiDriver) == "management_controller" assert IpmiDriver.USES_SSH is False assert IpmiDriver.DRIVER_NAME == "ipmi" # The server's power can be switched; the BMC is not reset over IPMI. assert callable(getattr(IpmiDriver, "set_power_state", None)) 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": ["GracefulShutdown", "ForceOff", "ForceRestart"]} def test_a_server_that_is_off_can_only_be_switched_on(driver): FakeCommand.instances[0].powerstate = "off" assert driver.get_power_state() == {"state": "Off", "actions": ["On"]} def test_every_offered_action_is_a_role_power_action(driver): for powerstate in ("on", "off"): FakeCommand.instances[0].powerstate = powerstate assert set(driver.get_power_state()["actions"]) <= set(models.POWER_ACTIONS) @pytest.mark.parametrize( "action, powerstate, expected", [ ("GracefulShutdown", "on", "shutdown"), ("ForceOff", "on", "off"), ("ForceRestart", "on", "reset"), ("On", "off", "on"), ], ) def test_set_power_state_sends_the_chassis_control(driver, action, powerstate, expected): cmd = FakeCommand.instances[0] cmd.powerstate = powerstate driver.set_power_state(action) # Returns once the BMC has accepted the request, not when the server is done. assert cmd.power_requests == [(expected, False)] def test_set_power_state_rejects_an_unknown_action(driver): with pytest.raises(ValueError, match="Reboot"): driver.set_power_state("Reboot") assert FakeCommand.instances[0].power_requests == [] @pytest.mark.parametrize( "action, powerstate", [ ("On", "on"), ("ForceOff", "off"), ("GracefulRestart", "on"), # IPMI has no graceful restart ("PowerCycle", "on"), ], ) def test_set_power_state_refuses_what_is_not_offered_now(driver, action, powerstate): cmd = FakeCommand.instances[0] cmd.powerstate = powerstate with pytest.raises(RuntimeError, match=f"{action} not accepted"): driver.set_power_state(action) assert cmd.power_requests == [] def test_a_refused_power_request_is_a_runtime_error(driver): def refuse(powerstate, wait=False): raise ipmi.IpmiException("Insufficient privilege level") FakeCommand.instances[0].set_power = refuse with pytest.raises(RuntimeError, match="ForceOff refused: Insufficient privilege"): driver.set_power_state("ForceOff") 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_. 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"]