IpmiDriver.set_power_state sends an IPMI chassis control: On, GracefulShutdown, ForceOff and ForceRestart. get_power_state now offers what fits the server's state: On when it is off, the other three when it is on. IPMI has no graceful restart and pyghmi offers no power cycle, so neither is offered. The driver still does not reset the BMC. For boards that offer nothing but IPMI, such as a Supermicro X10 without a Redfish license.
344 lines
12 KiB
Python
344 lines
12 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
|
|
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_<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"]
|