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):