diff --git a/CHANGELOG.md b/CHANGELOG.md index 117c8b2..d0e6120 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.4.0] - 2026-10-17 + +### Added +- `bmc` NAPALM driver (`napalm_redfish.bmc:BmcDriver`): the best of Redfish and IPMI for + one BMC, chosen by itself. It reads over Redfish where the BMC answers. A BMC that refuses + Redfish altogether (no license) is read and switched over IPMI, and 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. A refusal by the BMC is not retried over IPMI. The BMC + itself is reset over Redfish only. `get_device_warnings` adds `ipmi_fallback` to + `bmc_license_required` while it reads over IPMI. `get_management_protocols` over IPMI + reports IPMI over LAN on, the one thing it knows. Same account for both; `ipmi_port` + (623) in `optional_args`. + +### Changed +- Discovery suggests `bmc` for a BMC it finds: the fingerprint rules and the probed + `/redfish/v1/` path moved from `RedfishDriver` to `BmcDriver` + (`BMC_PORT_SPECS`, `BMC_HTTP_FINGERPRINT`). `redfish` stays for choosing by hand. + ## [0.3.1] - 2026-10-17 First run against real hardware over Redfish: a Supermicro X10DRL-i (BMC 3.91) with the diff --git a/README.md b/README.md index 46e8d95..b53f72a 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,13 @@ not depend on it. Finding IPMI over LAN enabled is itself worth reporting, see |---|---|---| | `redfish` | HTTPS, Redfish REST (session token or basic auth) | Read, plus power control and BMC reset | | `ipmi` | IPMI 2.0 over LAN (UDP 623) via `pyghmi` | Read, plus power control (no graceful restart, no power cycle); no BMC reset | +| `bmc` | Redfish first, IPMI where Redfish does not answer | Everything `redfish` does; a BMC that refuses Redfish (no license) is read and switched over IPMI, and a getter that fails over Redfish is asked over IPMI | + +`bmc` is the one to choose when unsure: it follows the BMC's license as it changes. A +Supermicro X10 without a license is read over IPMI, with SFT-OOB-LIC over Redfish (which then +reads more than IPMI: CPUs, DIMMs, NICs, power draw, the BIOS version, the SMBIOS UUID), and +with DCMS also its drives and firmware inventory. `optional_args`: those of `redfish`, plus +`ipmi_port` (623). Vendor quirks (Supermicro, Dell, HPE, Lenovo) live in one module per vendor, selected from the service root's `Vendor`/`Oem` data and `Manager.Model`. No separate entry point per diff --git a/docs/redfish-map.md b/docs/redfish-map.md index f428153..b08434e 100644 --- a/docs/redfish-map.md +++ b/docs/redfish-map.md @@ -45,3 +45,17 @@ Via `pyghmi`: device ID and FRU data for `get_facts`, sensor data repository for via ACPI, `ForceOff` → power down, `ForceRestart` → hard reset). IPMI has no graceful restart, and pyghmi offers no power cycle, so `get_power_state` never offers those two. No BMC reset over IPMI. + +## Redfish and IPMI together (`bmc` entry point) + +`BmcDriver` holds a `RedfishDriver` and an `IpmiDriver` for the same BMC and account: + +| Situation | Used | +|---|---| +| Redfish answers | Redfish for every getter; IPMI is not opened | +| A getter fails over Redfish (`RedfishError`, `ConnectionException`) | that getter over IPMI, if IPMI has it (Redfish-only getters re-raise) | +| The service root refuses Redfish for want of a license, or Redfish does not answer | IPMI for everything; Redfish-only getters raise `NotImplementedError` | +| `set_power_state` | Redfish when it answers; a refusal (`RuntimeError`) is final | +| `reboot_host` | Redfish only (`Manager.Reset`) | +| `get_management_protocols` over IPMI | `{"ipmi": {"enabled": True, "port": 623}}` | + diff --git a/napalm_redfish/__init__.py b/napalm_redfish/__init__.py index 8feca5a..57fea26 100644 --- a/napalm_redfish/__init__.py +++ b/napalm_redfish/__init__.py @@ -7,5 +7,5 @@ takes the first driver class of this package. ``IpmiDriver`` lives in from napalm_redfish.driver import RedfishDriver -__version__ = "0.3.1" +__version__ = "0.4.0" __all__ = ["RedfishDriver"] diff --git a/napalm_redfish/bmc.py b/napalm_redfish/bmc.py new file mode 100644 index 0000000..e4ba95f --- /dev/null +++ b/napalm_redfish/bmc.py @@ -0,0 +1,176 @@ +"""NAPALM driver for a BMC that takes the best of Redfish and IPMI, chosen by itself. + +``redfish`` and ``ipmi`` make the operator choose, and the right choice changes with the +BMC's license: a Supermicro X10 refuses Redfish without one, answers most of it with +SFT-OOB-LIC, and all of it with DCMS. This driver asks Redfish first and IPMI where Redfish +does not answer: + +* A BMC that refuses Redfish altogether (no license) is read over IPMI entirely. +* A getter that fails over Redfish (an error, a resource the BMC withholds) is asked over + IPMI, where IPMI has it. IPMI is opened only then. +* Power control goes over Redfish when it answers, else over IPMI. A refusal by the BMC is + an answer, not a failure, and is not retried over IPMI. +* The BMC itself is reset over Redfish only (``Manager.Reset``); IPMI's cold reset is not + offered, as in the ``ipmi`` driver. + +Both protocols use the same account. ``optional_args``: everything ``RedfishDriver`` +takes, plus ``ipmi_port`` (623). +""" + +from __future__ import annotations + +import logging +from typing import Any + +from napalm.base.exceptions import ConnectionException +from napalm_device_types import HealthMetricsMixin, ManagementControllerDriver + +from napalm_redfish.driver import BMC_HTTP_FINGERPRINT, BMC_PORT_SPECS, RedfishDriver +from napalm_redfish.ipmi import IpmiDriver +from napalm_redfish.transport import RedfishError + +LOG = logging.getLogger(__name__) + +_JsonDict = dict[str, Any] +_IPMI_PORT = 623 + + +class BmcDriver(HealthMetricsMixin, ManagementControllerDriver): + """One BMC over Redfish and IPMI: Redfish first, IPMI where Redfish does not answer.""" + + platform = "bmc" + DRIVER_NAME = "bmc" + default_port = 443 + #: Discovery suggests this driver for a BMC it finds, not ``redfish``. + PORT_SPECS = BMC_PORT_SPECS + HTTP_FINGERPRINT = BMC_HTTP_FINGERPRINT + #: A BMC restart takes a few minutes before Redfish answers again. + REBOOT_SETTLE_SECONDS = RedfishDriver.REBOOT_SETTLE_SECONDS + + 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 = dict(optional_args or {}) + ipmi_port = int(self.optional_args.pop("ipmi_port", None) or _IPMI_PORT) + self.ipmi_port = ipmi_port + self._redfish = RedfishDriver(hostname, username, password, timeout, self.optional_args) + self._ipmi = IpmiDriver(hostname, username, password, timeout, {"port": ipmi_port}) + self._redfish_ok = False + self._ipmi_open = False + + # --- connection ---------------------------------------------------------------------- + + def open(self) -> None: + """Connect over Redfish; a BMC that refuses it or does not answer it is opened over + IPMI instead. Fails only when neither answers.""" + try: + self._redfish.open() + except ConnectionException as exc: + LOG.info("%s: no Redfish (%s); reading it over IPMI", self.hostname, exc) + self._redfish_ok = False + else: + self._redfish_ok = self._redfish._license_refused is None + if not self._redfish_ok: + self._ipmi_conn() + + def close(self) -> None: + self._redfish.close() + if self._ipmi_open: + self._ipmi.close() + self._ipmi_open = False + + def is_alive(self) -> _JsonDict: + if self._redfish_ok: + return self._redfish.is_alive() + return self._ipmi.is_alive() + + def _ipmi_conn(self) -> IpmiDriver: + if not self._ipmi_open: + self._ipmi.open() + self._ipmi_open = True + return self._ipmi + + def _call(self, name: str, *args: Any) -> Any: + """``name`` over Redfish; over IPMI when Redfish is out or fails and IPMI has it.""" + has_ipmi = callable(getattr(IpmiDriver, name, None)) + if self._redfish_ok: + try: + return getattr(self._redfish, name)(*args) + except (RedfishError, ConnectionException) as exc: + if not has_ipmi: + raise + LOG.info("%s: %s failed over Redfish (%s); asking IPMI", self.hostname, name, + exc) + if not has_ipmi: + raise NotImplementedError(f"{self.hostname}: {name} needs Redfish") + return getattr(self._ipmi_conn(), name)(*args) + + # --- NAPALM getters ------------------------------------------------------------------ + + def get_facts(self) -> _JsonDict: + return self._call("get_facts") + + def get_interfaces(self) -> _JsonDict: + return self._call("get_interfaces") + + def get_interfaces_ip(self) -> _JsonDict: + return self._call("get_interfaces_ip") + + def get_environment(self) -> _JsonDict: + return self._call("get_environment") + + def get_users(self) -> _JsonDict: + return self._call("get_users") + + def get_ntp_servers(self) -> _JsonDict: + return self._call("get_ntp_servers") + + def get_snmp_information(self) -> _JsonDict: + return self._call("get_snmp_information") + + def get_device_warnings(self) -> list[_JsonDict]: + """Redfish's signals; for a BMC that refuses Redfish, with ``ipmi_fallback`` set, as + this driver reads it over IPMI meanwhile.""" + warnings = self._redfish.get_device_warnings() + if self._redfish_ok: + return warnings + return [{**w, "meta": {**w.get("meta", {}), "ipmi_fallback": True}} for w in warnings] + + # --- role methods -------------------------------------------------------------------- + + def get_power_state(self) -> _JsonDict: + return self._call("get_power_state") + + def set_power_state(self, action: str) -> None: + self._call("set_power_state", action) + + def reboot_host(self) -> None: + """Restart the BMC (``Manager.Reset``), never the server. Needs Redfish.""" + if not self._redfish_ok: + raise RuntimeError(f"{self.hostname}: resetting the BMC needs Redfish") + self._redfish.reboot_host() + + def get_hardware_inventory(self) -> _JsonDict: + return self._call("get_hardware_inventory") + + def get_event_log(self, limit: int = 500) -> list[_JsonDict]: + return self._call("get_event_log", limit) + + def get_firmware_inventory(self) -> list[_JsonDict]: + return self._call("get_firmware_inventory") + + def get_management_protocols(self) -> _JsonDict: + """Over Redfish, what the BMC reports. Over IPMI only one thing is known: IPMI over + LAN is on, since this driver is talking it.""" + if self._redfish_ok: + return self._redfish.get_management_protocols() + return {"ipmi": {"enabled": True, "port": self.ipmi_port}} diff --git a/napalm_redfish/driver.py b/napalm_redfish/driver.py index 2d868a2..8227bb0 100644 --- a/napalm_redfish/driver.py +++ b/napalm_redfish/driver.py @@ -94,6 +94,25 @@ _WITHHELD_ORDER = ("drives", "firmware inventory") #: system "Super Server"; the board is the system's PartNumber and the chassis model. _PLACEHOLDER_MODELS = frozenset({"super server", "default string", "to be filled by o.e.m."}) +#: What discovery recognises a BMC by; the ``bmc`` driver carries them (0.4.0), so a BMC +#: found is suggested with the driver that works with and without a license. +#: Every Redfish service answers ``GET /redfish/v1/`` without credentials. The port +#: itself is worth nothing: every switch with a web UI answers on 443. +BMC_PORT_SPECS = [PortSpec("https", 443, ("/redfish/v1/",), weight=0.0)] +#: Rules in one group are alternatives and count once towards the maximum +#: (napalm-device-types' ``rules_max_weight``). +BMC_HTTP_FINGERPRINT = [ + # The service root, or the error body a service refuses it with (an unlicensed + # Supermicro X10 answers 403 with a Redfish error). + FingerprintRule("redfishversion", 10.0, group="redfish_answer"), + FingerprintRule("@message.extendedinfo", 8.0, group="redfish_answer"), + FingerprintRule("#serviceroot.", 5.0), + # Landing pages of the BMC web UIs: Supermicro X9-X11 (ATEN firmware), iDRAC, iLO. + FingerprintRule("aten international", 6.0, group="landing_page"), + FingerprintRule("idrac", 6.0, group="landing_page"), + FingerprintRule("integrated lights-out", 6.0, group="landing_page"), +] + def _timestamp(created: str | None) -> float | None: """Unix time of a Redfish ``Created`` value; a time without offset counts as UTC.""" @@ -124,22 +143,8 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver): platform = "redfish" DRIVER_NAME = "redfish" - #: Every Redfish service answers ``GET /redfish/v1/`` without credentials. The port - #: itself is worth nothing: every switch with a web UI answers on 443. - PORT_SPECS = [PortSpec("https", 443, ("/redfish/v1/",), weight=0.0)] - #: Rules in one group are alternatives and count once towards the maximum - #: (napalm-device-types' ``rules_max_weight``). - HTTP_FINGERPRINT = [ - # The service root, or the error body a service refuses it with (an unlicensed - # Supermicro X10 answers 403 with a Redfish error). - FingerprintRule("redfishversion", 10.0, group="redfish_answer"), - FingerprintRule("@message.extendedinfo", 8.0, group="redfish_answer"), - FingerprintRule("#serviceroot.", 5.0), - # Landing pages of the BMC web UIs: Supermicro X9-X11 (ATEN firmware), iDRAC, iLO. - FingerprintRule("aten international", 6.0, group="landing_page"), - FingerprintRule("idrac", 6.0, group="landing_page"), - FingerprintRule("integrated lights-out", 6.0, group="landing_page"), - ] + # No fingerprint of its own: discovery suggests ``bmc`` (BMC_PORT_SPECS and + # BMC_HTTP_FINGERPRINT below), which also reads a BMC that refuses Redfish. #: A BMC restart takes a few minutes before Redfish answers again. REBOOT_SETTLE_SECONDS = 180 diff --git a/pyproject.toml b/pyproject.toml index 29aa678..c42657d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-redfish" -version = "0.3.1" +version = "0.4.0" description = "NAPALM driver for server baseboard management controllers over DMTF Redfish" readme = "README.md" license = { file = "LICENSE" } @@ -32,6 +32,7 @@ test = [ [project.entry-points."napalm.drivers"] redfish = "napalm_redfish:RedfishDriver" ipmi = "napalm_redfish.ipmi:IpmiDriver" +bmc = "napalm_redfish.bmc:BmcDriver" [tool.setuptools.packages.find] include = ["napalm_redfish*"] diff --git a/tests/test_bmc_driver.py b/tests/test_bmc_driver.py new file mode 100644 index 0000000..fb44af1 --- /dev/null +++ b/tests/test_bmc_driver.py @@ -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() diff --git a/tests/test_discovery.py b/tests/test_discovery.py index 62902bb..36386d7 100644 --- a/tests/test_discovery.py +++ b/tests/test_discovery.py @@ -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