diff --git a/nodescraper/connection/oob_rest/__init__.py b/nodescraper/connection/oob_rest/__init__.py new file mode 100644 index 00000000..604d7740 --- /dev/null +++ b/nodescraper/connection/oob_rest/__init__.py @@ -0,0 +1,34 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from .oob_rest_connection import OobRestConnection +from .oob_rest_connection_manager import OobRestConnectionManager +from .oob_rest_params import OobRestConnectionParams + +__all__ = [ + "OobRestConnection", + "OobRestConnectionManager", + "OobRestConnectionParams", +] diff --git a/nodescraper/connection/oob_rest/oob_rest_connection.py b/nodescraper/connection/oob_rest/oob_rest_connection.py new file mode 100644 index 00000000..0e1bb909 --- /dev/null +++ b/nodescraper/connection/oob_rest/oob_rest_connection.py @@ -0,0 +1,105 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from __future__ import annotations + +from typing import Any + +import requests +import urllib3 # type: ignore[import-untyped] + +from .oob_rest_params import OobRestConnectionParams + + +class OobRestConnection: + """Generic REST transport for an out-of-band controller. + + Owns a ``requests`` session, authenticates once with a username/password POST + to the configured login endpoint, and issues authenticated JSON requests. + Controller-specific endpoints and response parsing belong in the caller, not + here. + """ + + def __init__(self, params: OobRestConnectionParams): + self._params = params + scheme = "https" if params.use_https else "http" + host = str(params.host) + self._base = f"{scheme}://{host}:{params.port}" if params.port else f"{scheme}://{host}" + self._session = requests.Session() + self._session.verify = params.verify_ssl + if not params.verify_ssl: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self._authenticated = False + + @property + def base_url(self) -> str: + """Return the base URL the connection issues requests against.""" + return self._base + + def authenticate(self) -> None: + """Authenticate against the controller's login endpoint. + + Idempotent: a no-op once authenticated. Called automatically before any + request, and also invoked by the connection manager to verify the + connection on connect. + """ + if self._authenticated: + return + password = ( + self._params.password.get_secret_value() if self._params.password is not None else None + ) + payload: dict[str, Any] = { + "username": self._params.username, + "password": password, + **self._params.login_payload_extra, + } + resp = self._session.post( + f"{self._base}{self._params.login_path}", + json=payload, + timeout=self._params.timeout_seconds, + ) + resp.raise_for_status() + self._authenticated = True + + def get(self, path: str) -> dict: + """Issue an authenticated GET and return the parsed JSON body.""" + self.authenticate() + resp = self._session.get(f"{self._base}{path}", timeout=self._params.timeout_seconds) + resp.raise_for_status() + return resp.json() + + def post(self, path: str, json: Any | None = None) -> dict: + """Issue an authenticated POST and return the parsed JSON body.""" + self.authenticate() + resp = self._session.post( + f"{self._base}{path}", json=json, timeout=self._params.timeout_seconds + ) + resp.raise_for_status() + return resp.json() + + def close(self) -> None: + """Close the underlying session.""" + self._session.close() + self._authenticated = False diff --git a/nodescraper/connection/oob_rest/oob_rest_connection_manager.py b/nodescraper/connection/oob_rest/oob_rest_connection_manager.py new file mode 100644 index 00000000..444bdb9d --- /dev/null +++ b/nodescraper/connection/oob_rest/oob_rest_connection_manager.py @@ -0,0 +1,121 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from __future__ import annotations + +from logging import Logger +from typing import ClassVar, Optional, Union + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces.connectionmanager import ConnectionManager +from nodescraper.interfaces.taskresulthook import TaskResultHook +from nodescraper.models import SystemInfo, TaskResult +from nodescraper.utils import get_exception_traceback + +from .oob_rest_connection import OobRestConnection +from .oob_rest_params import OobRestConnectionParams + + +class OobRestConnectionManager(ConnectionManager[OobRestConnection, OobRestConnectionParams]): + """Connection manager for a generic out-of-band REST controller. + + Builds an :class:`OobRestConnection` from the provided parameters and + authenticates once on connect, so a controller that is unreachable or + rejects credentials fails here rather than in every plugin in turn. + """ + + CONNECTION_ARGS_MODEL: ClassVar[type[OobRestConnectionParams]] = OobRestConnectionParams + + def __init__( + self, + system_info: SystemInfo, + logger: Optional[Logger] = None, + max_event_priority_level: Union[EventPriority, str] = EventPriority.CRITICAL, + parent: Optional[str] = None, + task_result_hooks: Optional[list[TaskResultHook]] = None, + connection_args: Optional[OobRestConnectionParams] = None, + **kwargs, + ): + super().__init__( + system_info, + logger, + max_event_priority_level, + parent, + task_result_hooks, + connection_args, + **kwargs, + ) + + def connect(self) -> TaskResult: + if not self.connection_args: + self._log_event( + category=EventCategory.RUNTIME, + description="No OOB REST connection parameters provided", + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result + + raw = self.connection_args + if isinstance(raw, dict): + params = OobRestConnectionParams.model_validate(raw) + elif isinstance(raw, OobRestConnectionParams): + params = raw + else: + self._log_event( + category=EventCategory.RUNTIME, + description="OOB REST connection_args must be dict or OobRestConnectionParams", + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result + + try: + self.logger.info("Initializing OOB REST connection to %s", params.host) + connection = OobRestConnection(params) + connection.authenticate() + self.connection = connection + except Exception as exception: + self._log_event( + category=EventCategory.NETWORK, + description=f"Exception during OOB REST connection: {exception!s}", + data=get_exception_traceback(exception), + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + self.connection = None + return self.result + + def disconnect(self) -> None: + conn = self.connection + super().disconnect() + if isinstance(conn, OobRestConnection): + try: + conn.close() + except Exception: + pass diff --git a/nodescraper/connection/oob_rest/oob_rest_params.py b/nodescraper/connection/oob_rest/oob_rest_params.py new file mode 100644 index 00000000..ad5924bc --- /dev/null +++ b/nodescraper/connection/oob_rest/oob_rest_params.py @@ -0,0 +1,65 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from __future__ import annotations + +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, SecretStr +from pydantic.networks import IPvAnyAddress + + +class OobRestConnectionParams(BaseModel): + """Connection parameters for a generic out-of-band REST controller. + + Covers controllers that authenticate with a username/password POST to a + login endpoint and then serve JSON over HTTPS. The login endpoint and any + extra payload fields are parameterized so a controller that expects extra + login fields (e.g. ``{"tenant": "default"}``) can be configured without a + controller-specific subclass. + """ + + model_config = ConfigDict(arbitrary_types_allowed=True) + + host: Union[IPvAnyAddress, str] + username: str + password: Optional[SecretStr] = None + port: Optional[int] = Field(default=None, ge=1, le=65535) + use_https: bool = True + verify_ssl: bool = Field( + default=False, + description="Verify the HTTPS server certificate. Defaults to False because OOB " + "controllers commonly serve self-signed certificates.", + ) + timeout_seconds: float = Field(default=30.0, gt=0, le=300) + login_path: str = Field( + default="/v1/login", + description="Path of the login endpoint the controller authenticates against.", + ) + login_payload_extra: dict = Field( + default_factory=dict, + description="Extra fields merged into the login JSON payload alongside username and " + 'password (e.g. {"tenant": "default"} for a controller that requires it).', + ) diff --git a/test/unit/connection/test_oob_rest_connection_manager.py b/test/unit/connection/test_oob_rest_connection_manager.py new file mode 100644 index 00000000..45dcf247 --- /dev/null +++ b/test/unit/connection/test_oob_rest_connection_manager.py @@ -0,0 +1,118 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +import unittest +from unittest.mock import MagicMock, patch + +from nodescraper.connection.oob_rest import ( + OobRestConnection, + OobRestConnectionManager, + OobRestConnectionParams, +) +from nodescraper.enums import ExecutionStatus, SystemLocation +from nodescraper.models import SystemInfo + + +def _system_info() -> SystemInfo: + return SystemInfo(name="test_system", location=SystemLocation.LOCAL) + + +class TestOobRestConnectionManager(unittest.TestCase): + """Test suite for OobRestConnectionManager.""" + + def test_connect_without_args_fails(self): + """connect() with no connection_args reports EXECUTION_FAILURE.""" + manager = OobRestConnectionManager(system_info=_system_info()) + result = manager.connect() + self.assertEqual(result.status, ExecutionStatus.EXECUTION_FAILURE) + self.assertIsNone(manager.connection) + + @patch("nodescraper.connection.oob_rest.oob_rest_connection.requests.Session") + def test_connect_with_dict_args_authenticates(self, mock_session_cls): + """A dict connection_args is coerced to params and connect() authenticates.""" + session = MagicMock() + mock_session_cls.return_value = session + manager = OobRestConnectionManager( + system_info=_system_info(), + connection_args={ + "host": "10.0.0.1", + "username": "admin", + "password": "secret", + "login_payload_extra": {"tenant": "default"}, + }, + ) + result = manager.connect() + self.assertEqual(result.status, ExecutionStatus.OK) + self.assertIsInstance(manager.connection, OobRestConnection) + + # Login POST issued once, to the configured path, with the extra payload merged in. + session.post.assert_called_once() + call = session.post.call_args + self.assertEqual(call.args[0], "https://10.0.0.1/v1/login") + self.assertEqual(call.kwargs["json"]["tenant"], "default") + self.assertEqual(call.kwargs["json"]["username"], "admin") + self.assertEqual(call.kwargs["json"]["password"], "secret") + + @patch("nodescraper.connection.oob_rest.oob_rest_connection.requests.Session") + def test_connect_with_params_instance(self, mock_session_cls): + """connect() accepts an OobRestConnectionParams instance directly.""" + mock_session_cls.return_value = MagicMock() + params = OobRestConnectionParams(host="10.0.0.2", username="admin", password="pw") + manager = OobRestConnectionManager(system_info=_system_info(), connection_args=params) + result = manager.connect() + self.assertEqual(result.status, ExecutionStatus.OK) + self.assertIsInstance(manager.connection, OobRestConnection) + + @patch("nodescraper.connection.oob_rest.oob_rest_connection.requests.Session") + def test_connect_auth_failure_reports_failure(self, mock_session_cls): + """An auth exception surfaces as EXECUTION_FAILURE and leaves no connection.""" + session = MagicMock() + session.post.side_effect = Exception("401 unauthorized") + mock_session_cls.return_value = session + manager = OobRestConnectionManager( + system_info=_system_info(), + connection_args={"host": "10.0.0.3", "username": "admin", "password": "pw"}, + ) + result = manager.connect() + self.assertEqual(result.status, ExecutionStatus.EXECUTION_FAILURE) + self.assertIsNone(manager.connection) + + @patch("nodescraper.connection.oob_rest.oob_rest_connection.requests.Session") + def test_disconnect_closes_and_clears(self, mock_session_cls): + """disconnect() closes the session and clears the connection.""" + session = MagicMock() + mock_session_cls.return_value = session + manager = OobRestConnectionManager( + system_info=_system_info(), + connection_args={"host": "10.0.0.4", "username": "admin", "password": "pw"}, + ) + manager.connect() + manager.disconnect() + session.close.assert_called_once() + self.assertIsNone(manager.connection) + + +if __name__ == "__main__": + unittest.main()