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
@@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|||||||
|
|
||||||
## [Unreleased]
|
## [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
|
## [0.3.1] - 2026-10-17
|
||||||
|
|
||||||
First run against real hardware over Redfish: a Supermicro X10DRL-i (BMC 3.91) with the
|
First run against real hardware over Redfish: a Supermicro X10DRL-i (BMC 3.91) with the
|
||||||
|
|||||||
@@ -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 |
|
| `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 |
|
| `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
|
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
|
the service root's `Vendor`/`Oem` data and `Manager.Model`. No separate entry point per
|
||||||
|
|||||||
@@ -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
|
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.
|
restart, and pyghmi offers no power cycle, so `get_power_state` never offers those two.
|
||||||
No BMC reset over IPMI.
|
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}}` |
|
||||||
|
|
||||||
@@ -7,5 +7,5 @@ takes the first driver class of this package. ``IpmiDriver`` lives in
|
|||||||
|
|
||||||
from napalm_redfish.driver import RedfishDriver
|
from napalm_redfish.driver import RedfishDriver
|
||||||
|
|
||||||
__version__ = "0.3.1"
|
__version__ = "0.4.0"
|
||||||
__all__ = ["RedfishDriver"]
|
__all__ = ["RedfishDriver"]
|
||||||
@@ -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}}
|
||||||
+21
-16
@@ -94,6 +94,25 @@ _WITHHELD_ORDER = ("drives", "firmware inventory")
|
|||||||
#: system "Super Server"; the board is the system's PartNumber and the chassis model.
|
#: 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."})
|
_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:
|
def _timestamp(created: str | None) -> float | None:
|
||||||
"""Unix time of a Redfish ``Created`` value; a time without offset counts as UTC."""
|
"""Unix time of a Redfish ``Created`` value; a time without offset counts as UTC."""
|
||||||
@@ -124,22 +143,8 @@ class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
|
|||||||
|
|
||||||
platform = "redfish"
|
platform = "redfish"
|
||||||
DRIVER_NAME = "redfish"
|
DRIVER_NAME = "redfish"
|
||||||
#: Every Redfish service answers ``GET /redfish/v1/`` without credentials. The port
|
# No fingerprint of its own: discovery suggests ``bmc`` (BMC_PORT_SPECS and
|
||||||
#: itself is worth nothing: every switch with a web UI answers on 443.
|
# BMC_HTTP_FINGERPRINT below), which also reads a BMC that refuses Redfish.
|
||||||
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"),
|
|
||||||
]
|
|
||||||
#: A BMC restart takes a few minutes before Redfish answers again.
|
#: A BMC restart takes a few minutes before Redfish answers again.
|
||||||
REBOOT_SETTLE_SECONDS = 180
|
REBOOT_SETTLE_SECONDS = 180
|
||||||
|
|
||||||
|
|||||||
+2
-1
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
|||||||
|
|
||||||
[project]
|
[project]
|
||||||
name = "napalm-redfish"
|
name = "napalm-redfish"
|
||||||
version = "0.3.1"
|
version = "0.4.0"
|
||||||
description = "NAPALM driver for server baseboard management controllers over DMTF Redfish"
|
description = "NAPALM driver for server baseboard management controllers over DMTF Redfish"
|
||||||
readme = "README.md"
|
readme = "README.md"
|
||||||
license = { file = "LICENSE" }
|
license = { file = "LICENSE" }
|
||||||
@@ -32,6 +32,7 @@ test = [
|
|||||||
[project.entry-points."napalm.drivers"]
|
[project.entry-points."napalm.drivers"]
|
||||||
redfish = "napalm_redfish:RedfishDriver"
|
redfish = "napalm_redfish:RedfishDriver"
|
||||||
ipmi = "napalm_redfish.ipmi:IpmiDriver"
|
ipmi = "napalm_redfish.ipmi:IpmiDriver"
|
||||||
|
bmc = "napalm_redfish.bmc:BmcDriver"
|
||||||
|
|
||||||
[tool.setuptools.packages.find]
|
[tool.setuptools.packages.find]
|
||||||
include = ["napalm_redfish*"]
|
include = ["napalm_redfish*"]
|
||||||
|
|||||||
@@ -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 pathlib import Path
|
||||||
|
|
||||||
from napalm_redfish import RedfishDriver
|
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"
|
X10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91"
|
||||||
|
|
||||||
|
|
||||||
def _score(text: str) -> float:
|
def _score(text: str) -> float:
|
||||||
text = " ".join(text.lower().split())
|
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():
|
def test_an_open_https_port_proves_nothing():
|
||||||
# Every switch with a web UI answers on 443; the port alone made six of
|
# Every switch with a web UI answers on 443; the port alone made six of
|
||||||
# them look like BMCs.
|
# 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():
|
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():
|
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).
|
# BMC's SNMP sysDescr (5 of 18) beat the Supermicro landing page (6 of 41).
|
||||||
from napalm_device_types import rules_max_weight
|
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():
|
def test_supermicro_landing_page_alone_outranks_a_linux_sysdescr():
|
||||||
from napalm_device_types import rules_max_weight
|
from napalm_device_types import rules_max_weight
|
||||||
|
|
||||||
page = _score((X10 / "landing_page.html").read_text())
|
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