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.
This commit is contained in:
Christian Manivong committed 2026-10-17 01:04:43 +02:00
1 parent 0e38e18e26
commit 6c60f7ac60
8 files changed
+137 -25

No files matched your search

+1 -1
View File
@@ -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"]
+42 -7
View File
@@ -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."""