From acfeba1eca06d6fe96767d8ad99029bf17890a33 Mon Sep 17 00:00:00 2001 From: Jacob Yundt Date: Sun, 9 Aug 2026 20:22:25 -0400 Subject: [PATCH] Add asynchronous aiohttp Redfish client Add a caller-owned aiohttp transport with Basic authentication, timeout handling, same-origin target validation, and cached responses. Include standard ComputerSystem discovery and advertised reset support for asynchronous consumers. Signed-off-by: Jacob Yundt --- README.rst | 50 +++++ examples/async_client.py | 38 ++++ requirements.txt | 1 + setup.py | 3 + src/redfish/aio/__init__.py | 45 +++++ src/redfish/aio/client.py | 348 ++++++++++++++++++++++++++++++++++ src/redfish/aio/exceptions.py | 44 +++++ src/redfish/aio/models.py | 120 ++++++++++++ src/redfish/aio/response.py | 75 ++++++++ tests/aio/__init__.py | 4 + tests/aio/test_client.py | 339 +++++++++++++++++++++++++++++++++ tests/aio/test_models.py | 56 ++++++ tests/aio/test_systems.py | 304 +++++++++++++++++++++++++++++ tox.ini | 2 +- 14 files changed, 1428 insertions(+), 1 deletion(-) create mode 100644 examples/async_client.py create mode 100644 src/redfish/aio/__init__.py create mode 100644 src/redfish/aio/client.py create mode 100644 src/redfish/aio/exceptions.py create mode 100644 src/redfish/aio/models.py create mode 100644 src/redfish/aio/response.py create mode 100644 tests/aio/__init__.py create mode 100644 tests/aio/test_client.py create mode 100644 tests/aio/test_models.py create mode 100644 tests/aio/test_systems.py diff --git a/README.rst b/README.rst index c9f0143..436a4d1 100644 --- a/README.rst +++ b/README.rst @@ -26,6 +26,12 @@ Installing pip install redfish +The asynchronous client has an optional ``aiohttp`` dependency: + +.. code-block:: console + + pip install redfish[aiohttp] + Building from zip file source ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~ @@ -52,6 +58,8 @@ Required external packages: requests-toolbelt requests-unixsocket +The optional asynchronous client requires ``aiohttp>=3.9.0``. + If installing from GitHub, you may install the external packages by running: .. code-block:: console @@ -183,6 +191,48 @@ Each of the previous methods allows for the following arguments: - This can be useful when a particular URI is known to take multiple retries. - The default value is ``None``, which indicates the object-defined max retry count is used. +Asynchronous client +~~~~~~~~~~~~~~~~~~~ + +The additive asynchronous API uses ``aiohttp`` and does not change the existing synchronous client. The caller must provide an ``aiohttp.ClientSession`` and remains responsible for closing it. This allows an application to control connection pooling, TLS trust, proxy behavior, and session lifetime in one place. + +The asynchronous client currently supports HTTP Basic authentication. It does not create a Redfish session or require a separate login call. Requests do not follow redirects, and advertised resource or action targets are accepted only when they resolve to the configured Redfish origin. These rules prevent credentials from being sent to another origin. + +.. code-block:: python + + import aiohttp + + from redfish.aio import AsyncRedfishClient + + + async def get_systems(): + async with aiohttp.ClientSession() as session: + client = AsyncRedfishClient( + base_url="https://bmc.example", + username="user", + password="password", + session=session, + timeout=10, + discovery_timeout=60, + ) + + service_root = await client.get_service_root() + systems = await client.get_systems() + return service_root, systems + +``get``, ``head``, ``post``, ``put``, ``patch``, and ``delete`` are coroutines with the same ``path``, ``args``, ``body``, ``headers``, and ``timeout`` concepts as the synchronous methods. The returned response is fully read and cached before the coroutine returns, so it can be inspected after the underlying aiohttp response closes. + +``get_systems`` follows the standard ServiceRoot ``Systems`` link, collection pagination, ComputerSystem member links, and reset ActionInfo resources. It returns ``ComputerSystem`` objects containing standard identity, metadata, power state, reset target, and advertised standard reset types. ``reset_system`` sends an advertised reset type to that system's advertised reset target: + +.. code-block:: python + + systems = await client.get_systems() + system = systems["1"] + if "On" in system.reset_types: + await client.reset_system(system, "On") + +The optional request ``timeout`` bounds each HTTP request. ``discovery_timeout`` defaults to 60 seconds and bounds the complete ServiceRoot, collection, member, and ActionInfo discovery operation. TLS verification is controlled entirely by the injected ``ClientSession``. Configure that session with an appropriate CA certificate or SSL context for a Redfish service using a private or self-signed certificate. + Working with tasks ~~~~~~~~~~~~~~~~~~ diff --git a/examples/async_client.py b/examples/async_client.py new file mode 100644 index 0000000..f33078a --- /dev/null +++ b/examples/async_client.py @@ -0,0 +1,38 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md + +"""Discover ComputerSystem resources with the asynchronous Redfish client.""" + +import asyncio +import os + +import aiohttp + +from redfish.aio import AsyncRedfishClient + + +async def main(): + """Discover and display Redfish ComputerSystem resources.""" + async with aiohttp.ClientSession() as session: + client = AsyncRedfishClient( + base_url=os.environ["REDFISH_BASE_URL"], + username=os.environ["REDFISH_USERNAME"], + password=os.environ["REDFISH_PASSWORD"], + session=session, + timeout=10, + ) + systems = await client.get_systems() + for system in systems.values(): + print( + "{}: power={}, reset_types={}".format( + system.name or system.system_id, + system.power_state, + sorted(system.reset_types), + ) + ) + + +if __name__ == "__main__": + asyncio.run(main()) diff --git a/requirements.txt b/requirements.txt index 9840f9a..d78db70 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,4 @@ +aiohttp>=3.9.0 jsonpatch<=1.24 ; python_version == '3.4' jsonpatch ; python_version >= '3.5' jsonpath_ng diff --git a/setup.py b/setup.py index 4e79ea8..c8face9 100644 --- a/setup.py +++ b/setup.py @@ -36,6 +36,9 @@ 'requests-unixsocket' ], extras_require={ + 'aiohttp': [ + 'aiohttp>=3.9.0' + ], ':python_version == "3.4"': [ 'jsonpatch<=1.24' ], diff --git a/src/redfish/aio/__init__.py b/src/redfish/aio/__init__.py new file mode 100644 index 0000000..a33f489 --- /dev/null +++ b/src/redfish/aio/__init__.py @@ -0,0 +1,45 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md + +"""Asynchronous Redfish client API.""" + +from .client import AsyncRedfishClient +from .exceptions import ( + RedfishAuthenticationError, + RedfishConnectionError, + RedfishError, + RedfishHTTPError, + RedfishInvalidTargetError, + RedfishProtocolError, + RedfishTimeoutError, + RedfishUnsupportedResetError, +) +from .models import ( + STANDARD_RESET_TYPES, + ComputerSystem, + get_reset_action_info_target, + parse_computer_system, + parse_reset_action_info, +) +from .response import AsyncRestRequest, AsyncRestResponse + +__all__ = [ + "AsyncRedfishClient", + "AsyncRestRequest", + "AsyncRestResponse", + "ComputerSystem", + "RedfishAuthenticationError", + "RedfishConnectionError", + "RedfishError", + "RedfishHTTPError", + "RedfishInvalidTargetError", + "RedfishProtocolError", + "RedfishTimeoutError", + "RedfishUnsupportedResetError", + "STANDARD_RESET_TYPES", + "get_reset_action_info_target", + "parse_computer_system", + "parse_reset_action_info", +] diff --git a/src/redfish/aio/client.py b/src/redfish/aio/client.py new file mode 100644 index 0000000..81b2931 --- /dev/null +++ b/src/redfish/aio/client.py @@ -0,0 +1,348 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md + +"""Asynchronous Redfish client implemented with aiohttp.""" + +import asyncio +import base64 +from dataclasses import replace + +import aiohttp +from multidict import CIMultiDict +from yarl import URL + +from .exceptions import ( + RedfishAuthenticationError, + RedfishConnectionError, + RedfishHTTPError, + RedfishInvalidTargetError, + RedfishProtocolError, + RedfishTimeoutError, + RedfishUnsupportedResetError, +) +from .models import ( + get_reset_action_info_target, + parse_computer_system, + parse_reset_action_info, +) +from .response import AsyncRestRequest, AsyncRestResponse + + +class AsyncRedfishClient: + """Perform asynchronous Redfish requests with an injected session.""" + + def __init__( + self, + base_url, + username=None, + password=None, + session=None, + timeout=None, + default_prefix="/redfish/v1/", + discovery_timeout=60, + ): + if session is None: + raise ValueError( + "A caller-owned aiohttp.ClientSession is required" + ) + if (username is None) != (password is None): + raise ValueError("Username and password must be provided together") + + try: + url = URL(base_url) + except (TypeError, ValueError) as exc: + raise ValueError("Invalid Redfish base URL") from exc + if ( + url.scheme not in ("http", "https") + or url.host is None + or url.user is not None + or url.password is not None + or url.path not in ("", "/") + or url.query_string + or url.fragment + ): + raise ValueError("Invalid Redfish base URL") + + self._base_url = ( + url.with_path("/").with_query(None).with_fragment(None) + ) + self._session = session + self._timeout = self._make_timeout(timeout) + self._default_prefix = default_prefix + self._discovery_timeout = self._make_discovery_timeout( + discovery_timeout + ) + self._authorization = None + if username is not None: + if ":" in username: + raise ValueError("Username cannot contain ':'") + encoded = base64.b64encode( + "{}:{}".format(username, password).encode("utf-8") + ).decode("ascii") + self._authorization = "Basic {}".format(encoded) + + @staticmethod + def _make_timeout(timeout): + if timeout is None or isinstance(timeout, aiohttp.ClientTimeout): + return timeout + if not isinstance(timeout, (int, float)) or timeout < 0: + raise ValueError("Timeout must be a non-negative number") + return aiohttp.ClientTimeout(total=timeout) + + @staticmethod + def _make_discovery_timeout(timeout): + if timeout is not None and ( + not isinstance(timeout, (int, float)) or timeout < 0 + ): + raise ValueError( + "Discovery timeout must be a non-negative number" + ) + return timeout + + def _resolve_url(self, target): + try: + target_url = self._base_url.join(URL(target)) + if target_url.user is not None or target_url.password is not None: + raise RedfishInvalidTargetError( + "Target cannot contain credentials" + ) + if ( + target_url.scheme, + target_url.host, + target_url.port, + ) != ( + self._base_url.scheme, + self._base_url.host, + self._base_url.port, + ): + raise RedfishInvalidTargetError( + "Target must use the configured Redfish origin" + ) + except (TypeError, ValueError) as exc: + raise RedfishInvalidTargetError("Invalid Redfish target") from exc + return target_url + + def _request_headers(self, headers): + request_headers = CIMultiDict( + {"Accept": "*/*", "OData-Version": "4.0"} + ) + if headers is not None: + request_headers.update(headers) + if self._authorization is not None: + request_headers["Authorization"] = self._authorization + return request_headers + + async def _request( + self, + path, + method="GET", + args=None, + body=None, + headers=None, + timeout=None, + ): + request = AsyncRestRequest(path=path, method=method.upper(), body=body) + request_timeout = ( + self._timeout if timeout is None else self._make_timeout(timeout) + ) + kwargs = { + "allow_redirects": False, + "headers": self._request_headers(headers), + "params": args, + } + if request_timeout is not None: + kwargs["timeout"] = request_timeout + if isinstance(body, (dict, list)): + kwargs["json"] = body + elif body is not None: + kwargs["data"] = body + + try: + async with self._session.request( + method.upper(), self._resolve_url(path), **kwargs + ) as response: + content = await response.read() + encoding = response.get_encoding() + return AsyncRestResponse( + request=request, + status=response.status, + headers=response.headers, + read=content, + encoding=encoding, + ) + except asyncio.TimeoutError as exc: + raise RedfishTimeoutError("Redfish request timed out") from exc + except aiohttp.ClientError as exc: + raise RedfishConnectionError("Redfish request failed") from exc + + async def get(self, path, args=None, headers=None, timeout=None): + """Perform a GET request.""" + return await self._request( + path, method="GET", args=args, headers=headers, timeout=timeout + ) + + async def head(self, path, args=None, headers=None, timeout=None): + """Perform a HEAD request.""" + return await self._request( + path, method="HEAD", args=args, headers=headers, timeout=timeout + ) + + async def post( + self, path, args=None, body=None, headers=None, timeout=None + ): + """Perform a POST request.""" + return await self._request( + path, + method="POST", + args=args, + body=body, + headers=headers, + timeout=timeout, + ) + + async def put( + self, path, args=None, body=None, headers=None, timeout=None + ): + """Perform a PUT request.""" + return await self._request( + path, + method="PUT", + args=args, + body=body, + headers=headers, + timeout=timeout, + ) + + async def patch( + self, path, args=None, body=None, headers=None, timeout=None + ): + """Perform a PATCH request.""" + return await self._request( + path, + method="PATCH", + args=args, + body=body, + headers=headers, + timeout=timeout, + ) + + async def delete( + self, path, args=None, headers=None, timeout=None, body=None + ): + """Perform a DELETE request.""" + return await self._request( + path, + method="DELETE", + args=args, + body=body, + headers=headers, + timeout=timeout, + ) + + @staticmethod + def _ensure_success(response): + if response.status in (401, 403): + raise RedfishAuthenticationError( + "Redfish service rejected authentication" + ) + if not 200 <= response.status < 300: + raise RedfishHTTPError(response) + + async def _get_json(self, path): + response = await self.get(path) + self._ensure_success(response) + payload = response.dict + if not isinstance(payload, dict): + raise RedfishProtocolError( + "Redfish resource at {} is not a JSON object".format(path) + ) + return payload + + async def get_service_root(self): + """Return the standard Redfish service root.""" + return await self._get_json(self._default_prefix) + + async def _get_collection_members(self, link): + if ( + not isinstance(link, dict) + or not isinstance(path := link.get("@odata.id"), str) + or not path.strip() + ): + return [] + + payloads = [] + seen_paths = set() + while True: + if path in seen_paths: + raise RedfishProtocolError( + "Redfish collection pagination contains a cycle" + ) + seen_paths.add(path) + collection = await self._get_json(path) + members = collection.get("Members") + if not isinstance(members, list): + return [] + for member in members: + if ( + isinstance(member, dict) + and isinstance(member_path := member.get("@odata.id"), str) + and member_path.strip() + ): + payloads.append(await self._get_json(member_path)) + next_path = collection.get("Members@odata.nextLink") + if not isinstance(next_path, str) or not next_path.strip(): + return payloads + path = next_path + + async def _discover_systems(self): + root = await self.get_service_root() + systems = {} + for payload in await self._get_collection_members(root.get("Systems")): + system = parse_computer_system(payload) + if system is None: + continue + action_info_target = get_reset_action_info_target(payload) + if ( + system.reset_target is not None + and action_info_target is not None + ): + action_info = await self._get_json(action_info_target) + system = replace( + system, + reset_types=system.reset_types + | parse_reset_action_info(action_info), + ) + systems[system.system_id] = system + return systems + + async def get_systems(self): + """Discover ComputerSystem resources from the Redfish service root.""" + try: + return await asyncio.wait_for( + self._discover_systems(), timeout=self._discovery_timeout + ) + except asyncio.TimeoutError as exc: + raise RedfishTimeoutError( + "Redfish system discovery timed out" + ) from exc + + async def reset_system(self, system, reset_type, timeout=None): + """Perform a reset type advertised by a ComputerSystem.""" + if ( + system.reset_target is None + or reset_type not in system.reset_types + ): + raise RedfishUnsupportedResetError( + "ComputerSystem does not advertise ResetType {}".format( + reset_type + ) + ) + response = await self.post( + system.reset_target, + body={"ResetType": reset_type}, + timeout=timeout, + ) + self._ensure_success(response) + return response diff --git a/src/redfish/aio/exceptions.py b/src/redfish/aio/exceptions.py new file mode 100644 index 0000000..7cf28f2 --- /dev/null +++ b/src/redfish/aio/exceptions.py @@ -0,0 +1,44 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md + +"""Exceptions raised by the asynchronous Redfish client.""" + + +class RedfishError(Exception): + """Base exception for asynchronous Redfish operations.""" + + +class RedfishConnectionError(RedfishError): + """Raised when the Redfish service cannot be reached.""" + + +class RedfishTimeoutError(RedfishConnectionError): + """Raised when a Redfish operation times out.""" + + +class RedfishInvalidTargetError(RedfishError): + """Raised when a target is invalid or outside the configured origin.""" + + +class RedfishAuthenticationError(RedfishError): + """Raised when the Redfish service rejects authentication.""" + + +class RedfishHTTPError(RedfishError): + """Raised when a Redfish service returns an unsuccessful HTTP status.""" + + def __init__(self, response): + super().__init__( + "Redfish request returned HTTP {}".format(response.status) + ) + self.response = response + + +class RedfishProtocolError(RedfishError): + """Raised when a Redfish resource is malformed.""" + + +class RedfishUnsupportedResetError(RedfishError): + """Raised when a reset type is not advertised by a ComputerSystem.""" diff --git a/src/redfish/aio/models.py b/src/redfish/aio/models.py new file mode 100644 index 0000000..7d9c7e4 --- /dev/null +++ b/src/redfish/aio/models.py @@ -0,0 +1,120 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md + +"""Standard Redfish resource models used by the asynchronous client.""" + +from dataclasses import dataclass +from typing import FrozenSet, Optional + + +STANDARD_RESET_TYPES = frozenset( + { + "ForceOff", + "ForceOn", + "ForceRestart", + "FullPowerCycle", + "GracefulRestart", + "GracefulShutdown", + "Nmi", + "On", + "Pause", + "PowerCycle", + "PushPowerButton", + "Resume", + "Suspend", + } +) + + +@dataclass(frozen=True) +class ComputerSystem: + """Standard properties and reset capabilities of a ComputerSystem.""" + + odata_id: str + system_id: str + name: Optional[str] + uuid: Optional[str] + manufacturer: Optional[str] + model: Optional[str] + serial_number: Optional[str] + power_state: Optional[str] + reset_target: Optional[str] + reset_types: FrozenSet[str] + + +def _non_empty_string(value): + return value if isinstance(value, str) and value.strip() else None + + +def _reset_action(payload): + actions = payload.get("Actions") + if not isinstance(actions, dict): + return None + reset = actions.get("#ComputerSystem.Reset") + return reset if isinstance(reset, dict) else None + + +def get_reset_action_info_target(payload): + """Return the ActionInfo target advertised for ComputerSystem.Reset.""" + reset = _reset_action(payload) + if reset is None: + return None + return _non_empty_string(reset.get("@Redfish.ActionInfo")) + + +def parse_reset_action_info(payload): + """Return standard ResetType values from an ActionInfo resource.""" + parameters = payload.get("Parameters") + if not isinstance(parameters, list): + return frozenset() + for parameter in parameters: + if ( + not isinstance(parameter, dict) + or parameter.get("Name") != "ResetType" + ): + continue + allowable_values = parameter.get("AllowableValues") + if not isinstance(allowable_values, list): + return frozenset() + return frozenset( + value + for value in allowable_values + if isinstance(value, str) and value in STANDARD_RESET_TYPES + ) + return frozenset() + + +def parse_computer_system(payload): + """Parse a ComputerSystem resource or return None when unusable.""" + odata_id = _non_empty_string(payload.get("@odata.id")) + system_id = _non_empty_string(payload.get("Id")) + if odata_id is None or system_id is None: + return None + + reset_target = None + reset_types = frozenset() + reset = _reset_action(payload) + if reset is not None: + reset_target = _non_empty_string(reset.get("target")) + allowable_values = reset.get("ResetType@Redfish.AllowableValues") + if reset_target is not None and isinstance(allowable_values, list): + reset_types = frozenset( + value + for value in allowable_values + if isinstance(value, str) and value in STANDARD_RESET_TYPES + ) + + return ComputerSystem( + odata_id=odata_id, + system_id=system_id, + name=_non_empty_string(payload.get("Name")), + uuid=_non_empty_string(payload.get("UUID")), + manufacturer=_non_empty_string(payload.get("Manufacturer")), + model=_non_empty_string(payload.get("Model")), + serial_number=_non_empty_string(payload.get("SerialNumber")), + power_state=_non_empty_string(payload.get("PowerState")), + reset_target=reset_target, + reset_types=reset_types, + ) diff --git a/src/redfish/aio/response.py b/src/redfish/aio/response.py new file mode 100644 index 0000000..e3935e0 --- /dev/null +++ b/src/redfish/aio/response.py @@ -0,0 +1,75 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md + +"""Cached request and response objects for asynchronous Redfish operations.""" + +from dataclasses import dataclass +import json + +from multidict import CIMultiDict + +from .exceptions import RedfishProtocolError + + +@dataclass(frozen=True) +class AsyncRestRequest: + """Description of an asynchronous Redfish request.""" + + path: str + method: str = "GET" + body: object = None + + +class AsyncRestResponse: + """Cached response returned by an asynchronous Redfish request.""" + + def __init__(self, request, status, headers, read, encoding="utf-8"): + self._request = request + self._status = status + self._headers = CIMultiDict(headers) + self._read = read + self._encoding = encoding + + @property + def read(self): + """Return the raw response body.""" + return self._read + + @property + def status(self): + """Return the HTTP status code.""" + return self._status + + @property + def text(self): + """Return the decoded response body.""" + return self._read.decode(self._encoding, "replace") + + @property + def dict(self): + """Return the response body decoded as JSON.""" + if not self._read: + return {} + try: + return json.loads(self.text) + except (TypeError, ValueError) as exc: + raise RedfishProtocolError( + "Service responded with invalid JSON at URI {}".format( + self._request.path + ) + ) from exc + + @property + def request(self): + """Return the request that produced this response.""" + return self._request + + def getheaders(self): + """Return all response headers.""" + return list(self._headers.items()) + + def getheader(self, name): + """Return one response header case-insensitively.""" + return self._headers.get(name) diff --git a/tests/aio/__init__.py b/tests/aio/__init__.py new file mode 100644 index 0000000..9c84fd5 --- /dev/null +++ b/tests/aio/__init__.py @@ -0,0 +1,4 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md diff --git a/tests/aio/test_client.py b/tests/aio/test_client.py new file mode 100644 index 0000000..2afd436 --- /dev/null +++ b/tests/aio/test_client.py @@ -0,0 +1,339 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md + +import asyncio +import unittest + +import aiohttp +from aiohttp import web +from aiohttp.test_utils import TestServer + +from redfish.aio import ( + AsyncRedfishClient, + RedfishConnectionError, + RedfishInvalidTargetError, + RedfishTimeoutError, +) + + +class TestAsyncRedfishClient(unittest.IsolatedAsyncioTestCase): + """Test the asynchronous Redfish HTTP client.""" + + async def asyncSetUp(self): + self.requests = [] + app = web.Application() + + async def response(request): + if request.path == "/slow": + await asyncio.sleep(0.1) + if not request.can_read_body: + body = None + elif request.content_type == "application/json": + body = await request.json() + else: + body = await request.text() + self.requests.append( + { + "method": request.method, + "path_qs": request.path_qs, + "body": body, + "authorization": request.headers.get("Authorization"), + "authorization_all": request.headers.getall( + "Authorization", [] + ), + "accept": request.headers.get("Accept"), + "odata_version": request.headers.get("OData-Version"), + "custom": request.headers.get("X-Custom"), + } + ) + if request.path == "/empty": + return web.Response(status=204) + status = 401 if request.path == "/unauthorized" else 200 + return web.json_response( + {"method": request.method, "path": request.path}, + status=status, + headers={"X-Response": "present"}, + ) + + app.router.add_route("*", "/{path:.*}", response) + self.server = TestServer(app) + await self.server.start_server() + self.session = aiohttp.ClientSession() + self.client = AsyncRedfishClient( + base_url=str(self.server.make_url("/")), + username="user", + password="password", + session=self.session, + ) + + async def asyncTearDown(self): + await self.session.close() + await self.server.close() + + async def test_get_returns_cached_response(self): + """Test GET query parameters and response accessors.""" + response = await self.client.get( + "/resource", + args={"query": "value"}, + headers={"X-Custom": "header"}, + ) + + self.assertEqual(response.status, 200) + self.assertIsInstance(response.read, bytes) + self.assertEqual(response.dict, {"method": "GET", "path": "/resource"}) + self.assertIn(("X-Response", "present"), response.getheaders()) + self.assertEqual(response.getheader("x-response"), "present") + self.assertEqual(response.request.method, "GET") + self.assertEqual(response.request.path, "/resource") + self.assertEqual( + self.requests, + [ + { + "method": "GET", + "path_qs": "/resource?query=value", + "body": None, + "authorization": "Basic dXNlcjpwYXNzd29yZA==", + "authorization_all": [ + "Basic dXNlcjpwYXNzd29yZA==" + ], + "accept": "*/*", + "odata_version": "4.0", + "custom": "header", + } + ], + ) + + async def test_headers_are_case_insensitive_and_auth_is_protected(self): + """Test custom headers cannot duplicate or replace Basic auth.""" + await self.client.get( + "/resource", + headers={ + "accept": "application/json", + "odata-version": "4.01", + "authorization": "Bearer untrusted", + }, + ) + + self.assertEqual( + self.requests[0], + { + "method": "GET", + "path_qs": "/resource", + "body": None, + "authorization": "Basic dXNlcjpwYXNzd29yZA==", + "authorization_all": [ + "Basic dXNlcjpwYXNzd29yZA==" + ], + "accept": "application/json", + "odata_version": "4.01", + "custom": None, + }, + ) + + async def test_write_methods_send_json_body(self): + """Test POST, PUT, PATCH, and DELETE requests.""" + for method_name in ("post", "put", "patch", "delete"): + with self.subTest(method=method_name): + response = await getattr(self.client, method_name)( + "/resource", + args={"query": method_name}, + body={"method": method_name}, + ) + self.assertEqual(response.status, 200) + + self.assertEqual( + [ + (request["method"], request["body"]) + for request in self.requests + ], + [ + ("POST", {"method": "post"}), + ("PUT", {"method": "put"}), + ("PATCH", {"method": "patch"}), + ("DELETE", {"method": "delete"}), + ], + ) + + async def test_head_and_unstructured_body(self): + """Test HEAD requests and unstructured request bodies.""" + response = await self.client.head("/resource") + self.assertEqual(response.status, 200) + + await self.client.post("/resource", body="raw body") + self.assertEqual( + (self.requests[-1]["method"], self.requests[-1]["body"]), + ("POST", "raw body"), + ) + + async def test_caller_owns_session(self): + """Test the client never closes the injected session.""" + await self.client.get("/resource") + + self.assertFalse(self.session.closed) + + async def test_empty_response_has_empty_dictionary(self): + """Test a valid empty response has an empty dictionary body.""" + response = await self.client.post("/empty") + + self.assertEqual(response.status, 204) + self.assertEqual(response.dict, {}) + + async def test_same_origin_absolute_and_scheme_relative_targets(self): + """Test advertised same-origin target forms are accepted.""" + absolute_target = str(self.server.make_url("/absolute")) + scheme_relative_target = str( + self.server.make_url("/scheme-relative").with_scheme("") + ) + + await self.client.post(absolute_target, body={"ResetType": "On"}) + await self.client.post( + scheme_relative_target, body={"ResetType": "GracefulShutdown"} + ) + + self.assertEqual( + [request["path_qs"] for request in self.requests], + ["/absolute", "/scheme-relative"], + ) + + def test_default_ports_share_origin(self): + """Test explicit default ports match their implicit origins.""" + for base_url, target in ( + ("https://bmc.example", "https://bmc.example:443/reset"), + ("http://bmc.example", "http://bmc.example:80/reset"), + ): + with self.subTest(base_url=base_url, target=target): + client = AsyncRedfishClient( + base_url=base_url, session=self.session + ) + self.assertEqual(client._resolve_url(target).path, "/reset") + + async def test_cross_origin_target_is_rejected_before_request(self): + """Test credentials cannot be sent to another origin.""" + malicious_requests = [] + malicious_app = web.Application() + + async def capture_request(request): + malicious_requests.append(request.headers.get("Authorization")) + return web.Response(status=204) + + malicious_app.router.add_route("*", "/{path:.*}", capture_request) + malicious_server = TestServer(malicious_app) + await malicious_server.start_server() + self.addAsyncCleanup(malicious_server.close) + + for target in ( + str(malicious_server.make_url("/reset")), + str(malicious_server.make_url("/reset").with_scheme("")), + ): + with self.subTest(target=target), self.assertRaises( + RedfishInvalidTargetError + ): + await self.client.post(target, body={"ResetType": "On"}) + + self.assertEqual(malicious_requests, []) + + async def test_redirect_is_not_followed(self): + """Test redirects cannot forward credentials to another origin.""" + malicious_requests = [] + malicious_app = web.Application() + + async def capture_request(request): + malicious_requests.append(request.headers.get("Authorization")) + return web.Response(status=204) + + malicious_app.router.add_get("/{path:.*}", capture_request) + malicious_server = TestServer(malicious_app) + await malicious_server.start_server() + self.addAsyncCleanup(malicious_server.close) + + redirect_app = web.Application() + + async def redirect(_request): + raise web.HTTPFound(str(malicious_server.make_url("/target"))) + + redirect_app.router.add_get("/{path:.*}", redirect) + redirect_server = TestServer(redirect_app) + await redirect_server.start_server() + self.addAsyncCleanup(redirect_server.close) + redirect_client = AsyncRedfishClient( + base_url=str(redirect_server.make_url("/")), + username="user", + password="password", + session=self.session, + ) + + response = await redirect_client.get("/redirect") + + self.assertEqual(response.status, 302) + self.assertEqual(malicious_requests, []) + + async def test_default_and_request_timeout(self): + """Test default timeouts and per-request overrides.""" + client = AsyncRedfishClient( + base_url=str(self.server.make_url("/")), + username="user", + password="password", + session=self.session, + timeout=0.01, + ) + + with self.assertRaises(RedfishTimeoutError): + await client.get("/slow") + + response = await client.get("/slow", timeout=0.2) + self.assertEqual(response.status, 200) + + async def test_connection_error_is_translated(self): + """Test aiohttp connection failures use a Redfish exception.""" + server = TestServer(web.Application()) + await server.start_server() + base_url = str(server.make_url("/")) + await server.close() + client = AsyncRedfishClient(base_url=base_url, session=self.session) + + with self.assertRaises(RedfishConnectionError): + await client.get("/resource") + + async def test_client_validation(self): + """Test invalid constructor arguments are rejected.""" + valid = { + "base_url": str(self.server.make_url("/")), + "session": self.session, + } + invalid_arguments = ( + {"base_url": "https://bmc.example"}, + {**valid, "username": "user"}, + {**valid, "password": "password"}, + {**valid, "username": "invalid:user", "password": "password"}, + {**valid, "timeout": -1}, + {**valid, "timeout": "invalid"}, + {**valid, "discovery_timeout": -1}, + {**valid, "discovery_timeout": "invalid"}, + {**valid, "base_url": "https://["}, + {**valid, "base_url": "bmc.example"}, + {**valid, "base_url": "https://user@bmc.example"}, + {**valid, "base_url": "https://bmc.example/redfish"}, + ) + + for arguments in invalid_arguments: + with self.subTest(arguments=arguments), self.assertRaises( + ValueError + ): + AsyncRedfishClient(**arguments) + + async def test_invalid_targets_are_rejected(self): + """Test malformed and credential-bearing targets are rejected.""" + for target in ( + "https://[", + str(self.server.make_url("/resource").with_user("other")), + ): + with self.subTest(target=target), self.assertRaises( + RedfishInvalidTargetError + ): + await self.client.get(target) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/aio/test_models.py b/tests/aio/test_models.py new file mode 100644 index 0000000..825a75d --- /dev/null +++ b/tests/aio/test_models.py @@ -0,0 +1,56 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md + +import unittest + +from redfish.aio import ( + get_reset_action_info_target, + parse_computer_system, + parse_reset_action_info, +) + + +class TestAsyncResourceModels(unittest.TestCase): + """Test standard resource parsing used by the asynchronous client.""" + + def test_action_info_requires_reset_action(self): + """Test ActionInfo lookup without a reset action.""" + self.assertIsNone(get_reset_action_info_target({})) + + def test_action_info_without_usable_reset_values(self): + """Test malformed ActionInfo parameters produce no reset types.""" + for payload in ( + {}, + {"Parameters": [None, {"Name": "OtherParameter"}]}, + {"Parameters": [{"Name": "ResetType"}]}, + ): + with self.subTest(payload=payload): + self.assertEqual(parse_reset_action_info(payload), frozenset()) + + def test_computer_system_requires_standard_identifiers(self): + """Test systems without usable identifiers are rejected.""" + for payload in ( + {}, + {"Id": "1"}, + {"@odata.id": "/redfish/v1/Systems/1"}, + {"@odata.id": " ", "Id": "1"}, + {"@odata.id": "/redfish/v1/Systems/1", "Id": 1}, + ): + with self.subTest(payload=payload): + self.assertIsNone(parse_computer_system(payload)) + + def test_computer_system_without_reset_action(self): + """Test a usable system need not advertise reset support.""" + system = parse_computer_system( + {"@odata.id": "/redfish/v1/Systems/1", "Id": "1"} + ) + + self.assertIsNotNone(system) + self.assertIsNone(system.reset_target) + self.assertEqual(system.reset_types, frozenset()) + + +if __name__ == "__main__": + unittest.main() diff --git a/tests/aio/test_systems.py b/tests/aio/test_systems.py new file mode 100644 index 0000000..5f99c32 --- /dev/null +++ b/tests/aio/test_systems.py @@ -0,0 +1,304 @@ +# Copyright Notice: +# Copyright 2016-2026 DMTF. All rights reserved. +# License: BSD 3-Clause License. For full text see link: +# https://github.com/DMTF/python-redfish-library/blob/main/LICENSE.md + +import asyncio +import unittest + +import aiohttp +from aiohttp import web +from aiohttp.test_utils import TestServer + +from redfish.aio import ( + AsyncRedfishClient, + ComputerSystem, + RedfishAuthenticationError, + RedfishHTTPError, + RedfishProtocolError, + RedfishTimeoutError, + RedfishUnsupportedResetError, +) + + +class TestAsyncComputerSystemDiscovery(unittest.IsolatedAsyncioTestCase): + """Test standard asynchronous ComputerSystem operations.""" + + async def asyncSetUp(self): + self.requests = [] + self.statuses = {} + self.delays = {} + self.raw_responses = {} + self.resources = { + "/redfish/v1/": { + "Systems": {"@odata.id": "/redfish/v1/Systems"}, + }, + "/redfish/v1/Systems": { + "Members": [{"@odata.id": "/redfish/v1/Systems/1"}], + "Members@odata.nextLink": "/redfish/v1/Systems?page=2", + }, + "/redfish/v1/Systems?page=2": { + "Members": [{"@odata.id": "/redfish/v1/Systems/2"}], + }, + "/redfish/v1/Systems/1": { + "@odata.id": "/redfish/v1/Systems/1", + "Id": "1", + "Name": "Server One", + "UUID": "uuid-1", + "Manufacturer": "Acme", + "Model": "Model 1", + "SerialNumber": "serial-1", + "PowerState": "On", + "Actions": { + "#ComputerSystem.Reset": { + "target": ( + "/redfish/v1/Systems/1/Actions/" + "ComputerSystem.Reset" + ), + "ResetType@Redfish.AllowableValues": [ + "On", + "GracefulShutdown", + "VendorReset", + 1, + ], + } + }, + }, + "/redfish/v1/Systems/2": { + "@odata.id": "/redfish/v1/Systems/2", + "Id": "2", + "Name": "Server Two", + "PowerState": "Off", + "Actions": { + "#ComputerSystem.Reset": { + "target": ( + "/redfish/v1/Systems/2/Actions/" + "ComputerSystem.Reset" + ), + "@Redfish.ActionInfo": ( + "/redfish/v1/Systems/2/ResetActionInfo" + ), + } + }, + }, + "/redfish/v1/Systems/2/ResetActionInfo": { + "Parameters": [ + { + "Name": "ResetType", + "AllowableValues": [ + "ForceOff", + "GracefulRestart", + "VendorReset", + ], + } + ] + }, + } + app = web.Application() + + async def response(request): + path = request.path_qs + if delay := self.delays.get(path): + await asyncio.sleep(delay) + body = await request.json() if request.can_read_body else None + self.requests.append((request.method, path, body)) + status = self.statuses.get(path, 200) + if path in self.raw_responses: + return web.Response( + text=self.raw_responses[path], + status=status, + content_type="application/json", + ) + if request.method != "GET": + return web.Response(status=status) + return web.json_response( + self.resources.get(path, {}), status=status + ) + + app.router.add_route("*", "/{path:.*}", response) + self.server = TestServer(app) + await self.server.start_server() + self.session = aiohttp.ClientSession() + self.client = AsyncRedfishClient( + base_url=str(self.server.make_url("/")), + username="user", + password="password", + session=self.session, + ) + + async def asyncTearDown(self): + await self.session.close() + await self.server.close() + + async def test_discovers_paginated_systems_and_metadata(self): + """Test service-root traversal and ComputerSystem parsing.""" + systems = await self.client.get_systems() + + self.assertEqual( + systems, + { + "1": ComputerSystem( + odata_id="/redfish/v1/Systems/1", + system_id="1", + name="Server One", + uuid="uuid-1", + manufacturer="Acme", + model="Model 1", + serial_number="serial-1", + power_state="On", + reset_target=( + "/redfish/v1/Systems/1/Actions/" + "ComputerSystem.Reset" + ), + reset_types=frozenset({"On", "GracefulShutdown"}), + ), + "2": ComputerSystem( + odata_id="/redfish/v1/Systems/2", + system_id="2", + name="Server Two", + uuid=None, + manufacturer=None, + model=None, + serial_number=None, + power_state="Off", + reset_target=( + "/redfish/v1/Systems/2/Actions/" + "ComputerSystem.Reset" + ), + reset_types=frozenset({"ForceOff", "GracefulRestart"}), + ), + }, + ) + self.assertEqual( + [request[1] for request in self.requests], + [ + "/redfish/v1/", + "/redfish/v1/Systems", + "/redfish/v1/Systems/1", + "/redfish/v1/Systems?page=2", + "/redfish/v1/Systems/2", + "/redfish/v1/Systems/2/ResetActionInfo", + ], + ) + + async def test_reset_system_uses_advertised_target_and_type(self): + """Test reset uses the target and ResetType advertised by the BMC.""" + system = (await self.client.get_systems())["2"] + self.requests.clear() + + response = await self.client.reset_system(system, "ForceOff") + + self.assertEqual(response.status, 200) + self.assertEqual( + self.requests, + [ + ( + "POST", + "/redfish/v1/Systems/2/Actions/ComputerSystem.Reset", + {"ResetType": "ForceOff"}, + ) + ], + ) + + async def test_reset_system_rejects_unadvertised_type(self): + """Test an unadvertised reset type never reaches the BMC.""" + system = (await self.client.get_systems())["2"] + self.requests.clear() + + with self.assertRaises(RedfishUnsupportedResetError): + await self.client.reset_system(system, "On") + + self.assertEqual(self.requests, []) + + async def test_missing_and_malformed_systems_are_skipped(self): + """Test unusable collection and ComputerSystem data is skipped.""" + self.resources["/redfish/v1/Systems"] = { + "Members": [ + {}, + {"@odata.id": 1}, + {"@odata.id": " "}, + {"@odata.id": "/redfish/v1/Systems/malformed"}, + ] + } + self.resources["/redfish/v1/Systems/malformed"] = { + "@odata.id": "/redfish/v1/Systems/malformed" + } + + self.assertEqual(await self.client.get_systems(), {}) + + self.resources["/redfish/v1/"] = {} + self.assertEqual(await self.client.get_systems(), {}) + + async def test_repeated_pagination_link_is_rejected(self): + """Test cyclic collection pagination is rejected.""" + self.resources["/redfish/v1/Systems"] = { + "Members": [], + "Members@odata.nextLink": "/redfish/v1/Systems", + } + + with self.assertRaises(RedfishProtocolError): + await self.client.get_systems() + + async def test_discovery_timeout_includes_action_info(self): + """Test the overall discovery deadline includes ActionInfo requests.""" + self.delays["/redfish/v1/Systems/2/ResetActionInfo"] = 0.1 + client = AsyncRedfishClient( + base_url=str(self.server.make_url("/")), + username="user", + password="password", + session=self.session, + discovery_timeout=0.01, + ) + + with self.assertRaises(RedfishTimeoutError): + await client.get_systems() + + async def test_discovery_classifies_http_errors(self): + """Test unsuccessful responses are classified.""" + for status, expected_error in ( + (401, RedfishAuthenticationError), + (403, RedfishAuthenticationError), + (500, RedfishHTTPError), + ): + with self.subTest(status=status): + self.statuses["/redfish/v1/"] = status + with self.assertRaises(expected_error): + await self.client.get_systems() + + async def test_discovery_rejects_malformed_json(self): + """Test malformed JSON is reported as a protocol error.""" + self.raw_responses["/redfish/v1/"] = "{" + + with self.assertRaises(RedfishProtocolError): + await self.client.get_systems() + + async def test_discovery_rejects_non_object_json(self): + """Test a Redfish resource must contain a JSON object.""" + self.raw_responses["/redfish/v1/"] = "[]" + + with self.assertRaises(RedfishProtocolError): + await self.client.get_systems() + + async def test_malformed_collection_members_are_ignored(self): + """Test a collection without a Members array is treated as empty.""" + self.resources["/redfish/v1/Systems"] = {"Members": {}} + + self.assertEqual(await self.client.get_systems(), {}) + + async def test_reset_system_classifies_http_errors(self): + """Test reset HTTP failures are classified.""" + system = (await self.client.get_systems())["1"] + target = "/redfish/v1/Systems/1/Actions/ComputerSystem.Reset" + + for status, expected_error in ( + (401, RedfishAuthenticationError), + (500, RedfishHTTPError), + ): + with self.subTest(status=status): + self.statuses[target] = status + with self.assertRaises(expected_error): + await self.client.reset_system(system, "On") + + +if __name__ == "__main__": + unittest.main() diff --git a/tox.ini b/tox.ini index 751d0d9..20c9123 100644 --- a/tox.ini +++ b/tox.ini @@ -15,7 +15,7 @@ commands = [testenv:pep8] basepython = python3 deps = flake8 -commands = flake8 tests/ src/redfish/discovery +commands = flake8 tests/ src/redfish/discovery src/redfish/aio examples/async_client.py [travis] python = 3.14: py314