Files
napalm-redfish/napalm_redfish/driver.py
T
christianmanivong 0e38e18e26 fix: IPMI polls after the first failed; BMC drivers collect SNMP health (0.2.1)
The second IPMI poll of a BMC in the same worker process failed with
"'<' not supported between instances of 'float' and 'NoneType'".
pyghmi files a session in Session.initting_sessions under the
credentials as str and deletes it only under bytes, so the entry stays
and the next Command got the old, logged-out session back. The driver
drops that entry itself before open() and after close(). Reproduced and
verified live against the Supermicro X10DRL-i: three polls in one
process now succeed.

IpmiDriver and RedfishDriver mix in HealthMetricsMixin, so netOrk's
SNMP health poll reads the BMC's own load, memory and interfaces where
the BMC runs net-snmp with the UCD-MIB, as the X10's does.
2026-10-13 11:24:29 +02:00

588 lines
24 KiB
Python

"""NAPALM driver for server BMCs over DMTF Redfish.
The device is the BMC. NAPALM's getters describe the BMC (its ports, users, services);
the ``ManagementControllerDriver`` methods describe and control the server it manages.
``reboot_host`` restarts the BMC (``Manager.Reset``), never the server.
Optional args
-------------
port : int
HTTPS port (default 443).
verify_ssl : bool or str
``True`` (default) verifies the certificate against the CA bundle of ``requests``,
a path verifies against that CA file, ``False`` turns verification off (self-signed
BMC certificates are common).
auth : str
``auto`` (default: session, basic auth if the service cannot create sessions),
``session`` or ``basic``.
retries : int
Extra attempts after a connection error (default 1).
The managed server is the first member of ``Systems``; its BMC is the first manager in
its ``Links.ManagedBy`` and its chassis the first in its ``Links.Chassis`` (the first
member of ``Managers``/``Chassis`` when the links are missing).
"""
from __future__ import annotations
import logging
from datetime import datetime, timezone
from typing import Any
from napalm.base.exceptions import ConnectionException
from napalm_device_types import HealthMetricsMixin, ManagementControllerDriver
from napalm_device_types.base import FingerprintRule, PortSpec
from napalm_device_types.models import POWER_ACTIONS
from napalm_redfish.parsers import (
build_environment,
environment_sources,
parse_account,
parse_action_info,
parse_drive,
parse_ethernet_interface,
parse_fan,
parse_firmware,
parse_log_entry,
parse_log_service,
parse_manager,
parse_memory,
parse_network_protocol,
parse_power,
parse_power_state,
parse_power_subsystem,
parse_power_supply,
parse_processor,
parse_reset_action,
parse_sensor,
parse_service_root,
parse_simple_storage,
parse_storage,
parse_system,
parse_thermal,
parse_thermal_metrics,
parse_thermal_subsystem,
to_napalm_users,
)
from napalm_redfish.parsers.common import link, link_list, odata_id
from napalm_redfish.transport import (
RedfishError,
RedfishLicenseError,
RedfishNotFound,
RedfishTransport,
)
LOG = logging.getLogger(__name__)
_JsonDict = dict[str, Any]
#: ``get_management_protocols`` key -> ManagerNetworkProtocol property.
_PROTOCOLS = {
"ipmi": "IPMI",
"ssh": "SSH",
"http": "HTTP",
"https": "HTTPS",
"snmp": "SNMP",
"kvm": "KVMIP",
"virtual_media": "VirtualMedia",
}
_SEVERITIES = ("OK", "Warning", "Critical")
def _timestamp(created: str | None) -> float | None:
"""Unix time of a Redfish ``Created`` value; a time without offset counts as UTC."""
if not created:
return None
try:
when = datetime.fromisoformat(created.replace("Z", "+00:00"))
except ValueError:
return None
if when.tzinfo is None:
when = when.replace(tzinfo=timezone.utc)
return when.timestamp()
def _status(item: _JsonDict) -> str:
"""Role ``status``: ``Absent`` for an empty slot, else the Redfish health."""
if not item.get("present", True):
return "Absent"
return item.get("health") or ""
class RedfishDriver(HealthMetricsMixin, ManagementControllerDriver):
"""NAPALM driver for Redfish BMCs (Supermicro, iDRAC, iLO, XCC and others).
``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``.
"""
platform = "redfish"
DRIVER_NAME = "redfish"
#: Every Redfish service answers ``GET /redfish/v1/`` without credentials. The port
#: itself is worth nothing: every switch with a web UI answers on 443.
PORT_SPECS = [PortSpec("https", 443, ("/redfish/v1/",), weight=0.0)]
#: Rules in one group are alternatives and count once towards the maximum
#: (napalm-device-types' ``rules_max_weight``).
HTTP_FINGERPRINT = [
# The service root, or the error body a service refuses it with (an unlicensed
# Supermicro X10 answers 403 with a Redfish error).
FingerprintRule("redfishversion", 10.0, group="redfish_answer"),
FingerprintRule("@message.extendedinfo", 8.0, group="redfish_answer"),
FingerprintRule("#serviceroot.", 5.0),
# Landing pages of the BMC web UIs: Supermicro X9-X11 (ATEN firmware), iDRAC, iLO.
FingerprintRule("aten international", 6.0, group="landing_page"),
FingerprintRule("idrac", 6.0, group="landing_page"),
FingerprintRule("integrated lights-out", 6.0, group="landing_page"),
]
#: A BMC restart takes a few minutes before Redfish answers again.
REBOOT_SETTLE_SECONDS = 180
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.transport = RedfishTransport(
hostname,
username,
password,
port=self.optional_args.get("port"),
verify=self.optional_args.get("verify_ssl", True),
auth=self.optional_args.get("auth", "auto"),
timeout=timeout,
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
def close(self) -> None:
self.transport.close()
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:
return {"is_alive": False}
return {"is_alive": True}
# --- discovery (cached per connection) -------------------------------------------------
def _cached(self, key: str, load):
if key not in self._cache:
self._cache[key] = load()
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:
path = self._root()["links"].get(collection)
if path is None:
raise RedfishError(f"the service root links no {collection}")
members = self.transport.members(path, limit=1)
if not members:
raise RedfishError(f"{path} has no members")
return members[0]
def _system_raw(self) -> _JsonDict:
return self._cached("system", lambda: self._first("Systems"))
def _system_path(self) -> str:
return odata_id(self._system_raw()) or ""
def _linked_or_first(self, key: str, links_key: str, collection: str) -> _JsonDict:
def load() -> _JsonDict:
linked = link_list(self._system_raw(), "Links", links_key)
if linked:
return self.transport.get(linked[0])
return self._first(collection)
return self._cached(key, load)
def _manager_raw(self) -> _JsonDict:
return self._linked_or_first("manager", "ManagedBy", "Managers")
def _chassis_raw(self) -> _JsonDict:
return self._linked_or_first("chassis", "Chassis", "Chassis")
def _members_of(self, resource: _JsonDict, key: str) -> list[_JsonDict]:
"""Members of the collection ``resource[key]`` links; [] if not linked."""
path = link(resource, key)
return self.transport.members(path) if path else []
def _service(self, name: str) -> _JsonDict | None:
path = self._root()["links"].get(name)
return self.transport.get_optional(path) if path else None
def _network_protocol(self) -> _JsonDict:
raw = self.transport.follow(self._manager_raw(), "NetworkProtocol")
return parse_network_protocol(raw or {})
def _manager_interfaces(self) -> list[_JsonDict]:
return [parse_ethernet_interface(raw)
for raw in self._members_of(self._manager_raw(), "EthernetInterfaces")]
# --- 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()
protocol = self._network_protocol()
hostname = (protocol["hostname"]
or next((i["hostname"] for i in interfaces if i["hostname"]), None)
or self.hostname)
fqdn = (protocol["fqdn"]
or next((i["fqdn"] for i in interfaces if i["fqdn"]), None)
or hostname)
return {
"uptime": -1.0, # Redfish has no uptime
"vendor": system["manufacturer"] or "",
"model": system["model"] or "",
"hostname": hostname,
"fqdn": fqdn,
"os_version": manager["firmware_version"] or "",
"serial_number": system["serial_number"] or "",
"interface_list": sorted(i["name"] for i in interfaces if i["name"]),
}
def get_interfaces(self) -> _JsonDict:
return {i["name"]: i["interface"] for i in self._manager_interfaces() if i["name"]}
def get_interfaces_ip(self) -> _JsonDict:
result = {}
for iface in self._manager_interfaces():
families = {f: iface[f] for f in ("ipv4", "ipv6") if iface[f]}
if iface["name"] and families:
result[iface["name"]] = families
return result
def get_environment(self) -> _JsonDict:
"""Fans, temperatures and power supplies of the chassis; ``cpu`` and ``memory`` are
empty, since a BMC does not know the host OS's load."""
sources = environment_sources(self._chassis_raw())
fans: _JsonDict = {}
temperature: _JsonDict = {}
power: _JsonDict = {}
thermal = sources["thermal"]
if thermal and thermal["schema"] == "Thermal":
legacy = parse_thermal(self.transport.get(thermal["path"]))
fans, temperature = legacy["fans"], legacy["temperature"]
elif thermal:
subsystem = parse_thermal_subsystem(self.transport.get(thermal["path"]))
parsed_fans = [parse_fan(raw) for raw in self.transport.members(
subsystem["fans_path"])] if subsystem["fans_path"] else []
if sources["sensors"]:
sensors = [parse_sensor(raw)
for raw in self.transport.members(sources["sensors"])]
temperature = build_environment(sensors=sensors)["temperature"]
elif subsystem["thermal_metrics_path"]:
metrics = self.transport.get_optional(subsystem["thermal_metrics_path"])
temperature = parse_thermal_metrics(metrics or {})["temperature"]
fans = build_environment(fans=parsed_fans)["fans"]
supply = sources["power"]
if supply and supply["schema"] == "Power":
power = parse_power(self.transport.get(supply["path"]))["power"]
elif supply:
psus = [parse_power_supply(raw, self.transport.follow(raw, "Metrics"))
for raw in self._power_supplies_raw(supply["path"])]
power = build_environment(power_supplies=psus)["power"]
return {"fans": fans, "temperature": temperature, "power": power,
"cpu": {}, "memory": {}}
def _power_supplies_raw(self, subsystem_path: str) -> list[_JsonDict]:
subsystem = parse_power_subsystem(self.transport.get(subsystem_path))
path = subsystem["power_supplies_path"]
return self.transport.members(path) if path else []
def get_users(self) -> _JsonDict:
service = self._service("AccountService")
if service is None:
return {}
return to_napalm_users(parse_account(raw) for raw in self._members_of(service,
"Accounts"))
def get_ntp_servers(self) -> _JsonDict:
return {server: {} for server in self._network_protocol()["ntp"]["servers"]}
def get_snmp_information(self) -> _JsonDict:
"""SNMP state from ManagerNetworkProtocol. Redfish hides community strings, so the
``community`` keys are the communities' names; contact and location are not in
Redfish."""
snmp = self._network_protocol()["snmp"]
communities = {}
if snmp["enabled"]:
for community in snmp["communities"]:
if community["name"]:
mode = "rw" if community["access"] == "Full" else "ro"
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]:
"""The PowerAction values the BMC accepts for ``system`` in its current state."""
reset = parse_reset_action(system)
if reset["target"] is None:
return []
allowed = reset["allowed"]
if allowed is None and reset["action_info"]:
info = self.transport.get_optional(reset["action_info"])
allowed = parse_action_info(info) if info else None
if allowed is None:
# Not advertised: assume the standard values; the BMC rejects what it lacks.
allowed = list(POWER_ACTIONS)
actions = [a for a in POWER_ACTIONS if a in allowed]
state = parse_power_state(system)
if state == "Off":
return [a for a in actions if a == "On"]
if state == "On":
return [a for a in actions if a != "On"]
return actions
def get_power_state(self) -> _JsonDict:
system = self.transport.get(self._system_path())
return {"state": parse_power_state(system) or "",
"actions": self._accepted_actions(system)}
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)}")
system = self.transport.get(self._system_path())
target = parse_reset_action(system)["target"]
if target is None:
raise RuntimeError(f"{self.hostname}: the system offers no reset action")
accepted = self._accepted_actions(system)
if action not in accepted:
raise RuntimeError(
f"{self.hostname}: {action} not accepted while the server is "
f"{parse_power_state(system) or 'in an unknown state'} "
f"(accepted: {', '.join(accepted) or 'none'})")
try:
self.transport.post(target, {"ResetType": action})
except RedfishError as exc:
raise RuntimeError(f"{self.hostname}: {action} refused: {exc}") from exc
def reboot_host(self) -> None:
"""Restart the BMC (``Manager.Reset``, graceful when offered). The server keeps
running; the Redfish session ends with the restart."""
manager = self.transport.get(odata_id(self._manager_raw()) or "")
reset = parse_reset_action(manager, "#Manager.Reset")
if reset["target"] is None:
raise RuntimeError(f"{self.hostname}: the BMC offers no reset action")
body: _JsonDict = {}
if reset["allowed"] is not None:
reset_type = next((t for t in ("GracefulRestart", "ForceRestart")
if t in reset["allowed"]), None)
if reset_type is None:
raise RuntimeError(f"{self.hostname}: BMC reset types "
f"{reset['allowed']} include no restart")
body["ResetType"] = reset_type
try:
self.transport.post(reset["target"], body)
except RedfishError as exc:
raise RuntimeError(f"{self.hostname}: BMC reset refused: {exc}") from exc
# --- role: inventory -----------------------------------------------------------------
def get_hardware_inventory(self) -> _JsonDict:
raw = self._system_raw()
system = parse_system(raw)
return {
"manufacturer": system["manufacturer"] or "",
"model": system["model"] or "",
"serial_number": system["serial_number"] or "",
"system_uuid": (system["uuid"] or "").lower(),
"cpus": self._cpus(raw),
"memory": self._dimms(raw),
"drives": self._drives(raw),
"power_supplies": self._psus(),
"nics": self._nics(raw),
}
def _cpus(self, system: _JsonDict) -> list[_JsonDict]:
cpus = []
for raw in self._members_of(system, "Processors"):
cpu = parse_processor(raw)
if cpu["processor_type"] not in (None, "CPU"):
continue # GPUs, FPGAs, accelerators
cpus.append({"socket": cpu["socket"] or cpu["id"] or "", "model": cpu["model"] or "",
"cores": cpu["cores"], "threads": cpu["threads"],
"status": _status(cpu)})
return cpus
def _dimms(self, system: _JsonDict) -> list[_JsonDict]:
dimms = []
for raw in self._members_of(system, "Memory"):
dimm = parse_memory(raw)
size = dimm["capacity_mib"]
dimms.append({
"slot": dimm["location"] or dimm["id"] or "",
"size_bytes": size * 1024 * 1024 if size else None,
"type": dimm["memory_device_type"] or "",
"speed_mts": dimm["speed_mhz"], # Redfish: MHz or MT/s "as appropriate"
"part_number": dimm["part_number"] or "",
"serial_number": dimm["serial_number"] or "",
"status": _status(dimm),
})
return dimms
def _drives(self, system: _JsonDict) -> list[_JsonDict]:
parsed: list[_JsonDict] = []
if link(system, "Storage"):
seen: set[str] = set()
for storage in self._members_of(system, "Storage"):
for path in parse_storage(storage)["drive_paths"]:
if path not in seen:
seen.add(path)
drive = self.transport.get_optional(path)
if drive is not None:
parsed.append(parse_drive(drive))
else:
for storage in self._members_of(system, "SimpleStorage"):
parsed.extend(parse_simple_storage(storage)["drives"])
return [{
"slot": d["name"] or d["id"] or "",
"model": d["model"] or "",
"serial_number": d["serial_number"] or "",
"size_bytes": d["capacity_bytes"],
"media": d["media_type"] or "",
"protocol": d["protocol"] or "",
"status": _status(d),
} for d in parsed]
def _psus(self) -> list[_JsonDict]:
source = environment_sources(self._chassis_raw())["power"]
if source is None:
return []
if source["schema"] == "Power":
items = self.transport.get(source["path"]).get("PowerSupplies") or []
else:
items = self._power_supplies_raw(source["path"])
psus = []
for raw in items:
if not isinstance(raw, dict):
continue
psu = parse_power_supply(raw)
psus.append({
"slot": psu["name"],
"model": psu["model"] or "",
"serial_number": psu["serial_number"] or "",
"capacity_watts": int(psu["capacity"]) if psu["capacity"] >= 0 else None,
"status": _status(psu),
})
return psus
def _nics(self, system: _JsonDict) -> list[_JsonDict]:
nics = []
for raw in self._members_of(system, "EthernetInterfaces"):
nic = parse_ethernet_interface(raw)
speed = nic["interface"]["speed"]
link_up = {"LinkUp": True, "LinkDown": False, "NoLink": False}.get(
nic["link_status"] or "")
nics.append({
"name": nic["name"] or "",
"mac_address": nic["interface"]["mac_address"].lower(),
"speed_mbps": int(speed) if speed >= 0 else None,
"link_up": link_up,
})
return nics
# --- role: event log, firmware, protocols --------------------------------------------
def get_event_log(self, limit: int = 500) -> list[_JsonDict]:
"""Entries of every log service of the system and of the BMC, newest first.
Where the SEL lives varies by vendor, so all of them are read; entries without a
time sort last.
"""
entries: list[_JsonDict] = []
for owner in (self._system_raw(), self._manager_raw()):
try:
services = self._members_of(owner, "LogServices")
except RedfishNotFound:
continue
for raw_service in services:
service = parse_log_service(raw_service)
if not service["entries_path"]:
continue
try:
raw_entries = self.transport.members(service["entries_path"])
except RedfishNotFound:
LOG.warning("%s: log %s has no entries collection", self.hostname,
service["id"])
continue
for raw in raw_entries:
entry = parse_log_entry(raw)
severity = entry["severity"]
entries.append({
"id": odata_id(raw) or f"{service['id']}/{entry['id']}",
"timestamp": _timestamp(entry["created"]),
"severity": severity if severity in _SEVERITIES else "OK",
"message": entry["message"] or "",
})
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]:
service = self._service("UpdateService")
if service is None:
return []
items = [parse_firmware(raw) for raw in self._members_of(service, "FirmwareInventory")]
return [{"name": fw["name"] or fw["id"] or "", "version": fw["version"] or "",
"updateable": fw["updateable"]} for fw in items]
def get_management_protocols(self) -> _JsonDict:
"""What the BMC listens on. A protocol the BMC does not report counts as off."""
protocols = self._network_protocol()["protocols"]
result = {}
for key, name in _PROTOCOLS.items():
entry = protocols.get(name) or {}
result[key] = {"enabled": bool(entry.get("enabled")), "port": entry.get("port")}
return result