diff --git a/CHANGELOG.md b/CHANGELOG.md index 97963bd..b403454 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,6 +7,24 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +## [0.2.0] - 2026-10-13 + +### Added +- `ipmi` NAPALM driver (`IpmiDriver`) on `ManagementControllerDriver`, read-only, over IPMI + 2.0 with `pyghmi`: facts and inventory from the FRU (manufacturer, product, serial number, + system UUID), the BMC's LAN interface, temperatures, fans and power supplies from the + sensor records, the power state, the system event log and the BMC firmware version. No + power control and no BMC reset. For BMCs that refuse or lack Redfish; IPMI over LAN leaks + password hashes (CVE-2013-4786), so it is the fallback, not the main path. +- Recorded IPMI fixtures from a Supermicro X10DRL-i (BMC 3.91). + +### Changed +- A BMC that refuses Redfish for want of a vendor license no longer fails `open()`. + `get_facts` returns what is known without Redfish, `get_device_warnings` reports + `bmc_license_required` with the licenses the BMC names, and every other getter raises + `ConnectionException`. A poll therefore reports the missing license instead of an + offline BMC. + ## [0.1.2] - 2026-10-13 ### Fixed diff --git a/README.md b/README.md index 87e9205..1d74a70 100644 --- a/README.md +++ b/README.md @@ -5,11 +5,17 @@ NAPALM driver for server **baseboard management controllers (BMCs)** over boards that predate Redfish. Built on [napalm-device-types](https://git.netork.io/NAPALM/napalm-device-types). Written for netOrk. -**Status: early development.** The `redfish` driver (`napalm_redfish.RedfishDriver`, a -`ManagementControllerDriver` from napalm-device-types) implements the NAPALM getters and -the role methods in [docs/redfish-map.md](docs/redfish-map.md), tested end to end against -DMTF mockups. It has not yet run against real hardware; the first test device is a -Supermicro X10DRL-i (BMC firmware 3.91, Redfish). The `ipmi` fallback does not exist yet. +**Status: early development.** Two drivers, both `ManagementControllerDriver`s from +napalm-device-types: + +- `redfish` (`napalm_redfish.RedfishDriver`) implements the NAPALM getters and the role + methods in [docs/redfish-map.md](docs/redfish-map.md), tested end to end against DMTF + 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 + Supermicro X10DRL-i (BMC 3.91), which refuses Redfish without a license. ## Why Redfish, and why not one driver per vendor @@ -23,7 +29,7 @@ RAKP handshake hands the password hash to anyone who asks (CVE-2013-4786), so ne not depend on it. Finding IPMI over LAN enabled is itself worth reporting, see [docs/security.md](docs/security.md). -## Layout (planned) +## Layout | Entry point | Transport | Scope | |---|---|---| diff --git a/napalm_redfish/__init__.py b/napalm_redfish/__init__.py index 2f1a3fe..5695283 100644 --- a/napalm_redfish/__init__.py +++ b/napalm_redfish/__init__.py @@ -1,6 +1,11 @@ -"""NAPALM driver for server BMCs over DMTF Redfish.""" +"""NAPALM drivers for server BMCs: DMTF Redfish, and read-only IPMI as the fallback. + +Only ``RedfishDriver`` is exported here: NAPALM's own ``get_network_driver("redfish")`` +takes the first driver class of this package. ``IpmiDriver`` lives in +``napalm_redfish.ipmi`` and is registered as the ``ipmi`` entry point. +""" from napalm_redfish.driver import RedfishDriver -__version__ = "0.1.2" +__version__ = "0.2.0" __all__ = ["RedfishDriver"] diff --git a/napalm_redfish/driver.py b/napalm_redfish/driver.py index 4df9ad2..e25bf33 100644 --- a/napalm_redfish/driver.py +++ b/napalm_redfish/driver.py @@ -65,7 +65,12 @@ from napalm_redfish.parsers import ( to_napalm_users, ) from napalm_redfish.parsers.common import link, link_list, odata_id -from napalm_redfish.transport import RedfishError, RedfishNotFound, RedfishTransport +from napalm_redfish.transport import ( + RedfishError, + RedfishLicenseError, + RedfishNotFound, + RedfishTransport, +) LOG = logging.getLogger(__name__) @@ -152,12 +157,20 @@ class RedfishDriver(ManagementControllerDriver): retries=self.optional_args.get("retries", 1), ) self._cache: _JsonDict = {} + self._license_refused: RedfishLicenseError | None = None # --- connection ---------------------------------------------------------------------- def open(self) -> None: + """Connect. A BMC that refuses Redfish for want of a license is reachable all the + same: ``open`` succeeds, ``get_facts`` returns what is known without Redfish, and + ``get_device_warnings`` says which license is missing. Every other getter raises. + """ + self._license_refused = None try: self.transport.open() + except RedfishLicenseError as exc: + self._license_refused = exc except RedfishError as exc: raise ConnectionException(f"Cannot connect to Redfish at {self.hostname}: {exc}") \ from exc @@ -167,6 +180,8 @@ class RedfishDriver(ManagementControllerDriver): self._cache = {} def is_alive(self) -> _JsonDict: + if self._license_refused is not None: + return {"is_alive": True} try: self.transport.get("/redfish/v1/") except Exception: @@ -181,6 +196,8 @@ class RedfishDriver(ManagementControllerDriver): return self._cache[key] def _root(self) -> _JsonDict: + if self._license_refused is not None: + raise ConnectionException(f"{self.hostname}: {self._license_refused}") return self._cached("root", lambda: parse_service_root(self.transport.root)) def _first(self, collection: str) -> _JsonDict: @@ -233,6 +250,10 @@ class RedfishDriver(ManagementControllerDriver): # --- NAPALM getters ---------------------------------------------------------------- def get_facts(self) -> _JsonDict: + if self._license_refused is not None: + return {"uptime": -1.0, "vendor": "", "model": "", "hostname": self.hostname, + "fqdn": self.hostname, "os_version": "", "serial_number": "", + "interface_list": []} system = parse_system(self._system_raw()) manager = parse_manager(self._manager_raw()) interfaces = self._manager_interfaces() @@ -329,6 +350,14 @@ class RedfishDriver(ManagementControllerDriver): communities[community["name"]] = {"acl": "", "mode": mode} return {"chassis_id": "", "community": communities, "contact": "", "location": ""} + def get_device_warnings(self) -> list[_JsonDict]: + """Raw signals for netOrk's warning catalog; presentation is netOrk's.""" + refused = self._license_refused + if refused is None: + return [] + return [{"code": "bmc_license_required", + "meta": {"licenses": refused.licenses, "message": refused.detail}}] + # --- role: power ------------------------------------------------------------------- def _accepted_actions(self, system: _JsonDict) -> list[str]: diff --git a/napalm_redfish/ipmi.py b/napalm_redfish/ipmi.py new file mode 100644 index 0000000..2d52dbb --- /dev/null +++ b/napalm_redfish/ipmi.py @@ -0,0 +1,221 @@ +"""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. +""" + +from __future__ import annotations + +from typing import Any + +from napalm.base.exceptions import ConnectionException +from napalm_device_types import ManagementControllerDriver +from pyghmi.ipmi.command import Command + +_JsonDict = dict[str, Any] + +#: pyghmi's health bits (pyghmi.constants.Health). +_WARNING, _CRITICAL, _FAILED = 1, 2, 4 +#: The only interface a BMC reports over IPMI: its own LAN channel. +_BMC = "bmc" + + +def _severity(health: int) -> str: + if health & (_CRITICAL | _FAILED): + return "Critical" + if health & _WARNING: + return "Warning" + return "OK" + + +def _supply_present(reading: Any) -> bool: + return "Present" in (getattr(reading, "states", None) or []) + + +class IpmiDriver(ManagementControllerDriver): + """NAPALM driver for BMCs over IPMI 2.0 (lanplus), read-only.""" + + platform = "ipmi" + DRIVER_NAME = "ipmi" + #: IPMI is UDP; discovery probes no UDP, so this driver has no fingerprint. + default_port = 623 + + def __init__( + self, + hostname: str, + username: str, + password: str, + timeout: int = 60, + optional_args: _JsonDict | None = None, + ) -> None: + self.hostname = hostname + self.username = username + self.password = password + self.timeout = timeout + self.optional_args: _JsonDict = optional_args or {} + self.port = int(self.optional_args.get("port") or self.default_port) + self._ipmi: Any = None + + # --- connection ---------------------------------------------------------------------- + + def open(self) -> None: + try: + self._ipmi = Command( + bmc=self.hostname, + userid=self.username, + password=self.password, + port=self.port, + keepalive=False, + ) + except Exception as exc: + raise ConnectionException(f"Cannot connect to IPMI at {self.hostname}: {exc}") \ + from exc + + def close(self) -> None: + ipmi, self._ipmi = self._ipmi, None + if ipmi is not None: + try: + ipmi.ipmi_session.logout() + except Exception: # a session left open times out on the BMC + pass + + def is_alive(self) -> _JsonDict: + return {"is_alive": self._ipmi is not None} + + def _cmd(self) -> Any: + if self._ipmi is None: + raise ConnectionException(f"{self.hostname}: not connected, call open() first") + return self._ipmi + + # --- reads --------------------------------------------------------------------------- + + def _system(self) -> _JsonDict: + """The "System" FRU record: manufacturer, product, serial number, UUID.""" + for name, record in self._cmd().get_inventory(): + if name == "System": + return record or {} + return {} + + def _bmc_version(self) -> str: + for name, info in self._cmd().get_firmware(): + if name == "BMC Version": + return str(info.get("version") or "") + return "" + + def _sensors(self) -> list[Any]: + return list(self._cmd().get_sensor_data()) + + # --- NAPALM getters ------------------------------------------------------------------ + + def get_facts(self) -> _JsonDict: + system = self._system() + return { + "uptime": -1.0, # IPMI has no uptime + "vendor": system.get("Manufacturer") or "", + "model": system.get("Product name") or system.get("Model") or "", + "hostname": self.hostname, + "fqdn": self.hostname, + "os_version": self._bmc_version(), + "serial_number": system.get("Serial Number") or "", + "interface_list": [_BMC], + } + + def get_interfaces(self) -> _JsonDict: + net = self._cmd().get_net_configuration() + source = net.get("ipv4_configuration") or "" + return { + _BMC: { + "is_up": True, + "is_enabled": True, + "description": f"BMC LAN ({source})" if source else "BMC LAN", + "last_flapped": -1.0, + "speed": -1.0, + "mtu": -1, + "mac_address": (net.get("mac_address") or "").lower(), + } + } + + def get_interfaces_ip(self) -> _JsonDict: + address = self._cmd().get_net_configuration().get("ipv4_address") or "" + if "/" not in address: + return {} + ip, prefix = address.split("/", 1) + return {_BMC: {"ipv4": {ip: {"prefix_length": int(prefix)}}}} + + def get_environment(self) -> _JsonDict: + """Fans, temperatures and present power supplies from the sensor records; ``cpu`` and + ``memory`` are empty, since a BMC does not know the host OS's load.""" + fans: _JsonDict = {} + temperature: _JsonDict = {} + power: _JsonDict = {} + for reading in self._sensors(): + health = int(getattr(reading, "health", 0) or 0) + value = getattr(reading, "value", None) + if reading.type == "Temperature" and value is not None: + temperature[reading.name] = { + "temperature": float(value), + "is_alert": bool(health), + "is_critical": bool(health & (_CRITICAL | _FAILED)), + } + elif reading.type == "Fan" and value is not None: + fans[reading.name] = {"status": value > 0 and not health} + elif reading.type == "Power Supply" and _supply_present(reading): + power[reading.name] = {"status": not health, "capacity": -1.0, "output": -1.0} + return {"fans": fans, "temperature": temperature, "power": power, "cpu": {}, "memory": {}} + + # --- role methods -------------------------------------------------------------------- + + 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": []} + + def get_hardware_inventory(self) -> _JsonDict: + """What the FRU and the sensor records tell; IPMI lists no CPUs, DIMMs or drives.""" + system = self._system() + supplies = [ + { + "slot": reading.name.removesuffix(" Status"), + "model": "", + "serial_number": "", + "capacity_watts": None, + "status": _severity(int(reading.health or 0)) + if _supply_present(reading) else "Absent", + } + for reading in self._sensors() + if reading.type == "Power Supply" + ] + return { + "manufacturer": system.get("Manufacturer") or "", + "model": system.get("Product name") or system.get("Model") or "", + "serial_number": system.get("Serial Number") or "", + "system_uuid": str(system.get("UUID") or "").lower(), + "cpus": [], + "memory": [], + "drives": [], + "power_supplies": supplies, + "nics": [], + } + + def get_event_log(self, limit: int = 500) -> list[_JsonDict]: + """The system event log, newest first.""" + entries = [] + for event in self._cmd().get_event_log(): + component = event.get("component") or "" + text = event.get("event") or "" + timecode = event.get("timecode") + entries.append({ + "id": str(event.get("record_id", "")), + "timestamp": float(timecode) if timecode else None, + "severity": _severity(int(event.get("severity") or 0)), + "message": f"{component}: {text}" if component and text else component or text, + }) + entries.sort(key=lambda e: (e["timestamp"] is not None, e["timestamp"] or 0.0), + reverse=True) + return entries[:limit] + + def get_firmware_inventory(self) -> list[_JsonDict]: + version = self._bmc_version() + return [{"name": "BMC", "version": version, "updateable": None}] if version else [] diff --git a/napalm_redfish/transport.py b/napalm_redfish/transport.py index c01cc60..a6596be 100644 --- a/napalm_redfish/transport.py +++ b/napalm_redfish/transport.py @@ -61,6 +61,13 @@ class RedfishError(Exception): class RedfishLicenseError(RedfishError): """The service refuses Redfish until a vendor license is activated.""" + def __init__(self, message: str, *, licenses: str = "", detail: str = ""): + super().__init__(message) + #: The licenses the service names, e.g. "SUM DCMS OOB"; empty when it names none. + self.licenses = licenses + #: The service's own message. + self.detail = detail + class RedfishAuthError(RedfishError): """Login failed, or the account may not read the resource (HTTP 401/403).""" @@ -74,6 +81,19 @@ class RedfishNotFound(RedfishHTTPError): """The resource does not exist (HTTP 404).""" +def _message_args(body: Any) -> list[str]: + """The MessageArgs of a Redfish error body's extended info, in order.""" + error = body.get("error") if isinstance(body, Mapping) else None + if not isinstance(error, Mapping): + return [] + return [ + str(arg) + for info in error.get("@Message.ExtendedInfo") or [] + if isinstance(info, Mapping) + for arg in info.get("MessageArgs") or [] + ] + + def _message_ids(body: Any) -> list[str]: """The MessageIds of a Redfish error body's extended info.""" error = body.get("error") if isinstance(body, Mapping) else None @@ -202,7 +222,9 @@ class RedfishTransport: detail = _error_message(body) if any(message_id.endswith(LICENSE_REFUSED) for message_id in _message_ids(body)): return RedfishLicenseError( - f"{self.base_url}: Redfish needs a vendor license on this BMC: {detail}" + f"{self.base_url}: Redfish needs a vendor license on this BMC: {detail}", + licenses=" ".join(" ".join(_message_args(body)).split()), + detail=detail, ) return RedfishError(f"{self.base_url}: {reason}" + (f": {detail}" if detail else "")) diff --git a/pyproject.toml b/pyproject.toml index 2f7dd38..782a01b 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta" [project] name = "napalm-redfish" -version = "0.1.2" +version = "0.2.0" description = "NAPALM driver for server baseboard management controllers over DMTF Redfish" readme = "README.md" license = { file = "LICENSE" } @@ -14,6 +14,7 @@ dependencies = [ # Not on PyPI: install from git before this package (see README). "napalm-device-types>=4.8", "redfish>=3.2", + "pyghmi>=1.5", ] classifiers = [ "License :: OSI Approved :: Apache Software License", @@ -30,6 +31,7 @@ test = [ [project.entry-points."napalm.drivers"] redfish = "napalm_redfish:RedfishDriver" +ipmi = "napalm_redfish.ipmi:IpmiDriver" [tool.setuptools.packages.find] include = ["napalm_redfish*"] diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/COMMANDS.txt b/tests/fixtures/supermicro-x10drl-i-3.91/COMMANDS.txt index 6b492ac..8455f11 100644 --- a/tests/fixtures/supermicro-x10drl-i-3.91/COMMANDS.txt +++ b/tests/fixtures/supermicro-x10drl-i-3.91/COMMANDS.txt @@ -4,3 +4,9 @@ without a Supermicro license. Requests were made without credentials. service_root_403.json GET /redfish/v1/ -> HTTP 403, Content-Type application/json landing_page.html GET / -> HTTP 200, the BMC web login page (trimmed to the head and the login form action) + +ipmi/ Read over IPMI 2.0 (pyghmi 1.6, lanplus) with a BMC account, 2026-10-13. One file per + pyghmi call: power (get_power), net_configuration (get_net_configuration), + firmware (get_firmware), inventory (get_inventory), sensors (get_sensor_data, + each SensorReading's attributes), health (get_health), event_log (get_event_log). + Serial numbers, the system UUID, MAC addresses and IPs are replaced by placeholders. diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/event_log.json b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/event_log.json new file mode 100644 index 0000000..f0bcb56 --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/event_log.json @@ -0,0 +1,16 @@ +[ + { + "record_id": 1, + "timecode": 1790788051, + "component_id": "32.201.0", + "component": "PS2 Status", + "deassertion": true, + "component_type_id": 8, + "event_id": "111.1", + "component_type": "Power Supply", + "event": "Recovered", + "severity": 0, + "oem_handler": null, + "timestamp": "2026-09-30T15:07:31" + } +] diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/firmware.json b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/firmware.json new file mode 100644 index 0000000..f84e5d8 --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/firmware.json @@ -0,0 +1,8 @@ +[ + [ + "BMC Version", + { + "version": "3.91" + } + ] +] diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/health.json b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/health.json new file mode 100644 index 0000000..573a0c9 --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/health.json @@ -0,0 +1,4 @@ +{ + "badreadings": [], + "health": 0 +} diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/inventory.json b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/inventory.json new file mode 100644 index 0000000..84a7ad2 --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/inventory.json @@ -0,0 +1,50 @@ +[ + [ + "System", + { + "Board manufacture date": null, + "Board manufacturer": "Supermicro", + "Board product name": "X10DRL-i", + "Board serial number": "ZM000S000001", + "Board model": "X10DRL-i", + "Board FRU Id": null, + "board_extra": [], + "Manufacturer": "Supermicro", + "Product name": "X10DRL-i", + "Model": "X10DRL-i", + "Hardware Version": null, + "Serial Number": "ZM000S000001", + "Asset Number": null, + "FRU ID": null, + "product_extra": [], + "firmware_version": "3.91", + "Device ID": 32, + "Device Revision": 1, + "Manufacturer ID": 10876, + "Product ID": 2097, + "oem_parser": null, + "UUID": "00000000-534D-C40C-0000-000000000001" + } + ], + [ + "BMC FRU", + { + "Board manufacture date": null, + "Board manufacturer": "Supermicro", + "Board product name": "X10DRL-i", + "Board serial number": "ZM000S000001", + "Board model": "X10DRL-i", + "Board FRU Id": null, + "board_extra": [], + "Manufacturer": "Supermicro", + "Product name": "X10DRL-i", + "Model": "X10DRL-i", + "Hardware Version": null, + "Serial Number": "ZM000S000001", + "Asset Number": null, + "FRU ID": null, + "product_extra": [], + "oem_parser": null + } + ] +] diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/net_configuration.json b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/net_configuration.json new file mode 100644 index 0000000..d5a2248 --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/net_configuration.json @@ -0,0 +1,10 @@ +{ + "ipv4_address": "192.0.2.150/24", + "ipv4_configuration": "DHCP", + "mac_address": "0c:c4:7a:00:00:01", + "ipv4_gateway": "192.0.2.1", + "ipv4_backup_gateway": null, + "ipv4_gateway_mac": "02:00:00:00:00:01", + "ipv4_backup_gateway_mac": null, + "vlan_id": "off" +} diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/power.json b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/power.json new file mode 100644 index 0000000..0b31123 --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/power.json @@ -0,0 +1,3 @@ +{ + "powerstate": "on" +} diff --git a/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/sensors.json b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/sensors.json new file mode 100644 index 0000000..99a7f0f --- /dev/null +++ b/tests/fixtures/supermicro-x10drl-i-3.91/ipmi/sensors.json @@ -0,0 +1,498 @@ +[ + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 34.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "CPU1 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 37.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "CPU2 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 28.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "PCH Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 18.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "System Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 38.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "Peripheral Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 29.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "Vcpu1VRM Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 33.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "Vcpu2VRM Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 24.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "VmemABVRM Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 22.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "VmemCDVRM Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 35.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "VmemEFVRM Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 30.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "VmemGHVRM Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 25.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "P1-DIMMA1 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 24.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "P1-DIMMB1 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 25.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "P1-DIMMC1 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 22.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "P1-DIMMD1 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 26.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "P2-DIMME1 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 26.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "P2-DIMMF1 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 27.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "P2-DIMMG1 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Temperature", + "value": 27.0, + "imprecision": 0.5, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "°C", + "name": "P2-DIMMH1 Temp" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Fan", + "value": 6300.0, + "imprecision": 50.0, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "RPM", + "name": "FAN2" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Fan", + "value": 6900.0, + "imprecision": 50.0, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "RPM", + "name": "FAN3" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Fan", + "value": 6400.0, + "imprecision": 50.0, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "RPM", + "name": "FAN4" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Fan", + "value": 5100.0, + "imprecision": 50.0, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "RPM", + "name": "FANB" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 12.0, + "imprecision": 0.03149999999999942, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "12V" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 4.922000000000001, + "imprecision": 0.013000000000000789, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "5VCC" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 3.35, + "imprecision": 0.008500000000000174, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "3.3VCC" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 2.69, + "imprecision": 0.0129999999999999, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "VBAT" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 1.818, + "imprecision": 0.0044999999999999485, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "Vcpu1" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 1.818, + "imprecision": 0.0044999999999999485, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "Vcpu2" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 1.2000000000000002, + "imprecision": 0.0045000000000001705, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "VDIMMAB" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 1.2000000000000002, + "imprecision": 0.0045000000000001705, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "VDIMMCD" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 1.2000000000000002, + "imprecision": 0.0045000000000001705, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "VDIMMEF" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 1.2000000000000002, + "imprecision": 0.0045000000000001705, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "VDIMMGH" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 4.896, + "imprecision": 0.0129999999999999, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "5VSB" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 3.197, + "imprecision": 0.008500000000000174, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "3.3VSB" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 1.5, + "imprecision": 0.0044999999999999485, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "1.5V PCH" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 1.209, + "imprecision": 0.0044999999999999485, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "1.2V BMC" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Voltage", + "value": 1.05, + "imprecision": 0.0044999999999999485, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "V", + "name": "1.05V PCH" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Physical Security", + "value": null, + "imprecision": null, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "", + "name": "Chassis Intru" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Power Supply", + "value": null, + "imprecision": null, + "states": [ + "Present" + ], + "state_ids": [ + 552704 + ], + "unavailable": 0, + "units": "", + "name": "PS1 Status" + }, + { + "broken_sensor_ids": {}, + "health": 0, + "type": "Power Supply", + "value": null, + "imprecision": null, + "states": [], + "state_ids": [], + "unavailable": 0, + "units": "", + "name": "PS2 Status" + } +] diff --git a/tests/test_driver.py b/tests/test_driver.py index 3ef445a..ce8e8e3 100644 --- a/tests/test_driver.py +++ b/tests/test_driver.py @@ -490,14 +490,43 @@ def test_get_management_protocols_missing(driver, server): assert protocols["ssh"] == {"enabled": False, "port": None} -def test_unlicensed_bmc_says_so(server): +def _unlicensed(server): # A Supermicro X10 refuses Redfish entirely until a license is activated. x10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91" - body = json.loads((x10 / "service_root_403.json").read_text()) - server.override("GET", "/redfish/v1/", 403, body) + server.override("GET", "/redfish/v1/", 403, + json.loads((x10 / "service_root_403.json").read_text())) d = RedfishDriver("bmc.example.net", "netork", "s3cret", optional_args={"verify_ssl": False}) - with pytest.raises(ConnectionException) as exc: - d.open() - message = str(exc.value) - assert "license" in message.lower() - assert "SUM DCMS OOB" in message + d.open() + return d + + +def test_unlicensed_bmc_still_opens(server): + # Reachable, just refusing: the poll goes through and says why instead of + # reporting the BMC offline. + d = _unlicensed(server) + assert d.is_alive() == {"is_alive": True} + assert d.get_facts() == { + "uptime": -1.0, "vendor": "", "model": "", "hostname": "bmc.example.net", + "fqdn": "bmc.example.net", "os_version": "", "serial_number": "", "interface_list": [], + } + + +def test_unlicensed_bmc_reports_the_missing_license(server): + (warning,) = _unlicensed(server).get_device_warnings() + assert warning["code"] == "bmc_license_required" + assert warning["meta"]["licenses"] == "SUM DCMS OOB" + assert "Not licensed" in warning["meta"]["message"] + # Raw signal only: presentation is netOrk's. + assert set(warning) == {"code", "meta"} + + +def test_unlicensed_bmc_refuses_everything_else(server): + d = _unlicensed(server) + with pytest.raises(ConnectionException, match="license"): + d.get_interfaces() + with pytest.raises(ConnectionException, match="license"): + d.get_hardware_inventory() + + +def test_licensed_bmc_has_no_warnings(driver): + assert driver.get_device_warnings() == [] diff --git a/tests/test_ipmi.py b/tests/test_ipmi.py new file mode 100644 index 0000000..0500367 --- /dev/null +++ b/tests/test_ipmi.py @@ -0,0 +1,248 @@ +"""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. +""" + +import json +from pathlib import Path +from types import SimpleNamespace + +import pytest +from napalm.base.exceptions import ConnectionException +from napalm_device_types import ManagementControllerDriver, models +from napalm_device_types.roles import primary_role_of + +import napalm_redfish.ipmi as ipmi +from napalm_redfish.ipmi import IpmiDriver + +X10 = Path(__file__).parent / "fixtures" / "supermicro-x10drl-i-3.91" / "ipmi" + + +def fixture(name): + return json.loads((X10 / f"{name}.json").read_text()) + + +class FakeCommand: + """pyghmi's Command, answering from the recorded files.""" + + instances: list = [] + + def __init__(self, bmc=None, userid=None, password=None, port=623, keepalive=True, **kw): + self.args = {"bmc": bmc, "userid": userid, "password": password, "port": port} + self.logged_out = False + self.ipmi_session = SimpleNamespace(logout=self._logout) + FakeCommand.instances.append(self) + + def _logout(self): + self.logged_out = True + + def get_power(self): + return fixture("power") + + def get_net_configuration(self): + return fixture("net_configuration") + + def get_firmware(self): + return iter(tuple(item) for item in fixture("firmware")) + + def get_inventory(self): + return iter(tuple(item) for item in fixture("inventory")) + + def get_sensor_data(self): + return iter(SimpleNamespace(**s) for s in fixture("sensors")) + + def get_event_log(self): + return iter(fixture("event_log")) + + +@pytest.fixture(autouse=True) +def fake_pyghmi(monkeypatch): + FakeCommand.instances = [] + monkeypatch.setattr(ipmi, "Command", FakeCommand) + + +@pytest.fixture +def driver(): + d = IpmiDriver("192.0.2.150", "netork", "s3cret") + d.open() + yield d + d.close() + + +def assert_shape(value, typed_dict): + assert isinstance(value, dict) + assert set(value) == set(typed_dict.__annotations__), typed_dict.__name__ + + +# --- class and connection ------------------------------------------------------------------ + + +def test_role_and_read_only(): + 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") + assert not hasattr(IpmiDriver, "reboot_host") + + +def test_entry_point(): + from importlib.metadata import entry_points + + (ep,) = [e for e in entry_points(group="napalm.drivers") if e.name == "ipmi"] + assert ep.load() is IpmiDriver + + +def test_open_logs_in_on_the_ipmi_port_and_close_logs_out(): + d = IpmiDriver("192.0.2.150", "netork", "s3cret", optional_args={"port": 6230}) + d.open() + (cmd,) = FakeCommand.instances + assert cmd.args == {"bmc": "192.0.2.150", "userid": "netork", "password": "s3cret", + "port": 6230} + assert d.is_alive() == {"is_alive": True} + d.close() + assert cmd.logged_out + assert d.is_alive() == {"is_alive": False} + + +def test_default_port_is_623(): + IpmiDriver("192.0.2.150", "u", "p").open() + assert FakeCommand.instances[0].args["port"] == 623 + + +def test_open_failure_is_connection_exception(monkeypatch): + def refuse(**kwargs): + raise RuntimeError("Incorrect password provided") + + monkeypatch.setattr(ipmi, "Command", refuse) + with pytest.raises(ConnectionException, match="Incorrect password"): + IpmiDriver("192.0.2.150", "netork", "wrong").open() + + +# --- NAPALM getters ------------------------------------------------------------------------ + + +def test_get_facts(driver): + facts = driver.get_facts() + assert facts == { + "uptime": -1.0, + "vendor": "Supermicro", + "model": "X10DRL-i", + "hostname": "192.0.2.150", + "fqdn": "192.0.2.150", + "os_version": "3.91", + "serial_number": "ZM000S000001", + "interface_list": ["bmc"], + } + + +def test_get_interfaces(driver): + assert driver.get_interfaces() == { + "bmc": { + "is_up": True, + "is_enabled": True, + "description": "BMC LAN (DHCP)", + "last_flapped": -1.0, + "speed": -1.0, + "mtu": -1, + "mac_address": "0c:c4:7a:00:00:01", + } + } + + +def test_get_interfaces_ip(driver): + assert driver.get_interfaces_ip() == {"bmc": {"ipv4": {"192.0.2.150": {"prefix_length": 24}}}} + + +def test_get_environment(driver): + env = driver.get_environment() + assert set(env) == {"fans", "temperature", "power", "cpu", "memory"} + assert env["cpu"] == {} and env["memory"] == {} + assert len(env["temperature"]) == 19 + assert env["temperature"]["CPU1 Temp"] == {"temperature": 34.0, "is_alert": False, + "is_critical": False} + assert env["fans"] == {name: {"status": True} for name in ("FAN2", "FAN3", "FAN4", "FANB")} + # Only supplies that report themselves present. + assert env["power"] == {"PS1 Status": {"status": True, "capacity": -1.0, "output": -1.0}} + + +def test_sensor_health_flags(driver, monkeypatch): + readings = [ + {"name": "Hot", "type": "Temperature", "value": 91.0, "units": "°C", "health": 1, + "states": [], "unavailable": 0}, + {"name": "Melting", "type": "Temperature", "value": 105.0, "units": "°C", "health": 2, + "states": [], "unavailable": 0}, + {"name": "Gone", "type": "Temperature", "value": None, "units": "°C", "health": 0, + "states": [], "unavailable": 1}, + {"name": "FAN1", "type": "Fan", "value": 0.0, "units": "RPM", "health": 2, + "states": [], "unavailable": 0}, + ] + monkeypatch.setattr(FakeCommand, "get_sensor_data", + lambda self: iter(SimpleNamespace(**r) for r in readings)) + env = driver.get_environment() + assert env["temperature"] == { + "Hot": {"temperature": 91.0, "is_alert": True, "is_critical": False}, + "Melting": {"temperature": 105.0, "is_alert": True, "is_critical": True}, + } + assert env["fans"] == {"FAN1": {"status": False}} + + +# --- role methods -------------------------------------------------------------------------- + + +def test_get_power_state(driver): + state = driver.get_power_state() + assert_shape(state, models.PowerStateDict) + assert state == {"state": "On", "actions": []} + + +def test_get_hardware_inventory(driver): + inv = driver.get_hardware_inventory() + assert_shape(inv, models.HardwareInventoryDict) + assert inv["manufacturer"] == "Supermicro" + assert inv["model"] == "X10DRL-i" + assert inv["serial_number"] == "ZM000S000001" + assert inv["system_uuid"] == "00000000-534d-c40c-0000-000000000001" + assert inv["power_supplies"] == [ + {"slot": "PS1", "model": "", "serial_number": "", "capacity_watts": None, "status": "OK"}, + {"slot": "PS2", "model": "", "serial_number": "", "capacity_watts": None, + "status": "Absent"}, + ] + assert inv["cpus"] == [] and inv["memory"] == [] and inv["drives"] == [] and inv["nics"] == [] + + +def test_get_event_log(driver): + (entry,) = driver.get_event_log() + assert_shape(entry, models.EventLogEntryDict) + assert entry == {"id": "1", "timestamp": 1790788051.0, "severity": "OK", + "message": "PS2 Status: Recovered"} + + +def test_event_log_newest_first_and_limited(driver, monkeypatch): + sel = [ + {"record_id": i, "timecode": 1000 + i, "component": "FAN1", "event": "Lower critical", + "severity": 2, "deassertion": False} + for i in range(1, 6) + ] + monkeypatch.setattr(FakeCommand, "get_event_log", lambda self: iter(sel)) + entries = driver.get_event_log(limit=2) + assert [e["id"] for e in entries] == ["5", "4"] + assert entries[0]["severity"] == "Critical" + assert entries[0]["message"] == "FAN1: Lower critical" + + +def test_get_firmware_inventory(driver): + (fw,) = driver.get_firmware_inventory() + assert_shape(fw, models.FirmwareDict) + assert fw == {"name": "BMC", "version": "3.91", "updateable": None} + + +def test_napalm_still_finds_redfish_by_name(): + # NAPALM takes the first driver class of the package napalm_. + import napalm + + from napalm_redfish import RedfishDriver + + assert napalm.get_network_driver("redfish") is RedfishDriver