Mechanical: ruff format, and ruff check --fix with the rule set every NAPALM driver uses (E, F, I, UP, B at line length 100, Python 3.10). Mapping and Iterable come from collections.abc, imports are sorted. What ruff cannot fix comes in the next commit.
432 lines
17 KiB
Python
432 lines
17 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 collections.abc import Mapping
|
|
from typing import Any
|
|
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 RedfishFeatureLicenseError(RedfishAuthError):
|
|
"""One resource is refused until a vendor license is activated, the rest answers.
|
|
|
|
A Supermicro X10 with only the OOB license reads ``Systems`` and ``Managers`` but
|
|
refuses ``Systems/1/Storage`` and ``UpdateService`` for want of DCMS.
|
|
"""
|
|
|
|
def __init__(
|
|
self,
|
|
message: str,
|
|
*,
|
|
licenses: str = "",
|
|
status: int | None = None,
|
|
path: str | None = None,
|
|
):
|
|
super().__init__(message, status=status, path=path)
|
|
#: The licenses the service names, e.g. "DCMS"; empty when it names none.
|
|
self.licenses = licenses
|
|
|
|
|
|
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 == 403 and any(i.endswith(LICENSE_REFUSED) for i in _message_ids(data)):
|
|
raise RedfishFeatureLicenseError(
|
|
message,
|
|
licenses=" ".join(" ".join(_message_args(data)).split()),
|
|
status=status,
|
|
path=path,
|
|
)
|
|
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."""
|