First run against real hardware over Redfish: an X10DRL-i (BMC 3.91) with
SFT-OOB-LIC. Its Redfish tree is recorded under
tests/fixtures/supermicro-x10drl-i-3.91/redfish, scrubbed.
- A resource refused for want of a further license (403 OemLicenseNotPassed)
no longer fails the getter. Storage then falls back to SimpleStorage. The
firmware list falls back to the BMC and BIOS versions when UpdateService
is withheld.
- get_device_warnings reports bmc_license_partial with the licenses and the
withheld parts (drives, firmware inventory).
- A placeholder system model ("Super Server") is replaced by the board
(PartNumber, else the chassis model).
418 lines
17 KiB
Python
418 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 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 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."""
|