"""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, LicenseMixin, 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, RedfishFeatureLicenseError, 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") #: The parts a BMC may withhold for want of a license, in the order they are named. _WITHHELD_ORDER = ("drives", "firmware inventory") #: Models a system reports when it has none of its own. Supermicro's Redfish calls every #: system "Super Server"; the board is the system's PartNumber and the chassis model. _PLACEHOLDER_MODELS = frozenset({"super server", "default string", "to be filled by o.e.m."}) #: What discovery recognises a BMC by; the ``bmc`` driver carries them (0.4.0), so a BMC #: found is suggested with the driver that works with and without a license. #: Every Redfish service answers ``GET /redfish/v1/`` without credentials. The port #: itself is worth nothing: every switch with a web UI answers on 443. BMC_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``). BMC_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"), ] 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, LicenseMixin, 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" # No fingerprint of its own: discovery suggests ``bmc`` (BMC_PORT_SPECS and # BMC_HTTP_FINGERPRINT below), which also reads a BMC that refuses Redfish. #: 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 #: Parts the BMC withholds for want of a license, with the licenses it names. self._withheld: dict[str, str] = {} # --- 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 self._withheld = {} 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 _server_model(self) -> str: """The system's model; for a placeholder, the board (PartNumber or chassis model).""" raw = self._system_raw() model = (parse_system(raw)["model"] or "").strip() if model.lower() not in _PLACEHOLDER_MODELS: return model board = raw.get("PartNumber") or self._chassis_raw().get("Model") or "" return str(board).strip() or model 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": self._server_model(), "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. ``bmc_license_required`` when the BMC refuses Redfish altogether; ``bmc_license_partial`` when it answers but withholds parts (drives, the firmware inventory) for want of a license. The parts are probed here, so the warning does not depend on which getters ran before. """ refused = self._license_refused if refused is not None: return [{"code": "bmc_license_required", "meta": {"licenses": refused.licenses, "message": refused.detail}}] self._probe_withheld() if not self._withheld: return [] licenses = sorted({name for names in self._withheld.values() for name in names.split()}) parts = [part for part in _WITHHELD_ORDER if part in self._withheld] return [{"code": "bmc_license_partial", "meta": {"licenses": " ".join(licenses), "parts": parts}}] def get_licenses(self) -> list[_JsonDict]: """The licenses the BMC asks for and does not have (``status`` "missing"). Refused altogether: one entry per license the service names, with its message. Answering but withholding parts: one entry per license, with ``withholds`` naming the parts. A BMC that names nothing missing reports nothing: Redfish has no standard way to list the licenses a BMC does have. """ refused = self._license_refused if refused is not None: return [{"name": name, "status": "missing", "detail": refused.detail} for name in refused.licenses.split() or [""]] self._probe_withheld() withheld: dict[str, list[str]] = {} for part in _WITHHELD_ORDER: if part not in self._withheld: continue for name in self._withheld[part].split() or [""]: withheld.setdefault(name, []).append(part) return [{"name": name, "status": "missing", "withholds": parts} for name, parts in withheld.items()] def _withhold(self, part: str, exc: RedfishFeatureLicenseError) -> None: LOG.info("%s: %s withheld by the BMC: %s", self.hostname, part, exc) self._withheld[part] = exc.licenses def _probe_withheld(self) -> None: """Ask for the license-gated resources once, so ``_withheld`` is complete.""" storage = link(self._system_raw(), "Storage") if storage and "drives" not in self._withheld: try: self.transport.get(storage) except RedfishFeatureLicenseError as exc: self._withhold("drives", exc) except RedfishError: pass if "firmware inventory" not in self._withheld: self._update_service() # --- 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": self._server_model(), "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 _storage_drives(self, system: _JsonDict) -> list[_JsonDict] | None: """Drives from ``Storage``; ``None`` when it is not linked or withheld.""" if not link(system, "Storage"): return None try: storages = self._members_of(system, "Storage") except RedfishFeatureLicenseError as exc: self._withhold("drives", exc) return None parsed: list[_JsonDict] = [] seen: set[str] = set() for storage in storages: 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)) return parsed def _drives(self, system: _JsonDict) -> list[_JsonDict]: parsed = self._storage_drives(system) if parsed is None: # Older services, and a Supermicro X10 that withholds Storage without DCMS. parsed = [] 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 _update_service(self) -> _JsonDict | None: try: return self._service("UpdateService") except RedfishFeatureLicenseError as exc: self._withhold("firmware inventory", exc) return None def get_firmware_inventory(self) -> list[_JsonDict]: """``UpdateService/FirmwareInventory``. Where the BMC withholds it for want of a license, the BMC's own firmware and the BIOS, which the manager and the system state themselves.""" service = self._update_service() if service is None: if "firmware inventory" not in self._withheld: return [] bmc = parse_manager(self._manager_raw())["firmware_version"] bios = self._system_raw().get("BiosVersion") return [{"name": name, "version": version, "updateable": None} for name, version in (("BMC", bmc), ("BIOS", bios)) if version] 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