Files
napalm-redfish/napalm_redfish/transport.py
T
christianmanivong 332e6090a7 feat: read-only IPMI driver; report a missing Redfish license (0.2.0)
A Supermicro X10DRL-i refuses Redfish without a license (SUM, DCMS or
OOB), even the service root. IPMI answers without one.

- New entry point `ipmi` (napalm_redfish.ipmi.IpmiDriver) on
  ManagementControllerDriver, over pyghmi: facts and inventory from the
  FRU (serial number, system UUID), the BMC's LAN interface, sensors
  (temperatures, fans, power supplies), the power state, the event log
  and the BMC firmware. Read-only: no power control, no BMC reset.
  Tested against recordings of the X10 with serials, the UUID, MACs and
  IPs replaced.
- RedfishDriver.open() no longer fails on a license refusal: get_facts
  returns what is known without Redfish, get_device_warnings reports
  bmc_license_required with the licenses the BMC names, every other
  getter raises. netOrk then shows the reason instead of an offline BMC.
- IpmiDriver is not exported from the package: NAPALM's own
  get_network_driver("redfish") takes the first driver class of
  napalm_redfish.
2026-10-13 10:53:51 +02:00

400 lines
16 KiB
Python

"""HTTPS transport to a Redfish service, on top of the DMTF ``redfish`` library.
The library does the HTTP work; this module adds what a BMC driver needs on top:
- Session login through the SessionService (``X-Auth-Token``), with the session deleted on
close, because BMCs allow only a few concurrent sessions. Basic auth is the fallback for
services that cannot create sessions, never a second try after a rejected password.
- Certificate verification that is on unless turned off per device. The library disables
verification whenever it is not given a CA file, so ``verify=True`` passes the CA bundle
of ``requests`` explicitly.
- Collections followed by ``@odata.id`` (including ``nextLink`` pages and expanded members)
instead of assumed member IDs.
- Links followed only on the BMC itself: an ``@odata.id`` pointing at another host (a PDU
outlet, for example) is refused, so the session token never leaves the BMC.
"""
from __future__ import annotations
import ipaddress
import logging
import os
import re
from typing import Any, Mapping
from urllib.parse import urlsplit
import requests
from redfish.rest.v1 import (
AuthMethod,
InvalidCredentialsError,
RedfishPasswordChangeRequiredError,
RetriesExhaustedError,
ServerDownOrUnreachableError,
SessionCreationError,
redfish_client,
)
LOG = logging.getLogger(__name__)
SERVICE_ROOT = "/redfish/v1/"
DEFAULT_SESSIONS = "/redfish/v1/SessionService/Sessions"
AUTH_METHODS = ("auto", "session", "basic")
MAX_PAGES = 1000
#: The MessageId with which a service refuses every request until a vendor license is
#: activated (Supermicro X10 without one of SUM, DCMS or OOB).
LICENSE_REFUSED = "OemLicenseNotPassed"
#: How the library reports a service root that answered with an HTTP error.
_ROOT_STATUS = re.compile(r"return code:\s*(\d{3})")
Reference = str | Mapping[str, Any]
class RedfishError(Exception):
"""A Redfish request failed."""
def __init__(self, message: str, *, status: int | None = None, path: str | None = None):
super().__init__(message)
self.status = status
self.path = path
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)."""
class RedfishHTTPError(RedfishError):
"""The service answered with an HTTP error status."""
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
if not isinstance(error, Mapping):
return []
return [
str(info["MessageId"])
for info in error.get("@Message.ExtendedInfo") or []
if isinstance(info, Mapping) and info.get("MessageId")
]
def _error_message(body: Any) -> str:
"""The human-readable part of a Redfish error body, if any."""
if not isinstance(body, Mapping):
return ""
error = body.get("error")
if not isinstance(error, Mapping):
return ""
messages = [
info.get("Message")
for info in error.get("@Message.ExtendedInfo") or []
if isinstance(info, Mapping) and info.get("Message")
]
if not messages and error.get("message"):
messages = [error["message"]]
return "; ".join(str(m) for m in messages)
class RedfishTransport:
"""One authenticated connection to a Redfish service.
:param hostname: BMC host name or IP address (IPv6 without brackets is fine).
:param port: HTTPS port, default 443.
:param verify: ``True`` verifies the certificate against the CA bundle of ``requests``,
a path verifies against that CA file, ``False`` turns verification off.
:param auth: ``"auto"`` (session, basic auth if the service cannot create sessions),
``"session"`` or ``"basic"``.
:param timeout: seconds per request.
:param retries: extra attempts after a connection error (the library waits 1 s between).
"""
def __init__(
self,
hostname: str,
username: str,
password: str,
*,
port: int | None = None,
verify: bool | str = True,
auth: str = "auto",
timeout: float = 30,
retries: int = 1,
):
if auth not in AUTH_METHODS:
raise ValueError(f"auth must be one of {', '.join(AUTH_METHODS)}, not {auth!r}")
if isinstance(verify, str) and not os.path.isfile(verify):
raise ValueError(f"CA file not found: {verify}")
self.hostname = hostname
self.username = username
self.password = password
self.port = port
self.verify = verify
self.auth = auth
self.timeout = timeout
self.retries = retries
self.root: dict[str, Any] = {}
self.auth_method: str | None = None
self._client = None
@property
def base_url(self) -> str:
host = self.hostname
try:
if ipaddress.ip_address(host).version == 6:
host = f"[{host}]"
except ValueError:
pass
return f"https://{host}" + (f":{self.port}" if self.port else "")
@property
def _cafile(self) -> str | None:
if self.verify is True:
return requests.certs.where()
if self.verify is False:
return None
return self.verify
# --- connection --------------------------------------------------------------------
def open(self) -> None:
"""Read the service root (no credentials needed) and log in. No-op when open."""
if self._client is not None:
return
try:
client = redfish_client(
base_url=self.base_url,
username=self.username,
password=self.password,
default_prefix=SERVICE_ROOT,
cafile=self._cafile,
timeout=self.timeout,
max_retry=self.retries,
)
except (RetriesExhaustedError, ServerDownOrUnreachableError) as exc:
raise self._root_failure(f"service root not reachable: {exc}", exc) from exc
except Exception as exc:
raise self._root_failure(f"cannot read the service root: {exc}", exc) from exc
self._client = client
self.root = dict(getattr(client, "root", None) or {})
try:
self.auth_method = self._login(client)
except BaseException:
self._client = None
client._session.close()
raise
def _root_failure(self, reason: str, exc: BaseException) -> RedfishError:
"""The error for an unreadable service root, with the service's own reason if it gave one.
The library keeps only the status code. When the service did answer, its error body is
read once more: an unlicensed Supermicro X10 refuses even the service root, and only
the body says that a license is what is missing.
"""
body = self._root_error_body() if _ROOT_STATUS.search(str(exc)) else None
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}",
licenses=" ".join(" ".join(_message_args(body)).split()),
detail=detail,
)
return RedfishError(f"{self.base_url}: {reason}" + (f": {detail}" if detail else ""))
def _root_error_body(self) -> Any:
"""The service root's error body, or None when there is none to read."""
try:
resp = requests.get(
self.base_url + SERVICE_ROOT,
verify=self._cafile or False,
timeout=self.timeout,
headers={"Accept": "application/json"},
)
return resp.json() if resp.status_code >= 400 else None
except Exception: # noqa: BLE001 - the original error is what gets reported
return None
def _login(self, client) -> str:
if self.auth == "basic":
self._call(client.login, auth=AuthMethod.BASIC)
return "basic"
try:
self._call(client.login, auth=AuthMethod.SESSION)
except _SessionUnsupported as exc:
if self.auth == "session":
raise RedfishAuthError(f"{self.base_url}: session login failed: {exc}") from exc
LOG.info("%s: no Redfish session (%s), falling back to basic auth", self.base_url, exc)
else:
if client.get_session_key():
return "session"
if self.auth == "session":
raise RedfishAuthError(f"{self.base_url}: session login returned no X-Auth-Token")
LOG.info("%s: session login returned no token, falling back to basic auth",
self.base_url)
self._call(client.login, auth=AuthMethod.BASIC)
return "basic"
def _call(self, login, **kwargs) -> None:
try:
login(**kwargs)
except InvalidCredentialsError as exc:
raise RedfishAuthError(f"{self.base_url}: invalid credentials", status=401) from exc
except RedfishPasswordChangeRequiredError as exc:
raise RedfishAuthError(f"{self.base_url}: the account must change its password "
"before it can be used") from exc
except SessionCreationError as exc:
raise _SessionUnsupported(str(exc).strip()) from exc
except (RetriesExhaustedError, ServerDownOrUnreachableError) as exc:
raise RedfishError(f"{self.base_url}: login failed: {exc}") from exc
def close(self) -> None:
"""Delete the session (if any) and close the HTTP connection. Never raises."""
client, self._client = self._client, None
self.auth_method = None
if client is None:
return
try:
client.logout()
except Exception as exc: # a leaked session times out; a failing close must not mask
LOG.warning("%s: could not delete the Redfish session: %s", self.base_url, exc)
client._session.close()
def __enter__(self) -> "RedfishTransport":
self.open()
return self
def __exit__(self, *exc_info) -> None:
self.close()
# --- requests ----------------------------------------------------------------------
def path(self, ref: Reference) -> str:
"""Request path for a path, an absolute URL on this BMC or an ``{"@odata.id": ...}``."""
if isinstance(ref, Mapping):
ref = ref.get("@odata.id")
if not isinstance(ref, str) or not ref:
raise RedfishError(f"not a Redfish reference: {ref!r}")
parts = urlsplit(ref)
if parts.scheme or parts.netloc:
ours = urlsplit(self.base_url)
if parts.scheme != "https" or parts.netloc.lower() != ours.netloc.lower():
raise RedfishError(f"refusing to follow a link to another host: {ref}")
if not parts.path.startswith("/"):
raise RedfishError(f"not an absolute Redfish path: {ref}")
return parts.path + (f"?{parts.query}" if parts.query else "")
def _request(self, method: str, ref: Reference, body: Any = None) -> dict[str, Any]:
if self._client is None:
raise RedfishError("not connected: call open() first")
path = self.path(ref)
try:
if method == "GET":
resp = self._client.get(path)
else:
resp = self._client.post(path, body=body)
except RetriesExhaustedError as exc:
raise RedfishError(f"{method} {path}: no response", path=path) from exc
status = resp.status
try:
data = resp.dict
except Exception as exc:
if status < 400:
raise RedfishError(f"{method} {path}: response is not JSON",
status=status, path=path) from exc
data = {}
if status < 400:
if not isinstance(data, dict):
raise RedfishError(f"{method} {path}: response is not a JSON object",
status=status, path=path)
return data
detail = _error_message(data)
message = f"{method} {path}: HTTP {status}" + (f": {detail}" if detail else "")
if status in (401, 403):
raise RedfishAuthError(message, status=status, path=path)
if status == 404:
raise RedfishNotFound(message, status=status, path=path)
raise RedfishHTTPError(message, status=status, path=path)
def get(self, ref: Reference) -> dict[str, Any]:
return self._request("GET", ref)
def get_optional(self, ref: Reference | None) -> dict[str, Any] | None:
"""GET, but ``None`` for a missing reference or a 404."""
if ref is None:
return None
try:
return self.get(ref)
except RedfishNotFound:
return None
def post(self, ref: Reference, body: Mapping[str, Any]) -> dict[str, Any]:
return self._request("POST", ref, dict(body))
def follow(self, resource: Mapping[str, Any], key: str) -> dict[str, Any] | None:
"""The resource linked as ``resource[key]``; ``None`` if not linked or not found."""
link = resource.get(key)
if not isinstance(link, Mapping) or "@odata.id" not in link:
return None
return self.get_optional(link)
def members(self, collection: Reference, *, limit: int | None = None) -> list[dict[str, Any]]:
"""All members of a collection, each fetched by its ``@odata.id``.
Members the service already expanded inline are used as they are. ``nextLink`` pages
are followed. ``limit`` stops after that many members.
"""
result: list[dict[str, Any]] = []
page_ref: Reference | None = collection
seen: set[str] = set()
while page_ref is not None and len(seen) < MAX_PAGES:
page_path = self.path(page_ref)
if page_path in seen:
LOG.warning("%s: nextLink loop at %s", self.base_url, page_path)
break
seen.add(page_path)
page = self.get(page_path)
for member in page.get("Members") or []:
if limit is not None and len(result) >= limit:
return result
if not isinstance(member, Mapping):
continue
if "Id" in member or "@odata.type" in member:
result.append(dict(member))
elif "@odata.id" in member:
result.append(self.get(member))
if limit is not None and len(result) >= limit:
break
page_ref = page.get("Members@odata.nextLink") or page.get("@odata.nextLink")
return result
class _SessionUnsupported(Exception):
"""Session creation failed for a reason other than bad credentials."""