Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions nodescraper/connection/oob_rest/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
105 changes: 105 additions & 0 deletions nodescraper/connection/oob_rest/oob_rest_connection.py
Original file line number Diff line number Diff line change
@@ -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
121 changes: 121 additions & 0 deletions nodescraper/connection/oob_rest/oob_rest_connection_manager.py
Original file line number Diff line number Diff line change
@@ -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
65 changes: 65 additions & 0 deletions nodescraper/connection/oob_rest/oob_rest_params.py
Original file line number Diff line number Diff line change
@@ -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).',
)
Loading
Loading