feat: a bmc driver that takes the best of Redfish and IPMI (0.4.0)
bmc (napalm_redfish.bmc:BmcDriver) reads a BMC over Redfish where it answers. A BMC that refuses Redfish (no license) is read and switched over IPMI. A getter that fails over Redfish is asked over IPMI where IPMI has it; IPMI is opened only then. Power control goes over Redfish when it answers, and a refusal is final. The BMC is reset over Redfish only. Same account for both; ipmi_port in optional_args. Discovery suggests bmc for a BMC it finds: the fingerprint rules moved from RedfishDriver to BmcDriver.
This commit is contained in:
1 parent
adf152ee12
commit
e9ad7951cd
9 files changed
+412
-23
No files matched your search
@@ -0,0 +1,158 @@
|
||||
"""BmcDriver: the best of Redfish and IPMI for one BMC, chosen by itself.
|
||||
|
||||
Redfish where the BMC answers it; IPMI where it does not. A BMC that refuses Redfish
|
||||
altogether (no license) is read over IPMI entirely; a getter that fails over Redfish is
|
||||
asked over IPMI, where IPMI has it. Both use the same account.
|
||||
|
||||
Against the recordings of the Supermicro X10DRL-i: its Redfish tree with the OOB license,
|
||||
its unlicensed service root, and its IPMI answers.
|
||||
"""
|
||||
|
||||
import json
|
||||
from pathlib import Path
|
||||
|
||||
import pytest
|
||||
from napalm_device_types import ManagementControllerDriver
|
||||
from napalm_device_types.roles import primary_role_of
|
||||
|
||||
import napalm_redfish.ipmi as ipmi
|
||||
from napalm_redfish.bmc import BmcDriver
|
||||
from tests.conftest import BASE_URL, FIXTURES, MockupServer
|
||||
from tests.test_ipmi import FakeCommand
|
||||
|
||||
X10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91"
|
||||
MOCKUP = "supermicro-x10drl-i-3.91/redfish"
|
||||
HOST = BASE_URL.removeprefix("https://")
|
||||
|
||||
|
||||
@pytest.fixture(autouse=True)
|
||||
def fake_pyghmi(monkeypatch):
|
||||
FakeCommand.instances = []
|
||||
monkeypatch.setattr(ipmi, "Command", FakeCommand)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def x10(http):
|
||||
server = MockupServer(http, MOCKUP)
|
||||
for path, status in json.loads((FIXTURES / MOCKUP / "STATUS.json").read_text()).items():
|
||||
body = json.loads((FIXTURES / MOCKUP / path[len("/redfish/v1/"):] / "index.json")
|
||||
.read_text())
|
||||
server.override("GET", path, status, body)
|
||||
return server
|
||||
|
||||
|
||||
def _open(**optional):
|
||||
d = BmcDriver(HOST, "netork", "s3cret", optional_args={"verify_ssl": False, **optional})
|
||||
d.open()
|
||||
return d
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def unlicensed(x10):
|
||||
x10.override("GET", "/redfish/v1/", 403,
|
||||
json.loads((X10 / "service_root_403.json").read_text()))
|
||||
d = _open()
|
||||
yield d
|
||||
d.close()
|
||||
|
||||
|
||||
# ── the class ───────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def test_it_is_one_bmc_driver_with_power_and_bmc_reset():
|
||||
assert issubclass(BmcDriver, ManagementControllerDriver)
|
||||
assert primary_role_of(BmcDriver) == "management_controller"
|
||||
assert BmcDriver.DRIVER_NAME == "bmc"
|
||||
assert BmcDriver.default_port == 443
|
||||
assert callable(getattr(BmcDriver, "set_power_state", None))
|
||||
assert callable(getattr(BmcDriver, "reboot_host", None))
|
||||
|
||||
|
||||
def test_entry_point():
|
||||
from importlib.metadata import entry_points
|
||||
|
||||
(ep,) = [e for e in entry_points(group="napalm.drivers") if e.name == "bmc"]
|
||||
assert ep.load() is BmcDriver
|
||||
|
||||
|
||||
# ── a BMC that answers Redfish ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_redfish_is_used_and_ipmi_is_not_even_opened(x10):
|
||||
d = _open()
|
||||
try:
|
||||
assert d.get_facts()["model"] == "X10DRL-i"
|
||||
assert len(d.get_hardware_inventory()["cpus"]) == 2
|
||||
assert d.get_power_state()["actions"] == [
|
||||
"GracefulShutdown", "ForceOff", "GracefulRestart", "ForceRestart"]
|
||||
finally:
|
||||
d.close()
|
||||
assert FakeCommand.instances == []
|
||||
|
||||
|
||||
def test_a_getter_that_fails_over_redfish_is_asked_over_ipmi(x10):
|
||||
x10.override("GET", "/redfish/v1/Chassis/1/Thermal", 500, {"error": {"message": "busy"}})
|
||||
d = _open()
|
||||
try:
|
||||
env = d.get_environment()
|
||||
finally:
|
||||
d.close()
|
||||
# From the IPMI sensor records.
|
||||
assert len(env["temperature"]) == 19
|
||||
assert len(FakeCommand.instances) == 1
|
||||
|
||||
|
||||
def test_the_ipmi_port_is_its_own(x10):
|
||||
x10.override("GET", "/redfish/v1/Chassis/1/Thermal", 500, {"error": {"message": "busy"}})
|
||||
d = _open(ipmi_port=6230)
|
||||
try:
|
||||
d.get_environment()
|
||||
finally:
|
||||
d.close()
|
||||
(cmd,) = FakeCommand.instances
|
||||
assert cmd.args == {"bmc": HOST, "userid": "netork", "password": "s3cret", "port": 6230}
|
||||
assert cmd.logged_out
|
||||
|
||||
|
||||
def test_the_partial_license_warning_passes_through(x10):
|
||||
d = _open()
|
||||
try:
|
||||
assert d.get_device_warnings() == [{
|
||||
"code": "bmc_license_partial",
|
||||
"meta": {"licenses": "DCMS", "parts": ["drives", "firmware inventory"]},
|
||||
}]
|
||||
finally:
|
||||
d.close()
|
||||
|
||||
|
||||
# ── a BMC that refuses Redfish ──────────────────────────────────────────
|
||||
|
||||
|
||||
def test_an_unlicensed_bmc_is_read_over_ipmi(unlicensed):
|
||||
facts = unlicensed.get_facts()
|
||||
assert facts["vendor"] == "Supermicro"
|
||||
assert facts["model"] == "X10DRL-i"
|
||||
assert facts["os_version"] == "3.91"
|
||||
assert unlicensed.get_power_state() == {
|
||||
"state": "On", "actions": ["GracefulShutdown", "ForceOff", "ForceRestart"]}
|
||||
|
||||
|
||||
def test_an_unlicensed_bmc_switches_power_over_ipmi(unlicensed):
|
||||
unlicensed.set_power_state("GracefulShutdown")
|
||||
assert FakeCommand.instances[0].power_requests == [("shutdown", False)]
|
||||
|
||||
|
||||
def test_an_unlicensed_bmc_says_ipmi_reads_it_meanwhile(unlicensed):
|
||||
(warning,) = unlicensed.get_device_warnings()
|
||||
assert warning["code"] == "bmc_license_required"
|
||||
assert warning["meta"]["licenses"] == "SUM DCMS OOB"
|
||||
assert warning["meta"]["ipmi_fallback"] is True
|
||||
|
||||
|
||||
def test_over_ipmi_the_protocol_netork_talks_is_known_to_be_on(unlicensed):
|
||||
assert unlicensed.get_management_protocols() == {"ipmi": {"enabled": True, "port": 623}}
|
||||
|
||||
|
||||
def test_the_bmc_is_not_reset_over_ipmi(unlicensed):
|
||||
with pytest.raises(RuntimeError, match="needs Redfish"):
|
||||
unlicensed.reboot_host()
|
||||
+15
-5
@@ -8,23 +8,33 @@ import json
|
||||
from pathlib import Path
|
||||
|
||||
from napalm_redfish import RedfishDriver
|
||||
from napalm_redfish.bmc import BmcDriver
|
||||
from napalm_redfish.ipmi import IpmiDriver
|
||||
|
||||
X10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91"
|
||||
|
||||
|
||||
def _score(text: str) -> float:
|
||||
text = " ".join(text.lower().split())
|
||||
return sum(rule.weight for rule in RedfishDriver.HTTP_FINGERPRINT if rule.pattern in text)
|
||||
return sum(rule.weight for rule in BmcDriver.HTTP_FINGERPRINT if rule.pattern in text)
|
||||
|
||||
|
||||
def test_discovery_suggests_the_bmc_driver_only():
|
||||
# bmc works with and without a license; two drivers on the same rules would tie.
|
||||
assert BmcDriver.HTTP_FINGERPRINT
|
||||
assert RedfishDriver.HTTP_FINGERPRINT == []
|
||||
assert not RedfishDriver.PORT_SPECS
|
||||
assert IpmiDriver.HTTP_FINGERPRINT == []
|
||||
|
||||
|
||||
def test_an_open_https_port_proves_nothing():
|
||||
# Every switch with a web UI answers on 443; the port alone made six of
|
||||
# them look like BMCs.
|
||||
assert [spec.weight for spec in RedfishDriver.PORT_SPECS if spec.port == 443] == [0.0]
|
||||
assert [spec.weight for spec in BmcDriver.PORT_SPECS if spec.port == 443] == [0.0]
|
||||
|
||||
|
||||
def test_service_root_is_still_probed():
|
||||
assert any("/redfish/v1/" in spec.paths for spec in RedfishDriver.PORT_SPECS)
|
||||
assert any("/redfish/v1/" in spec.paths for spec in BmcDriver.PORT_SPECS)
|
||||
|
||||
|
||||
def test_supermicro_landing_page_is_recognised():
|
||||
@@ -53,11 +63,11 @@ def test_alternatives_count_once_towards_the_maximum():
|
||||
# BMC's SNMP sysDescr (5 of 18) beat the Supermicro landing page (6 of 41).
|
||||
from napalm_device_types import rules_max_weight
|
||||
|
||||
assert rules_max_weight(RedfishDriver.HTTP_FINGERPRINT) == 21.0
|
||||
assert rules_max_weight(BmcDriver.HTTP_FINGERPRINT) == 21.0
|
||||
|
||||
|
||||
def test_supermicro_landing_page_alone_outranks_a_linux_sysdescr():
|
||||
from napalm_device_types import rules_max_weight
|
||||
|
||||
page = _score((X10 / "landing_page.html").read_text())
|
||||
assert page / rules_max_weight(RedfishDriver.HTTP_FINGERPRINT) > 5.0 / 18.0
|
||||
assert page / rules_max_weight(BmcDriver.HTTP_FINGERPRINT) > 5.0 / 18.0
|
||||
Reference in new issue
Block a user