feat: add ManagementControllerDriver for server BMCs

A role base for baseboard management controllers (Supermicro, Dell
iDRAC, HPE iLO, Lenovo XCC), reached over Redfish or IPMI, not SSH.

It declares, under TYPE_CHECKING only: get_power_state, set_power_state
(Redfish ResetType actions), get_hardware_inventory (CPUs, DIMMs,
drives, PSUs, NICs, and the server's serial, system UUID and NIC MACs),
get_event_log, get_firmware_inventory and get_management_protocols.

reboot_host restarts the BMC itself (Manager.Reset), never the managed
server, which goes through set_power_state. No HealthMetricsMixin and no
command channel; sensors come through NAPALM's get_environment.
This commit is contained in:
christianmanivong committed 2026-10-13 06:47:19 +02:00
1 parent dd8977750d
commit e509445572
7 files changed
+364 -2

No files matched your search

+11
View File
@@ -36,6 +36,16 @@ class QnapQtsDriver(StorageDriver, HypervisorDriver, LinuxDriver):
`roles_of(cls)`, `role_keys_of(cls)` and `primary_role_of(cls)` read that back. Nothing
restates the ranking: there is no precedence table and no attribute to override.
A server's baseboard management controller (Supermicro BMC, Dell iDRAC, HPE iLO,
Lenovo XCC) is a role of its own, `ManagementControllerDriver` (`management_controller`),
reached over Redfish or IPMI (`USES_SSH = False`). It is not the server it manages:
`reboot_host` restarts the BMC (Redfish `Manager.Reset`), while `set_power_state`
(`On`, `GracefulShutdown`, `ForceOff`, `GracefulRestart`, `ForceRestart`, `PowerCycle`)
acts on the server. `get_hardware_inventory` carries the server's serial number, system
UUID and NIC MACs -- what ties the BMC to the host it manages. Its sensors come through
NAPALM's `get_environment`, with `cpu` and `memory` empty, since a BMC does not know the
host OS's load; it has no `HealthMetricsMixin` (SNMP) and no command channel.
### Role bases declare; they never implement
**A role base must not contain a single runtime method — not even a
@@ -258,6 +268,7 @@ Requires Python ≥ 3.10 (tested up to 3.13) and NAPALM ≥ 4.0.
| `ResidentialGatewayDriver` | Router + firewall + AP in one box | OpenWrt, FritzBox |
| `PhoneDriver` | IP telephony endpoints | Yealink |
| `MediaDriver` | Speakers, streamers, media renderers | Sonos, Chromecast |
| `ManagementControllerDriver` | Server baseboard management controllers | Supermicro BMC, Dell iDRAC, HPE iLO, Lenovo XCC |
Mixins that come with the classes above: `PingSweepMixin` (subnet sweeps, in
every driver through `DeviceTypeDriver`) and `DhcpServerMixin` (static DHCP
+19
View File
@@ -29,6 +29,7 @@ Role bases -- what a device *is*:
* :class:`~napalm_device_types.storage.StorageDriver`
* :class:`~napalm_device_types.phone.PhoneDriver`
* :class:`~napalm_device_types.media.MediaDriver`
* :class:`~napalm_device_types.management_controller.ManagementControllerDriver`
* :class:`~napalm_device_types.residential_gateway.ResidentialGatewayDriver`
Function classes -- what a device *can do*. Shared behaviour lives here once
@@ -92,7 +93,17 @@ from napalm_device_types.listening import (
parse_listening_sockets,
)
from napalm_device_types.mac_acl import MacAclMixin
from napalm_device_types.management_controller import ManagementControllerDriver
from napalm_device_types.media import MediaDriver
from napalm_device_types.models import (
POWER_ACTIONS,
EventLogEntryDict,
FirmwareDict,
HardwareInventoryDict,
ManagementProtocolsDict,
PowerAction,
PowerStateDict,
)
from napalm_device_types.nat_vpn import NatVpnMixin
from napalm_device_types.packages import PackageManagementMixin
from napalm_device_types.phone import PhoneDriver
@@ -154,6 +165,14 @@ __all__ = [
"HypervisorDriver",
"InterfaceFilterMixin",
"MacAclMixin",
"ManagementControllerDriver",
"POWER_ACTIONS",
"PowerAction",
"PowerStateDict",
"HardwareInventoryDict",
"EventLogEntryDict",
"FirmwareDict",
"ManagementProtocolsDict",
"MediaDriver",
"NatVpnMixin",
"OSDriver",
@@ -0,0 +1,152 @@
# -*- coding: utf-8 -*-
"""
Abstract base class for server baseboard management controllers (BMCs).
Usage::
from napalm_device_types import ManagementControllerDriver
class IdracDriver(ManagementControllerDriver):
...
"""
from typing import TYPE_CHECKING, List
from napalm_device_types.base import DeviceTypeDriver
if TYPE_CHECKING: # pragma: no cover - for type checkers only
from napalm_device_types.models import (
EventLogEntryDict,
FirmwareDict,
HardwareInventoryDict,
ManagementProtocolsDict,
PowerAction,
PowerStateDict,
)
class ManagementControllerDriver(DeviceTypeDriver):
"""
Abstract intermediate driver for the management controller of a server
(e.g. Supermicro BMC, Dell iDRAC, HPE iLO, Lenovo XCC), reached over
Redfish or IPMI rather than SSH.
A BMC is not the server it manages. ``reboot_host`` restarts the BMC
itself (Redfish ``Manager.Reset``), never the managed server; the server is
switched on, off and restarted through :meth:`set_power_state`.
The BMC's sensors (temperatures, fans, PSU power) come through NAPALM's
``get_environment``, with ``cpu`` and ``memory`` empty: a BMC does not know
the load of the operating system on its server. There is no
``HealthMetricsMixin`` here -- that is SNMP's -- and
``CommandChannelMixin`` stays unimplemented: a BMC offers no shell to run a
command in.
"""
#: Stable key netOrk exposes as ``device_class``. The order in which a
#: driver lists its role bases is the ranking; see
#: :func:`napalm_device_types.roles.primary_role_of`.
ROLE: str = "management_controller"
TYPE_LABEL: str = "Management Controller"
USES_SSH: bool = False
if TYPE_CHECKING:
def get_power_state(self) -> PowerStateDict:
"""
Returns the managed server's power state.
* state (string) - ``"On"``, ``"Off"``, ``"PoweringOn"`` or ``"PoweringOff"``
* actions (list) - the :data:`~napalm_device_types.models.PowerAction`
values the BMC accepts right now
Example::
{"state": "On", "actions": ["GracefulShutdown", "ForceOff", "ForceRestart"]}
"""
...
def set_power_state(self, action: PowerAction) -> None:
"""
Switches, shuts down or restarts the managed server.
Returns once the BMC has accepted the request; the server takes its
time. ``GracefulShutdown`` and ``GracefulRestart`` ask the operating
system (ACPI) and may be ignored by it.
:param action: One of ``On``, ``GracefulShutdown``, ``ForceOff``,
``GracefulRestart``, ``ForceRestart`` or ``PowerCycle``.
:raises ValueError: If *action* is not one of them.
:raises RuntimeError: If the BMC refuses it, e.g. ``On`` for a
server that is already on.
"""
...
def get_hardware_inventory(self) -> HardwareInventoryDict:
"""
Returns the managed server's hardware: CPUs, DIMMs, drives, power
supplies and NICs, with the server's serial number, its SMBIOS
system UUID and the MAC address of each NIC -- what ties the BMC to
the operating system running on the server.
Example::
{
"manufacturer": "Dell Inc.",
"model": "PowerEdge R650",
"serial_number": "7XK2Q93",
"system_uuid": "4c4c4544-0058-4b10-8032-b7c04f513933",
"cpus": [{"socket": "CPU.Socket.1", "model": "Intel(R) Xeon(R) Silver 4314",
"cores": 16, "threads": 32, "status": "OK"}],
"memory": [...],
"drives": [...],
"power_supplies": [...],
"nics": [{"name": "NIC.Integrated.1-1", "mac_address": "b0:7b:25:de:1a:40",
"speed_mbps": 10000, "link_up": True}],
}
"""
...
def get_event_log(self, limit: int = 500) -> List[EventLogEntryDict]:
"""
Returns the BMC's system event log (SEL, Lifecycle Log, IML),
newest first.
:param limit: At most this many entries.
"""
...
def get_firmware_inventory(self) -> List[FirmwareDict]:
"""
Returns every firmware component the BMC reports: its own, the
BIOS, storage controllers, NICs, power supplies.
Example::
[
{"name": "Integrated Dell Remote Access Controller",
"version": "7.00.00.172", "updateable": True},
{"name": "BIOS", "version": "1.13.2", "updateable": True},
]
"""
...
def get_management_protocols(self) -> ManagementProtocolsDict:
"""
Returns what the BMC itself listens on: IPMI, SSH, HTTP, HTTPS,
SNMP, the KVM console and virtual media, each with whether it is
enabled and its port.
Example::
{
"ipmi": {"enabled": False, "port": 623},
"ssh": {"enabled": True, "port": 22},
"http": {"enabled": False, "port": 80},
"https": {"enabled": True, "port": 443},
"snmp": {"enabled": False, "port": 161},
"kvm": {"enabled": True, "port": 5900},
"virtual_media": {"enabled": True, "port": None},
}
"""
...
+117 -1
View File
@@ -5,7 +5,7 @@ These supplement the types defined in napalm.base.models and are used by the
abstract device-type driver classes in this package.
"""
from typing import Dict, List, Optional
from typing import Dict, List, Literal, Optional, Tuple, get_args
from typing_extensions import NotRequired, TypedDict
@@ -1035,3 +1035,119 @@ class PingSweepResultDict(TypedDict):
scanned: int # destinations actually probed
alive_count: int # entries with alive=True
truncated: bool # True if targets were dropped at the sweep cap
# ---------------------------------------------------------------------------
# Management controller (BMC: Supermicro, iDRAC, iLO, XCC)
# ---------------------------------------------------------------------------
#: What ``set_power_state`` does to the managed server; Redfish's ResetType names.
PowerAction = Literal[
"On",
"GracefulShutdown",
"ForceOff",
"GracefulRestart",
"ForceRestart",
"PowerCycle",
]
POWER_ACTIONS: Tuple[str, ...] = get_args(PowerAction)
class PowerStateDict(TypedDict):
"""The managed server's power, as its BMC reports it."""
state: str # "On", "Off", "PoweringOn" or "PoweringOff"
actions: List[str] # the PowerAction values this BMC accepts now
class BmcCpuDict(TypedDict):
socket: str # "CPU1", "Proc 1"
model: str # "Intel(R) Xeon(R) Silver 4314 CPU @ 2.40GHz"
cores: Optional[int]
threads: Optional[int]
status: str # "OK", "Warning", "Critical", "Absent"
class BmcDimmDict(TypedDict):
slot: str # "DIMMA1", "PROC 1 DIMM 3"
size_bytes: Optional[int]
type: str # "DDR4", "DDR5"; empty when unknown
speed_mts: Optional[int] # MT/s
part_number: str
serial_number: str
status: str
class BmcDriveDict(TypedDict):
slot: str
model: str
serial_number: str
size_bytes: Optional[int]
media: str # "HDD", "SSD"; empty when unknown
protocol: str # "SATA", "SAS", "NVMe"; empty when unknown
status: str
class BmcPsuDict(TypedDict):
slot: str
model: str
serial_number: str
capacity_watts: Optional[int]
status: str
class BmcNicDict(TypedDict):
"""A network port of the managed server, not of the BMC."""
name: str # "NIC.Integrated.1-1", "Port 1"
mac_address: str # lowercase, colon-separated; empty when unknown
speed_mbps: Optional[int]
link_up: Optional[bool]
class HardwareInventoryDict(TypedDict):
"""The managed server's hardware, as its BMC sees it."""
manufacturer: str
model: str
serial_number: str # the server's, not the BMC's
system_uuid: str # SMBIOS system UUID, lowercase
cpus: List[BmcCpuDict]
memory: List[BmcDimmDict]
drives: List[BmcDriveDict]
power_supplies: List[BmcPsuDict]
nics: List[BmcNicDict]
class EventLogEntryDict(TypedDict):
"""One entry of the BMC's system event log (SEL / Lifecycle / IML)."""
id: str
timestamp: Optional[float] # Unix time; None when the BMC gave none
severity: str # "OK", "Warning" or "Critical"
message: str
class FirmwareDict(TypedDict):
"""One firmware component: the BMC's own, BIOS, a controller, a NIC."""
name: str # "BMC", "BIOS", "PERC H755 Front"
version: str
updateable: Optional[bool]
class ProtocolStateDict(TypedDict):
enabled: bool
port: Optional[int] # None for a protocol without a port of its own
class ManagementProtocolsDict(TypedDict):
"""What the BMC itself listens on."""
ipmi: ProtocolStateDict
ssh: ProtocolStateDict
http: ProtocolStateDict
https: ProtocolStateDict
snmp: ProtocolStateDict
kvm: ProtocolStateDict
virtual_media: ProtocolStateDict
+1 -1
View File
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
[project]
name = "napalm-device-types"
version = "4.5.1"
version = "4.6.0"
description = "Abstract device-type base classes for NAPALM drivers"
readme = "README.md"
requires-python = ">=3.10"
+62
View File
@@ -22,6 +22,7 @@ from napalm_device_types import (
DeviceTypeDriver,
FirewallDriver,
HypervisorDriver,
ManagementControllerDriver,
MediaDriver,
OSDriver,
PhoneDriver,
@@ -35,6 +36,7 @@ ROLE_BASES = [
AccessPointDriver,
FirewallDriver,
HypervisorDriver,
ManagementControllerDriver,
MediaDriver,
OSDriver,
PhoneDriver,
@@ -84,6 +86,12 @@ class TestDeclaredMethodsDoNotExistAtRuntime:
(ResidentialGatewayDriver, "get_wan_status"),
(PhoneDriver, "get_sip_accounts"),
(MediaDriver, "get_playback_state"),
(ManagementControllerDriver, "get_power_state"),
(ManagementControllerDriver, "set_power_state"),
(ManagementControllerDriver, "get_hardware_inventory"),
(ManagementControllerDriver, "get_event_log"),
(ManagementControllerDriver, "get_firmware_inventory"),
(ManagementControllerDriver, "get_management_protocols"),
],
)
def test_absent_until_a_driver_implements_it(self, base, method):
@@ -139,3 +147,57 @@ class TestRoleIntrospection:
assert roles_of(Bare) == []
assert primary_role_of(Bare) is None
class TestManagementController:
"""A BMC is reached over Redfish or IPMI, and it is not the server it manages."""
def test_role_key_and_transport(self):
assert ManagementControllerDriver.ROLE == "management_controller"
assert ManagementControllerDriver.USES_SSH is False
def test_power_actions_are_redfish_reset_types(self):
from napalm_device_types import POWER_ACTIONS
assert POWER_ACTIONS == (
"On",
"GracefulShutdown",
"ForceOff",
"GracefulRestart",
"ForceRestart",
"PowerCycle",
)
def test_carries_neither_snmp_health_metrics_nor_a_command_channel(self):
"""Health metrics are SNMP; the BMC's sensors come through get_environment."""
from napalm_device_types import HealthMetricsMixin
assert not issubclass(ManagementControllerDriver, HealthMetricsMixin)
assert not hasattr(ManagementControllerDriver, "get_health_metrics")
assert not hasattr(ManagementControllerDriver, "run_command")
assert not hasattr(ManagementControllerDriver, "open_stream")
def test_docstring_says_reboot_host_restarts_the_bmc_not_the_server(self):
doc = ManagementControllerDriver.__doc__ or ""
assert "reboot_host" in doc and "Manager.Reset" in doc
assert "set_power_state" in doc
assert "get_environment" in doc
def test_its_return_types_are_exported(self):
import napalm_device_types as package
for name in (
"PowerAction",
"PowerStateDict",
"HardwareInventoryDict",
"EventLogEntryDict",
"FirmwareDict",
"ManagementProtocolsDict",
):
assert name in package.__all__ and hasattr(package, name), name
def test_a_server_driver_can_rank_it_first(self):
class Idrac(ManagementControllerDriver):
pass
assert primary_role_of(Idrac) == "management_controller"
+2
View File
@@ -5,6 +5,7 @@ from napalm_device_types import (
AccessPointDriver,
FirewallDriver,
HypervisorDriver,
ManagementControllerDriver,
OSDriver,
ResidentialGatewayDriver,
StorageDriver,
@@ -16,6 +17,7 @@ BASE_CLASSES = [
(AccessPointDriver, "Access Point"),
(FirewallDriver, "Firewall"),
(HypervisorDriver, "Hypervisor"),
(ManagementControllerDriver, "Management Controller"),
(OSDriver, "OS"),
(ResidentialGatewayDriver, "Gateway"),
(StorageDriver, "Storage"),