Add Redfish transport
Session login through the SessionService with X-Auth-Token, the session deleted on close (also on errors and when the DELETE fails), basic auth as fallback only when the service cannot create sessions, never after a rejected password. Certificate verification is on by default and configurable per device; True passes the requests CA bundle explicitly because the redfish library disables verification without a CA file. Collections are followed by @odata.id, including expanded members and nextLink pages. Links to other hosts are refused so the session token stays on the BMC.
This commit is contained in:
2 files changed
+755
No files matched your search
@@ -0,0 +1,327 @@
|
||||
"""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
|
||||
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
|
||||
|
||||
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 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 _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 RedfishError(f"{self.base_url}: service root not reachable: {exc}") from exc
|
||||
except Exception as exc:
|
||||
raise RedfishError(f"{self.base_url}: cannot read the service root: {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 _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."""
|
||||
@@ -0,0 +1,428 @@
|
||||
"""Transport tests against mocked HTTP serving the DMTF public-rackmount1 mockup."""
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
import os
|
||||
|
||||
import pytest
|
||||
import requests
|
||||
from conftest import BASE_URL, SESSION_PATH, SESSIONS, TOKEN
|
||||
|
||||
from napalm_redfish.transport import (
|
||||
RedfishAuthError,
|
||||
RedfishError,
|
||||
RedfishHTTPError,
|
||||
RedfishNotFound,
|
||||
RedfishTransport,
|
||||
)
|
||||
|
||||
BASIC = "Basic " + base64.b64encode(b"netork:s3cret").decode()
|
||||
|
||||
|
||||
def make(**kwargs) -> RedfishTransport:
|
||||
kwargs.setdefault("verify", False)
|
||||
kwargs.setdefault("retries", 0)
|
||||
return RedfishTransport("bmc.example.net", "netork", "s3cret", **kwargs)
|
||||
|
||||
|
||||
# --- base URL ------------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"hostname, port, expected",
|
||||
[
|
||||
("bmc.example.net", None, "https://bmc.example.net"),
|
||||
("bmc.example.net", 8443, "https://bmc.example.net:8443"),
|
||||
("192.0.2.10", None, "https://192.0.2.10"),
|
||||
("2001:db8::10", None, "https://[2001:db8::10]"),
|
||||
("2001:db8::10", 8443, "https://[2001:db8::10]:8443"),
|
||||
],
|
||||
)
|
||||
def test_base_url(hostname, port, expected):
|
||||
assert RedfishTransport(hostname, "u", "p", port=port).base_url == expected
|
||||
|
||||
|
||||
def test_rejects_unknown_auth_method():
|
||||
with pytest.raises(ValueError):
|
||||
RedfishTransport("bmc.example.net", "u", "p", auth="digest")
|
||||
|
||||
|
||||
# --- session login -------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_open_reads_service_root_before_login(server):
|
||||
t = make()
|
||||
t.open()
|
||||
first = server.rsps.calls[0].request
|
||||
assert first.method == "GET" and first.url.rstrip("/") == BASE_URL + "/redfish/v1"
|
||||
assert "X-Auth-Token" not in first.headers and "Authorization" not in first.headers
|
||||
assert t.root["RedfishVersion"] == "1.15.0"
|
||||
t.close()
|
||||
|
||||
|
||||
def test_session_login_posts_credentials_and_uses_token(server):
|
||||
t = make()
|
||||
t.open()
|
||||
assert t.auth_method == "session"
|
||||
(login,) = server.calls("POST", SESSIONS)
|
||||
assert json.loads(login.request.body) == {"UserName": "netork", "Password": "s3cret"}
|
||||
|
||||
t.get("/redfish/v1/Systems")
|
||||
get = server.calls("GET", "/redfish/v1/Systems")[0].request
|
||||
assert get.headers["X-Auth-Token"] == TOKEN
|
||||
assert "Authorization" not in get.headers
|
||||
t.close()
|
||||
|
||||
|
||||
def test_close_deletes_the_session(server):
|
||||
t = make()
|
||||
t.open()
|
||||
t.close()
|
||||
(delete,) = server.calls("DELETE", SESSION_PATH)
|
||||
assert delete.request.headers["X-Auth-Token"] == TOKEN
|
||||
assert t.auth_method is None
|
||||
|
||||
|
||||
def test_open_twice_logs_in_once(server):
|
||||
t = make()
|
||||
t.open()
|
||||
t.open()
|
||||
t.close()
|
||||
assert len(server.calls("POST", SESSIONS)) == 1
|
||||
assert len(server.calls("DELETE", SESSION_PATH)) == 1
|
||||
|
||||
|
||||
def test_close_twice_deletes_once(server):
|
||||
t = make()
|
||||
t.open()
|
||||
t.close()
|
||||
t.close()
|
||||
assert len(server.calls("DELETE")) == 1
|
||||
|
||||
|
||||
def test_context_manager_deletes_session_on_error(server):
|
||||
with pytest.raises(RuntimeError):
|
||||
with make() as t:
|
||||
t.get("/redfish/v1/Systems")
|
||||
raise RuntimeError("boom")
|
||||
assert len(server.calls("DELETE", SESSION_PATH)) == 1
|
||||
|
||||
|
||||
def test_close_survives_failed_logout(server, caplog):
|
||||
server.override("DELETE", SESSION_PATH, 500, {"error": {"message": "nope"}})
|
||||
t = make()
|
||||
t.open()
|
||||
with caplog.at_level(logging.WARNING):
|
||||
t.close()
|
||||
assert t.auth_method is None
|
||||
assert "session" in caplog.text.lower()
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:Service incorrectly did not provide the 'Location'")
|
||||
def test_session_uri_from_body_when_location_missing(server):
|
||||
server.override("POST", SESSIONS, 201, {"@odata.id": SESSION_PATH}, {"X-Auth-Token": TOKEN})
|
||||
t = make()
|
||||
t.open()
|
||||
t.close()
|
||||
assert len(server.calls("DELETE", SESSION_PATH)) == 1
|
||||
|
||||
|
||||
def test_wrong_password_raises_without_basic_retry(server):
|
||||
server.override("POST", SESSIONS, 401, {"error": {"message": "bad credentials"}})
|
||||
t = make()
|
||||
with pytest.raises(RedfishAuthError):
|
||||
t.open()
|
||||
# One failed login only: a second attempt with basic auth would count twice against
|
||||
# the BMC's lockout threshold.
|
||||
assert len(server.calls("POST")) == 1
|
||||
assert not [c for c in server.rsps.calls if "Authorization" in c.request.headers]
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [404, 405, 501])
|
||||
def test_falls_back_to_basic_when_sessions_unsupported(server, status):
|
||||
server.override("POST", SESSIONS, status, {"error": {"message": "not supported"}})
|
||||
t = make()
|
||||
t.open()
|
||||
assert t.auth_method == "basic"
|
||||
t.get("/redfish/v1/Systems")
|
||||
get = server.calls("GET", "/redfish/v1/Systems")[0].request
|
||||
assert get.headers["Authorization"] == BASIC
|
||||
assert "X-Auth-Token" not in get.headers
|
||||
t.close()
|
||||
assert not server.calls("DELETE")
|
||||
|
||||
|
||||
@pytest.mark.filterwarnings("ignore:Service incorrectly did not provide the 'Location'")
|
||||
def test_falls_back_to_basic_when_no_token_returned(server):
|
||||
server.override("POST", SESSIONS, 201, {"@odata.id": SESSION_PATH})
|
||||
t = make()
|
||||
t.open()
|
||||
assert t.auth_method == "basic"
|
||||
t.close()
|
||||
|
||||
|
||||
def test_no_fallback_when_session_auth_is_forced(server):
|
||||
server.override("POST", SESSIONS, 405, {})
|
||||
t = make(auth="session")
|
||||
with pytest.raises(RedfishAuthError):
|
||||
t.open()
|
||||
|
||||
|
||||
def test_basic_auth_never_creates_a_session(server):
|
||||
t = make(auth="basic")
|
||||
t.open()
|
||||
assert t.auth_method == "basic"
|
||||
t.get("/redfish/v1/Systems")
|
||||
assert not server.calls("POST")
|
||||
assert server.calls("GET", "/redfish/v1/Systems")[0].request.headers["Authorization"] == BASIC
|
||||
t.close()
|
||||
|
||||
|
||||
def test_basic_auth_wrong_password(server):
|
||||
server.override("GET", SESSIONS, 401, {})
|
||||
with pytest.raises(RedfishAuthError):
|
||||
make(auth="basic").open()
|
||||
|
||||
|
||||
def test_login_url_from_service_root(server):
|
||||
custom = dict(_root(), Links={"Sessions": {"@odata.id": "/redfish/v1/Custom/Sessions"}})
|
||||
server.override("GET", "/redfish/v1/", 200, custom)
|
||||
server.override("GET", "/redfish/v1", 200, custom)
|
||||
server.override("POST", "/redfish/v1/Custom/Sessions", 201, {},
|
||||
{"X-Auth-Token": TOKEN, "Location": SESSION_PATH})
|
||||
t = make()
|
||||
t.open()
|
||||
assert server.calls("POST", "/redfish/v1/Custom/Sessions")
|
||||
t.close()
|
||||
|
||||
|
||||
def _root():
|
||||
from conftest import load
|
||||
|
||||
return load("dmtf-public-rackmount1", "/redfish/v1")
|
||||
|
||||
|
||||
def test_unreachable_service_raises_redfish_error(http):
|
||||
t = make()
|
||||
with pytest.raises(RedfishError):
|
||||
t.open() # nothing registered on the mock: connection refused
|
||||
|
||||
|
||||
def test_requests_before_open_fail(server):
|
||||
with pytest.raises(RedfishError):
|
||||
make().get("/redfish/v1/Systems")
|
||||
|
||||
|
||||
# --- certificate verification --------------------------------------------------------------
|
||||
|
||||
|
||||
def _verify_of(server):
|
||||
return server.rsps.calls[0].request.req_kwargs["verify"]
|
||||
|
||||
|
||||
def test_verify_true_uses_ca_bundle(server):
|
||||
t = make(verify=True)
|
||||
t.open()
|
||||
verify = _verify_of(server)
|
||||
# The redfish library turns verification off unless it gets a CA file, so True must
|
||||
# become a real bundle, not None/False.
|
||||
assert isinstance(verify, str) and os.path.isfile(verify)
|
||||
assert verify == requests.certs.where()
|
||||
t.close()
|
||||
|
||||
|
||||
def test_verify_false_disables_verification(server):
|
||||
t = make(verify=False)
|
||||
t.open()
|
||||
assert _verify_of(server) is False
|
||||
t.close()
|
||||
|
||||
|
||||
def test_verify_path_is_passed_through(server, tmp_path):
|
||||
ca = tmp_path / "bmc-ca.pem"
|
||||
ca.write_text("dummy")
|
||||
t = make(verify=str(ca))
|
||||
t.open()
|
||||
assert _verify_of(server) == str(ca)
|
||||
t.close()
|
||||
|
||||
|
||||
def test_verify_path_must_exist():
|
||||
with pytest.raises(ValueError):
|
||||
RedfishTransport("bmc.example.net", "u", "p", verify="/nonexistent/ca.pem")
|
||||
|
||||
|
||||
def test_verify_is_the_default():
|
||||
assert RedfishTransport("bmc.example.net", "u", "p").verify is True
|
||||
|
||||
|
||||
# --- GET and errors ------------------------------------------------------------------------
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def transport(server):
|
||||
t = make()
|
||||
t.open()
|
||||
yield t
|
||||
t.close()
|
||||
|
||||
|
||||
def test_get_returns_json(transport):
|
||||
assert transport.get("/redfish/v1/Systems/437XR1138R2")["Model"] == "3500"
|
||||
|
||||
|
||||
def test_get_accepts_odata_reference(transport):
|
||||
ref = {"@odata.id": "/redfish/v1/Systems/437XR1138R2"}
|
||||
assert transport.get(ref)["Id"] == "437XR1138R2"
|
||||
|
||||
|
||||
def test_get_strips_fragment(transport, server):
|
||||
data = transport.get("/redfish/v1/Chassis/1U/Thermal#/Fans/0")
|
||||
assert data["Id"] == "Thermal"
|
||||
assert server.calls("GET", "/redfish/v1/Chassis/1U/Thermal")[0].request.url.endswith("/Thermal")
|
||||
|
||||
|
||||
def test_get_accepts_same_host_absolute_url(transport):
|
||||
assert transport.get(BASE_URL + "/redfish/v1/Systems")["Members@odata.count"] == 1
|
||||
|
||||
|
||||
def test_get_refuses_links_to_other_hosts(transport, server):
|
||||
before = len(server.rsps.calls)
|
||||
# PowerSupply.Links.Outlet in the mockup points at a PDU on another host. Following it
|
||||
# would send this BMC's session token there.
|
||||
with pytest.raises(RedfishError):
|
||||
transport.get("https://redfishpdu.contoso.com/redfish/v1/PowerEquipment/RackPDUs/1")
|
||||
assert len(server.rsps.calls) == before
|
||||
|
||||
|
||||
def test_get_refuses_relative_paths(transport):
|
||||
with pytest.raises(RedfishError):
|
||||
transport.get("Systems/1")
|
||||
|
||||
|
||||
def test_get_404_raises_not_found(transport):
|
||||
with pytest.raises(RedfishNotFound) as exc:
|
||||
transport.get("/redfish/v1/Systems/1")
|
||||
assert exc.value.status == 404
|
||||
assert exc.value.path == "/redfish/v1/Systems/1"
|
||||
|
||||
|
||||
def test_get_optional_returns_none_on_404(transport):
|
||||
assert transport.get_optional("/redfish/v1/Chassis/1U/Missing") is None
|
||||
assert transport.get_optional(None) is None
|
||||
|
||||
|
||||
def test_get_error_carries_redfish_message(transport, server):
|
||||
server.override("GET", "/redfish/v1/Managers", 500, {
|
||||
"error": {"code": "Base.1.0.InternalError", "message": "General error",
|
||||
"@Message.ExtendedInfo": [{"Message": "The BMC is busy."}]}})
|
||||
with pytest.raises(RedfishHTTPError) as exc:
|
||||
transport.get("/redfish/v1/Managers")
|
||||
assert exc.value.status == 500
|
||||
assert "The BMC is busy." in str(exc.value)
|
||||
|
||||
|
||||
@pytest.mark.parametrize("status", [401, 403])
|
||||
def test_get_auth_errors(transport, server, status):
|
||||
server.override("GET", "/redfish/v1/AccountService", status, {})
|
||||
with pytest.raises(RedfishAuthError):
|
||||
transport.get("/redfish/v1/AccountService")
|
||||
|
||||
|
||||
def test_get_invalid_json(transport, server):
|
||||
server.override("GET", "/redfish/v1/Managers", 200, "<html>login</html>")
|
||||
with pytest.raises(RedfishError):
|
||||
transport.get("/redfish/v1/Managers")
|
||||
|
||||
|
||||
# --- collections ---------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_members_follows_odata_ids(transport, server):
|
||||
systems = transport.members("/redfish/v1/Systems")
|
||||
# The mockup's system ID is its serial number, not "1".
|
||||
assert [s["Id"] for s in systems] == ["437XR1138R2"]
|
||||
assert server.calls("GET", "/redfish/v1/Systems/437XR1138R2")
|
||||
|
||||
|
||||
def test_members_ignores_member_count(transport):
|
||||
# Members@odata.count says 2, but three members are listed: trust the list.
|
||||
fw = transport.members("/redfish/v1/UpdateService/FirmwareInventory")
|
||||
assert [f["Id"] for f in fw] == ["BMC", "SS", "BIOS"]
|
||||
|
||||
|
||||
def test_members_from_link_on_resource(transport):
|
||||
system = transport.get("/redfish/v1/Systems/437XR1138R2")
|
||||
cpus = transport.members(system["Processors"])
|
||||
assert [c["Id"] for c in cpus] == ["CPU1", "CPU2", "FPGA1"]
|
||||
|
||||
|
||||
def test_members_uses_expanded_members_without_extra_requests(transport, server):
|
||||
entries = transport.members("/redfish/v1/Managers/BMC/LogServices/Log/Entries")
|
||||
assert entries[0]["Message"] == "System May be Melting"
|
||||
assert not server.calls("GET", "/redfish/v1/Managers/BMC/LogServices/Log/Entries/1")
|
||||
|
||||
|
||||
def test_members_follows_next_link(transport, server):
|
||||
path = "/redfish/v1/Systems/437XR1138R2/LogServices/Log1/Entries"
|
||||
server.override("GET", path + "?$skiptoken=2", 200, {
|
||||
"Members": [{"@odata.id": path + "/3", "Id": "3", "Message": "third"}],
|
||||
"@odata.id": path,
|
||||
})
|
||||
entries = transport.members(path)
|
||||
assert [e["Id"] for e in entries] == ["1", "2", "3"]
|
||||
|
||||
|
||||
def test_members_next_link_loop_terminates(transport, server):
|
||||
path = "/redfish/v1/Managers"
|
||||
server.override("GET", path, 200, {
|
||||
"Members": [{"@odata.id": "/redfish/v1/Managers/BMC"}],
|
||||
"Members@odata.nextLink": path,
|
||||
})
|
||||
assert [m["Id"] for m in transport.members(path)] == ["BMC"]
|
||||
|
||||
|
||||
def test_members_limit(transport, server):
|
||||
path = "/redfish/v1/Systems/437XR1138R2/LogServices/Log1/Entries"
|
||||
entries = transport.members(path, limit=1)
|
||||
assert [e["Id"] for e in entries] == ["1"]
|
||||
assert len(server.calls("GET", path)) == 1
|
||||
|
||||
|
||||
def test_members_of_missing_collection(transport):
|
||||
with pytest.raises(RedfishNotFound):
|
||||
transport.members("/redfish/v1/Systems/437XR1138R2/Storage")
|
||||
|
||||
|
||||
def test_follow(transport):
|
||||
manager = transport.get("/redfish/v1/Managers/BMC")
|
||||
assert transport.follow(manager, "NetworkProtocol")["Id"] == "NetworkProtocol"
|
||||
assert transport.follow(manager, "NoSuchLink") is None
|
||||
|
||||
|
||||
def test_follow_missing_target_is_none(transport, server):
|
||||
system = transport.get("/redfish/v1/Systems/437XR1138R2")
|
||||
# Storage is not linked from this system; Bios is linked but not in the fixtures.
|
||||
assert transport.follow(system, "Storage") is None
|
||||
assert transport.follow(system, "Bios") is None
|
||||
|
||||
|
||||
# --- POST ----------------------------------------------------------------------------------
|
||||
|
||||
|
||||
def test_post_sends_json_and_returns_body(transport, server):
|
||||
target = "/redfish/v1/Systems/437XR1138R2/Actions/ComputerSystem.Reset"
|
||||
server.override("POST", target, 204, {})
|
||||
assert transport.post(target, {"ResetType": "GracefulRestart"}) == {}
|
||||
(call,) = server.calls("POST", target)
|
||||
assert json.loads(call.request.body) == {"ResetType": "GracefulRestart"}
|
||||
assert call.request.headers["X-Auth-Token"] == TOKEN
|
||||
|
||||
|
||||
def test_post_error(transport, server):
|
||||
target = "/redfish/v1/Managers/BMC/Actions/Manager.Reset"
|
||||
server.override("POST", target, 400, {"error": {"message": "ActionParameterValueNotInList"}})
|
||||
with pytest.raises(RedfishHTTPError) as exc:
|
||||
transport.post(target, {"ResetType": "PowerCycle"})
|
||||
assert exc.value.status == 400
|
||||
Reference in new issue
Block a user