From e509445572306a9296a1ea5deb90b8259798ce24 Mon Sep 17 00:00:00 2001 From: Christian Manivong Date: Tue, 13 Oct 2026 06:47:19 +0200 Subject: [PATCH] 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. --- README.md | 11 ++ napalm_device_types/__init__.py | 19 +++ napalm_device_types/management_controller.py | 152 +++++++++++++++++++ napalm_device_types/models.py | 118 +++++++++++++- pyproject.toml | 2 +- tests/test_role_contracts.py | 62 ++++++++ tests/test_type_label.py | 2 + 7 files changed, 364 insertions(+), 2 deletions(-) create mode 100644 napalm_device_types/management_controller.py diff --git a/README.md b/README.md index d82f9f3..19982fb 100644 --- a/README.md +++ b/README.md @@ -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 diff --git a/napalm_device_types/__init__.py b/napalm_device_types/__init__.py index a6e27d2..673b6ad 100644 --- a/napalm_device_types/__init__.py +++ b/napalm_device_types/__init__.py @@ -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", diff --git a/napalm_device_types/management_controller.py b/napalm_device_types/management_controller.py new file mode 100644 index 0000000..a1d6df4 --- /dev/null +++ b/napalm_device_types/management_controller.py @@ -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}, + } + """ + ... diff --git a/napalm_device_types/models.py b/napalm_device_types/models.py index ab8fea9..1a5b375 100644 --- a/napalm_device_types/models.py +++ b/napalm_device_types/models.py @@ -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 diff --git a/pyproject.toml b/pyproject.toml index 9d792ec..d484761 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -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" diff --git a/tests/test_role_contracts.py b/tests/test_role_contracts.py index f5673a5..98a32ff 100644 --- a/tests/test_role_contracts.py +++ b/tests/test_role_contracts.py @@ -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" diff --git a/tests/test_type_label.py b/tests/test_type_label.py index e9abfc8..c949a5e 100644 --- a/tests/test_type_label.py +++ b/tests/test_type_label.py @@ -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"),