From 6c60f7ac604dc772002365506b7e9bf75a20844e Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Sat, 17 Oct 2026 01:04:43 +0200 Subject: [PATCH] feat: switch the server's power over IPMI (0.3.0) 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. --- CHANGELOG.md | 10 ++++++ README.md | 11 +++--- docs/redfish-map.md | 9 +++-- docs/security.md | 6 ++-- napalm_redfish/__init__.py | 2 +- napalm_redfish/ipmi.py | 49 +++++++++++++++++++++---- pyproject.toml | 2 +- tests/test_ipmi.py | 73 ++++++++++++++++++++++++++++++++++---- 8 files changed, 137 insertions(+), 25 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7bbdc0d..2a39c0d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,16 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.3.0] - 2026-10-17 + +### Added +- `IpmiDriver.set_power_state`: the server's power over IPMI chassis control (`On`, + `GracefulShutdown`, `ForceOff`, `ForceRestart`). `get_power_state` now offers those the + server accepts in its state (`On` when off, the other three when on). IPMI has no + graceful restart, and pyghmi offers no power cycle; neither is offered. The driver + still does not reset the BMC. This is for boards that offer nothing but IPMI, such as a + Supermicro X10 without a Redfish license. + ## [0.2.1] - 2026-10-13 ### Added diff --git a/README.md b/README.md index 1d74a70..46e8d95 100644 --- a/README.md +++ b/README.md @@ -1,7 +1,7 @@ # napalm-redfish NAPALM driver for server **baseboard management controllers (BMCs)** over -[DMTF Redfish](https://www.dmtf.org/standards/redfish), with a read-only IPMI fallback for +[DMTF Redfish](https://www.dmtf.org/standards/redfish), with an IPMI fallback for boards that predate Redfish. Built on [napalm-device-types](https://git.netork.io/NAPALM/napalm-device-types). Written for netOrk. @@ -13,8 +13,9 @@ napalm-device-types: mockups. A BMC that refuses Redfish for want of a vendor license (a Supermicro X10 without SUM, DCMS or OOB) still opens: the driver reports `bmc_license_required` through `get_device_warnings` instead of failing. -- `ipmi` (`napalm_redfish.ipmi.IpmiDriver`) reads over IPMI 2.0 only: FRU facts and - inventory, sensors, power state, event log, BMC firmware. Tested against recordings of a +- `ipmi` (`napalm_redfish.ipmi.IpmiDriver`) reads over IPMI 2.0: FRU facts and + inventory, sensors, power state, event log, BMC firmware. It also switches the server's + power (chassis control), but does not reset the BMC. Tested against recordings of a Supermicro X10DRL-i (BMC 3.91), which refuses Redfish without a license. ## Why Redfish, and why not one driver per vendor @@ -24,7 +25,7 @@ firmware, X11 and later), Dell iDRAC, HPE iLO and Lenovo XCC all implement it. O with small vendor quirk modules covers all of them; a `napalm-supermicro-ipmi` would cover one. -IPMI over LAN stays a **read-only fallback** for boards without Redfish. Its IPMI 2.0 +IPMI over LAN stays a **fallback** for boards without Redfish. Its IPMI 2.0 RAKP handshake hands the password hash to anyone who asks (CVE-2013-4786), so netOrk should not depend on it. Finding IPMI over LAN enabled is itself worth reporting, see [docs/security.md](docs/security.md). @@ -34,7 +35,7 @@ not depend on it. Finding IPMI over LAN enabled is itself worth reporting, see | Entry point | Transport | Scope | |---|---|---| | `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-only: facts, sensors, power state, SEL | +| `ipmi` | IPMI 2.0 over LAN (UDP 623) via `pyghmi` | Read, plus power control (no graceful restart, no power cycle); no BMC reset | 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 ff65199..f428153 100644 --- a/docs/redfish-map.md +++ b/docs/redfish-map.md @@ -39,6 +39,9 @@ must follow the collections instead of assuming IDs, because vendors differ (Del ## IPMI fallback (`ipmi` entry point) -Read-only, via `pyghmi`: device ID and FRU data for `get_facts`, sensor data repository for -`get_environment`, chassis status for `get_power_state`, SEL for `get_event_log`. No power -control over IPMI. +Via `pyghmi`: device ID and FRU data for `get_facts`, sensor data repository for +`get_environment`, chassis status for `get_power_state`, SEL for `get_event_log`. +`set_power_state` sends a chassis control (`On` → power up, `GracefulShutdown` → soft off +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. diff --git a/docs/security.md b/docs/security.md index eda09ac..cd34584 100644 --- a/docs/security.md +++ b/docs/security.md @@ -19,7 +19,9 @@ credentials are worth as much as root on every machine it manages. offline. This is part of the protocol, not a bug a firmware update fixes. - Cipher suite 0 (no authentication) is enabled on some BMCs and allows login with any password. -- So the `ipmi` entry point is read-only and a fallback only. `get_management_protocols` +- So the `ipmi` entry point is a fallback only. It switches the server's power, because + some boards (a Supermicro X10 without a Redfish license) offer nothing else, but it does + not reset the BMC. `get_management_protocols` reporting `IPMI.ProtocolEnabled = true` should become a finding in netOrk, with the advice to disable IPMI over LAN where Redfish is available. @@ -28,7 +30,7 @@ credentials are worth as much as root on every machine it manages. - `set_power_state` can take a production server down. In netOrk it needs a `ConfirmDialog`, a permission of its own, and an audit log entry. - `ForceOff`/`ForceRestart`/`PowerCycle` skip the OS shutdown; offer `GracefulShutdown`/ - `GracefulRestart` first. + `GracefulRestart` first. Over IPMI there is no graceful restart, and no power cycle. - `reboot_host` restarts only the BMC (`Manager.Reset`). The managed server keeps running. ## Firmware diff --git a/napalm_redfish/__init__.py b/napalm_redfish/__init__.py index 4648fa6..afdce1d 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.2.1" +__version__ = "0.3.0" __all__ = ["RedfishDriver"] diff --git a/napalm_redfish/ipmi.py b/napalm_redfish/ipmi.py index e3542a6..e965948 100644 --- a/napalm_redfish/ipmi.py +++ b/napalm_redfish/ipmi.py @@ -1,9 +1,9 @@ """Read-only NAPALM driver for BMCs over IPMI 2.0, for boards that refuse or lack Redfish. IPMI over LAN gives a password hash to anyone who names a valid user (CVE-2013-4786), so -this is the fallback, not the main path. It only reads: facts and inventory from the FRU, -sensors, the power state and the system event log. It neither switches the server's power -nor resets the BMC; those go through Redfish. +this is the fallback, not the main path. It reads facts and inventory from the FRU, +sensors, the power state and the system event log, and switches the server's power +(chassis control). It does not reset the BMC; that goes through Redfish. """ from __future__ import annotations @@ -12,6 +12,8 @@ from typing import Any from napalm.base.exceptions import ConnectionException from napalm_device_types import HealthMetricsMixin, ManagementControllerDriver +from napalm_device_types.models import POWER_ACTIONS +from pyghmi.exceptions import IpmiException from pyghmi.ipmi.command import Command from pyghmi.ipmi.private.session import Session @@ -21,6 +23,14 @@ _JsonDict = dict[str, Any] _WARNING, _CRITICAL, _FAILED = 1, 2, 4 #: The only interface a BMC reports over IPMI: its own LAN channel. _BMC = "bmc" +#: Role power actions -> pyghmi's chassis control. IPMI has no graceful restart, and pyghmi +#: offers no power cycle; neither is offered. +_CHASSIS_CONTROL = { + "On": "on", + "GracefulShutdown": "shutdown", + "ForceOff": "off", + "ForceRestart": "reset", +} def _severity(health: int) -> str: @@ -36,7 +46,7 @@ def _supply_present(reading: Any) -> bool: class IpmiDriver(HealthMetricsMixin, ManagementControllerDriver): - """NAPALM driver for BMCs over IPMI 2.0 (lanplus), read-only. + """NAPALM driver for BMCs over IPMI 2.0 (lanplus): reads, and the server's power. ``HealthMetricsMixin``: the BMC's own load, memory and interfaces over SNMP (UCD-MIB), where the BMC runs net-snmp. The managed server's sensors come from ``get_environment``. @@ -185,10 +195,35 @@ class IpmiDriver(HealthMetricsMixin, ManagementControllerDriver): # --- role methods -------------------------------------------------------------------- + def _power_state(self) -> str: + return str(self._cmd().get_power().get("powerstate") or "").capitalize() + + @staticmethod + def _accepted_actions(state: str) -> list[str]: + """The power actions that make sense in ``state``; IPMI advertises none itself.""" + if state == "Off": + return ["On"] + if state == "On": + return [a for a in POWER_ACTIONS if a in _CHASSIS_CONTROL and a != "On"] + return [] + def get_power_state(self) -> _JsonDict: - state = str(self._cmd().get_power().get("powerstate") or "") - # Read-only: no action is offered. - return {"state": state.capitalize(), "actions": []} + state = self._power_state() + return {"state": state, "actions": self._accepted_actions(state)} + + def set_power_state(self, action: str) -> None: + if action not in POWER_ACTIONS: + raise ValueError(f"{action!r} is not one of {', '.join(POWER_ACTIONS)}") + state = self._power_state() + accepted = self._accepted_actions(state) + if action not in accepted: + raise RuntimeError( + f"{self.hostname}: {action} not accepted while the server is " + f"{state or 'in an unknown state'} (accepted: {', '.join(accepted) or 'none'})") + try: + self._cmd().set_power(_CHASSIS_CONTROL[action], wait=False) + except IpmiException as exc: + raise RuntimeError(f"{self.hostname}: {action} refused: {exc}") from exc def get_hardware_inventory(self) -> _JsonDict: """What the FRU and the sensor records tell; IPMI lists no CPUs, DIMMs or drives.""" diff --git a/pyproject.toml b/pyproject.toml index 06cb2e8..0199e94 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-redfish" -version = "0.2.1" +version = "0.3.0" description = "NAPALM driver for server baseboard management controllers over DMTF Redfish" readme = "README.md" license = { file = "LICENSE" } diff --git a/tests/test_ipmi.py b/tests/test_ipmi.py index 692c191..ddd1266 100644 --- a/tests/test_ipmi.py +++ b/tests/test_ipmi.py @@ -1,7 +1,7 @@ """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 -is read-only: it neither switches the server's power nor resets the BMC. +reads, and switches the server's power; it does not reset the BMC. """ import json @@ -31,6 +31,8 @@ class FakeCommand: 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) @@ -38,7 +40,11 @@ class FakeCommand: self.logged_out = True def get_power(self): - return fixture("power") + 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") @@ -78,13 +84,13 @@ def assert_shape(value, typed_dict): # --- class and connection ------------------------------------------------------------------ -def test_role_and_read_only(): +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" - # Read-only: no power control, no BMC reset over IPMI. - assert not hasattr(IpmiDriver, "set_power_state") + # 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") @@ -195,7 +201,62 @@ def test_sensor_health_flags(driver, monkeypatch): def test_get_power_state(driver): state = driver.get_power_state() assert_shape(state, models.PowerStateDict) - assert state == {"state": "On", "actions": []} + 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):