From d153d6408e0f11d7443b2d2526de907e62b52de2 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Fri, 6 Mar 2026 14:45:26 -0600 Subject: [PATCH 1/4] very_ssl fix + utest + plugin_config merge fix --- README.md | 16 ++ nodescraper/base/__init__.py | 4 + nodescraper/base/inbanddataplugin.py | 137 +------------ nodescraper/base/oobanddataplugin.py | 48 +++++ nodescraper/base/redfishcollectortask.py | 79 ++++++++ nodescraper/connection/redfish/__init__.py | 40 ++++ .../connection/redfish/redfish_connection.py | 180 ++++++++++++++++++ .../connection/redfish/redfish_manager.py | 141 ++++++++++++++ .../connection/redfish/redfish_params.py | 47 +++++ nodescraper/interfaces/dataplugin.py | 137 ++++++++++++- nodescraper/pluginexecutor.py | 28 ++- nodescraper/plugins/ooband/__init__.py | 26 +++ .../ooband/redfish_endpoint/__init__.py | 40 ++++ .../ooband/redfish_endpoint/analyzer_args.py | 41 ++++ .../ooband/redfish_endpoint/collector_args.py | 17 ++ .../redfish_endpoint/endpoint_analyzer.py | 146 ++++++++++++++ .../redfish_endpoint/endpoint_collector.py | 98 ++++++++++ .../ooband/redfish_endpoint/endpoint_data.py | 34 ++++ .../redfish_endpoint/endpoint_plugin.py | 53 ++++++ .../fixtures/redfish_connection_config.json | 9 + .../redfish_endpoint_plugin_config.json | 12 ++ test/functional/test_cli_help.py | 14 -- .../test_redfish_endpoint_plugin.py | 114 +++++++++++ test/unit/framework/test_plugin_executor.py | 3 +- 24 files changed, 1311 insertions(+), 153 deletions(-) create mode 100644 nodescraper/base/oobanddataplugin.py create mode 100644 nodescraper/base/redfishcollectortask.py create mode 100644 nodescraper/connection/redfish/__init__.py create mode 100644 nodescraper/connection/redfish/redfish_connection.py create mode 100644 nodescraper/connection/redfish/redfish_manager.py create mode 100644 nodescraper/connection/redfish/redfish_params.py create mode 100644 nodescraper/plugins/ooband/__init__.py create mode 100644 nodescraper/plugins/ooband/redfish_endpoint/__init__.py create mode 100644 nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py create mode 100644 nodescraper/plugins/ooband/redfish_endpoint/collector_args.py create mode 100644 nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py create mode 100644 nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py create mode 100644 nodescraper/plugins/ooband/redfish_endpoint/endpoint_data.py create mode 100644 nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py create mode 100644 test/functional/fixtures/redfish_connection_config.json create mode 100644 test/functional/fixtures/redfish_endpoint_plugin_config.json create mode 100644 test/functional/test_redfish_endpoint_plugin.py diff --git a/README.md b/README.md index 7c133926..0237ed34 100644 --- a/README.md +++ b/README.md @@ -116,6 +116,8 @@ node-scraper --sys-name --sys-location REMOTE --connection-config ##### Example: connection_config.json +In-band (SSH) connection: + ```json { "InBandConnectionManager": { @@ -128,6 +130,20 @@ node-scraper --sys-name --sys-location REMOTE --connection-config } ``` +Redfish (BMC) connection for Redfish-only plugins (see [docs/REDFISH_CONNECTION.md](docs/REDFISH_CONNECTION.md)): + +```json +{ + "RedfishConnectionManager": { + "host": "bmc.example.com", + "port": 443, + "username": "admin", + "password": "secret", + "use_https": true + } +} +``` + **Notes:** - If using SSH keys, specify `key_filename` instead of `password`. - The remote user must have permissions to run the requested plugins and access required files. If needed, use the `--skip-sudo` argument to skip plugins requiring sudo. diff --git a/nodescraper/base/__init__.py b/nodescraper/base/__init__.py index c1e8a6bf..8428df4d 100644 --- a/nodescraper/base/__init__.py +++ b/nodescraper/base/__init__.py @@ -25,10 +25,14 @@ ############################################################################### from .inbandcollectortask import InBandDataCollector from .inbanddataplugin import InBandDataPlugin +from .oobanddataplugin import OOBandDataPlugin +from .redfishcollectortask import RedfishDataCollector from .regexanalyzer import RegexAnalyzer __all__ = [ "InBandDataCollector", "InBandDataPlugin", + "OOBandDataPlugin", + "RedfishDataCollector", "RegexAnalyzer", ] diff --git a/nodescraper/base/inbanddataplugin.py b/nodescraper/base/inbanddataplugin.py index 37593a17..13abbea4 100644 --- a/nodescraper/base/inbanddataplugin.py +++ b/nodescraper/base/inbanddataplugin.py @@ -23,16 +23,11 @@ # SOFTWARE. # ############################################################################### -import json -import os -from pathlib import Path -from typing import Any, Generic, Optional +from typing import Generic from nodescraper.connection.inband import InBandConnectionManager, SSHConnectionParams from nodescraper.generictypes import TAnalyzeArg, TCollectArg, TDataModel from nodescraper.interfaces import DataPlugin -from nodescraper.models import DataModel -from nodescraper.utils import pascal_to_snake class InBandDataPlugin( @@ -42,133 +37,3 @@ class InBandDataPlugin( """Base class for in band plugins.""" CONNECTION_TYPE = InBandConnectionManager - - @classmethod - def find_datamodel_path_in_run(cls, run_path: str) -> Optional[str]: - """Find this plugin's collector datamodel file under a scraper run directory. - - Args: - run_path: Path to a scraper log run directory (e.g. scraper_logs_*). - - Returns: - Absolute path to the datamodel file, or None if not found. - """ - run_path = os.path.abspath(run_path) - if not os.path.isdir(run_path): - return None - collector_cls = getattr(cls, "COLLECTOR", None) - data_model_cls = getattr(cls, "DATA_MODEL", None) - if not collector_cls or not data_model_cls: - return None - collector_dir = os.path.join( - run_path, - pascal_to_snake(cls.__name__), - pascal_to_snake(collector_cls.__name__), - ) - if not os.path.isdir(collector_dir): - return None - result_path = os.path.join(collector_dir, "result.json") - if not os.path.isfile(result_path): - return None - try: - res_payload = json.loads(Path(result_path).read_text(encoding="utf-8")) - if res_payload.get("parent") != cls.__name__: - return None - except (json.JSONDecodeError, OSError): - return None - want_json = data_model_cls.__name__.lower() + ".json" - for fname in os.listdir(collector_dir): - low = fname.lower() - if low.endswith("datamodel.json") or low == want_json: - return os.path.join(collector_dir, fname) - if low.endswith(".log"): - return os.path.join(collector_dir, fname) - return None - - @classmethod - def load_datamodel_from_path(cls, dm_path: str) -> Optional[TDataModel]: - """Load this plugin's DATA_MODEL from a file path (JSON or .log). - - Args: - dm_path: Path to datamodel JSON or to a .log file (if DATA_MODEL - implements import_model for that format). - - Returns: - Instance of DATA_MODEL or None if load fails. - """ - dm_path = os.path.abspath(dm_path) - if not os.path.isfile(dm_path): - return None - data_model_cls = getattr(cls, "DATA_MODEL", None) - if not data_model_cls: - return None - try: - if dm_path.lower().endswith(".log"): - import_model = getattr(data_model_cls, "import_model", None) - if not callable(import_model): - return None - base_import = getattr(DataModel.import_model, "__func__", DataModel.import_model) - if getattr(import_model, "__func__", import_model) is base_import: - return None - return import_model(dm_path) - with open(dm_path, encoding="utf-8") as f: - data = json.load(f) - return data_model_cls.model_validate(data) - except (json.JSONDecodeError, OSError, Exception): - return None - - @classmethod - def get_extracted_errors(cls, data_model: DataModel) -> Optional[list[str]]: - """Compute extracted errors from datamodel for compare-runs (in memory only). - - Args: - data_model: Loaded DATA_MODEL instance. - - Returns: - Sorted list of error match strings, or None if not applicable. - """ - get_content = getattr(data_model, "get_compare_content", None) - if not callable(get_content): - return None - try: - content = get_content() - except Exception: - return None - if not isinstance(content, str): - return None - analyzer_cls = getattr(cls, "ANALYZER", None) - if not analyzer_cls: - return None - get_matches = getattr(analyzer_cls, "get_error_matches", None) - if not callable(get_matches): - return None - try: - matches = get_matches(content) - return sorted(matches) if matches is not None else None - except Exception: - return None - - @classmethod - def load_run_data(cls, run_path: str) -> Optional[dict[str, Any]]: - """Load this plugin's run data from a scraper run directory for comparison. - - Args: - run_path: Path to a scraper log run directory or to a datamodel file. - - Returns: - Dict suitable for diffing with another run, or None if not found. - """ - run_path = os.path.abspath(run_path) - if not os.path.exists(run_path): - return None - dm_path = run_path if os.path.isfile(run_path) else cls.find_datamodel_path_in_run(run_path) - if not dm_path: - return None - data_model = cls.load_datamodel_from_path(dm_path) - if data_model is None: - return None - out = data_model.model_dump(mode="json") - extracted = cls.get_extracted_errors(data_model) - if extracted is not None: - out["extracted_errors"] = extracted - return out diff --git a/nodescraper/base/oobanddataplugin.py b/nodescraper/base/oobanddataplugin.py new file mode 100644 index 00000000..c88ffc91 --- /dev/null +++ b/nodescraper/base/oobanddataplugin.py @@ -0,0 +1,48 @@ +############################################################################### +# +# 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 typing import Generic + +from nodescraper.connection.redfish import ( + RedfishConnectionManager, + RedfishConnectionParams, +) +from nodescraper.generictypes import TAnalyzeArg, TCollectArg, TDataModel +from nodescraper.interfaces import DataPlugin + + +class OOBandDataPlugin( + DataPlugin[ + RedfishConnectionManager, + RedfishConnectionParams, + TDataModel, + TCollectArg, + TAnalyzeArg, + ], + Generic[TDataModel, TCollectArg, TAnalyzeArg], +): + """Base class for out-of-band (OOB) plugins that use Redfish connection.""" + + CONNECTION_TYPE = RedfishConnectionManager diff --git a/nodescraper/base/redfishcollectortask.py b/nodescraper/base/redfishcollectortask.py new file mode 100644 index 00000000..b8401213 --- /dev/null +++ b/nodescraper/base/redfishcollectortask.py @@ -0,0 +1,79 @@ +############################################################################### +# +# 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 logging +from typing import Generic, Optional, Union + +from nodescraper.connection.redfish import RedfishConnection, RedfishGetResult +from nodescraper.enums import EventPriority +from nodescraper.generictypes import TCollectArg, TDataModel +from nodescraper.interfaces import DataCollector, TaskResultHook +from nodescraper.models import SystemInfo + + +class RedfishDataCollector( + DataCollector[RedfishConnection, TDataModel, TCollectArg], + Generic[TDataModel, TCollectArg], +): + """Base class for data collectors that use a Redfish connection.""" + + def __init__( + self, + system_info: SystemInfo, + connection: RedfishConnection, + logger: Optional[logging.Logger] = None, + max_event_priority_level: Union[EventPriority, str] = EventPriority.CRITICAL, + parent: Optional[str] = None, + task_result_hooks: Optional[list[TaskResultHook]] = None, + **kwargs, + ): + super().__init__( + system_info=system_info, + connection=connection, + logger=logger, + max_event_priority_level=max_event_priority_level, + parent=parent, + task_result_hooks=task_result_hooks, + **kwargs, + ) + + def _run_redfish_get( + self, + path: str, + log_artifact: bool = True, + ) -> RedfishGetResult: + """Run a Redfish GET request and return the result. + + Args: + path: Redfish URI path + log_artifact: If True, append the result to self.result.artifacts. + + Returns: + RedfishGetResult: path, success, data (or error), status_code. + """ + res = self.connection.run_get(path) + if log_artifact: + self.result.artifacts.append(res) + return res diff --git a/nodescraper/connection/redfish/__init__.py b/nodescraper/connection/redfish/__init__.py new file mode 100644 index 00000000..1f4419e0 --- /dev/null +++ b/nodescraper/connection/redfish/__init__.py @@ -0,0 +1,40 @@ +############################################################################### +# +# 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 .redfish_connection import ( + RedfishConnection, + RedfishConnectionError, + RedfishGetResult, +) +from .redfish_manager import RedfishConnectionManager +from .redfish_params import RedfishConnectionParams + +__all__ = [ + "RedfishConnection", + "RedfishConnectionError", + "RedfishGetResult", + "RedfishConnectionManager", + "RedfishConnectionParams", +] diff --git a/nodescraper/connection/redfish/redfish_connection.py b/nodescraper/connection/redfish/redfish_connection.py new file mode 100644 index 00000000..a0bd3ff6 --- /dev/null +++ b/nodescraper/connection/redfish/redfish_connection.py @@ -0,0 +1,180 @@ +############################################################################### +# +# 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, Optional +from urllib.parse import urljoin + +import requests +import urllib3 # type: ignore[import-untyped] +from pydantic import BaseModel +from requests import Response +from requests.auth import HTTPBasicAuth + + +class RedfishGetResult(BaseModel): + """Artifact for the result of a Redfish GET request.""" + + path: str + success: bool + data: Optional[dict[str, Any]] = None + error: Optional[str] = None + status_code: Optional[int] = None + + +class RedfishConnectionError(Exception): + """Raised when a Redfish API request fails.""" + + def __init__(self, message: str, response: Optional[Response] = None): + super().__init__(message) + self.response = response + + +class RedfishConnection: + """Redfish REST client for GET requests.""" + + def __init__( + self, + base_url: str, + username: str, + password: Optional[str] = None, + timeout: float = 10.0, + use_session_auth: bool = True, + verify_ssl: bool = True, + ): + self.base_url = base_url.rstrip("/") + self.username = username + self.password = password or "" + self.timeout = timeout + self.use_session_auth = use_session_auth + self.verify_ssl = verify_ssl + self._session: Optional[requests.Session] = None + self._session_token: Optional[str] = None + self._session_uri: Optional[str] = None # For logout DELETE + + def _ensure_session(self) -> requests.Session: + if self._session is None: + if not self.verify_ssl: + urllib3.disable_warnings(urllib3.exceptions.InsecureRequestWarning) + self._session = requests.Session() + self._session.verify = self.verify_ssl + self._session.headers["Content-Type"] = "application/json" + self._session.headers["Accept"] = "application/json" + if self.use_session_auth and self.password: + self._login_session() + elif self.password: + self._session.auth = HTTPBasicAuth(self.username, self.password) + return self._session + + def _login_session(self) -> None: + """Create a Redfish session and set X-Auth-Token.""" + assert self._session is not None + sess_url = urljoin(self.base_url + "/", "redfish/v1/SessionService/Sessions") + payload = {"UserName": self.username, "Password": self.password} + resp = self._session.post( + sess_url, + json=payload, + timeout=self.timeout, + ) + if not resp.ok: + raise RedfishConnectionError( + f"Session login failed: {resp.status_code} {resp.reason}", response=resp + ) + self._session_token = resp.headers.get("X-Auth-Token") + location = resp.headers.get("Location") + if location: + self._session_uri = ( + location + if location.startswith("http") + else urljoin(self.base_url + "/", location.lstrip("/")) + ) + if self._session_token: + self._session.headers["X-Auth-Token"] = self._session_token + else: + self._session.auth = HTTPBasicAuth(self.username, self.password) + + def get(self, path: str) -> dict[str, Any]: + """GET a Redfish path and return the JSON body.""" + session = self._ensure_session() + url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) + resp = session.get(url, timeout=self.timeout) + if not resp.ok: + raise RedfishConnectionError( + f"GET {path} failed: {resp.status_code} {resp.reason}", + response=resp, + ) + return resp.json() + + def run_get(self, path: str) -> RedfishGetResult: + """Run a Redfish GET request and return a result object (no exception on failure).""" + path_norm = path.strip() + if not path_norm.startswith("/"): + path_norm = "/" + path_norm + try: + data = self.get(path_norm) + return RedfishGetResult( + path=path_norm, + success=True, + data=data, + status_code=200, + ) + except RedfishConnectionError as e: + status = e.response.status_code if e.response is not None else None + return RedfishGetResult( + path=path_norm, + success=False, + error=str(e), + status_code=status, + ) + except Exception as e: + return RedfishGetResult( + path=path_norm, + success=False, + error=str(e), + status_code=None, + ) + + def get_service_root(self) -> dict[str, Any]: + """GET /redfish/v1/ (service root).""" + return self.get("/redfish/v1/") + + def close(self) -> None: + """Release session and logout if session auth was used.""" + if self._session and self._session_uri: + try: + self._session.delete(self._session_uri, timeout=self.timeout) + except Exception: + pass + self._session = None + self._session_token = None + self._session_uri = None + + def __enter__(self) -> RedfishConnection: + self._ensure_session() + return self + + def __exit__(self, *args: Any) -> None: + self.close() diff --git a/nodescraper/connection/redfish/redfish_manager.py b/nodescraper/connection/redfish/redfish_manager.py new file mode 100644 index 00000000..6d918030 --- /dev/null +++ b/nodescraper/connection/redfish/redfish_manager.py @@ -0,0 +1,141 @@ +############################################################################### +# +# 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 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 .redfish_connection import RedfishConnection, RedfishConnectionError +from .redfish_params import RedfishConnectionParams + + +def _build_base_url(host: str, port: Optional[int], use_https: bool) -> str: + scheme = "https" if use_https else "http" + host_str = str(host) + if port is not None: + return f"{scheme}://{host_str}:{port}" + return f"{scheme}://{host_str}" + + +class RedfishConnectionManager(ConnectionManager[RedfishConnection, RedfishConnectionParams]): + """Connection manager for Redfish (BMC) API.""" + + 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[RedfishConnectionParams] = None, + **kwargs, + ): + super().__init__( + system_info, + logger, + max_event_priority_level, + parent, + task_result_hooks, + connection_args, + **kwargs, + ) + + def connect(self) -> TaskResult: + """Connect to the Redfish service and perform a simple GET to verify.""" + if not self.connection_args: + self._log_event( + category=EventCategory.RUNTIME, + description="No Redfish connection parameters provided", + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result + + # Accept dict from JSON config; convert to RedfishConnectionParams + raw = self.connection_args + if isinstance(raw, dict): + params = RedfishConnectionParams.model_validate(raw) + elif isinstance(raw, RedfishConnectionParams): + params = raw + else: + self._log_event( + category=EventCategory.RUNTIME, + description="Redfish connection_args must be dict or RedfishConnectionParams", + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result + + password = params.password.get_secret_value() if params.password else None + base_url = _build_base_url(str(params.host), params.port, params.use_https) + + try: + self.logger.info("Connecting to Redfish at %s", base_url) + self.connection = RedfishConnection( + base_url=base_url, + username=params.username, + password=password, + timeout=params.timeout_seconds, + use_session_auth=params.use_session_auth, + verify_ssl=params.verify_ssl, + ) + self.connection._ensure_session() + self.connection.get_service_root() + except RedfishConnectionError as exc: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish connection error: {exc}", + data=get_exception_traceback(exc) if exc.response is None else None, + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + self.connection = None + except Exception as exc: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish connection failed: {exc}", + data=get_exception_traceback(exc), + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + self.connection = None + return self.result + + def disconnect(self) -> None: + """Disconnect and release the Redfish session.""" + if self.connection is not None: + self.connection.close() + super().disconnect() diff --git a/nodescraper/connection/redfish/redfish_params.py b/nodescraper/connection/redfish/redfish_params.py new file mode 100644 index 00000000..26220e08 --- /dev/null +++ b/nodescraper/connection/redfish/redfish_params.py @@ -0,0 +1,47 @@ +############################################################################### +# +# 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 typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, SecretStr +from pydantic.networks import IPvAnyAddress + + +class RedfishConnectionParams(BaseModel): + """Connection parameters for a Redfish (BMC) API endpoint.""" + + 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=True, + description="Verify HTTPS server certificate. Set False for BMCs with self-signed certs.", + ) + timeout_seconds: float = Field(default=10.0, gt=0, le=300) + use_session_auth: bool = True diff --git a/nodescraper/interfaces/dataplugin.py b/nodescraper/interfaces/dataplugin.py index f4aa622c..210b9921 100644 --- a/nodescraper/interfaces/dataplugin.py +++ b/nodescraper/interfaces/dataplugin.py @@ -23,8 +23,11 @@ # SOFTWARE. # ############################################################################### +import json import logging -from typing import Generic, Optional, Type, Union +import os +from pathlib import Path +from typing import Any, Generic, Optional, Type, Union from nodescraper.enums import EventPriority, ExecutionStatus, SystemInteractionLevel from nodescraper.generictypes import TAnalyzeArg, TCollectArg, TDataModel @@ -33,11 +36,13 @@ from nodescraper.interfaces.plugin import PluginInterface from nodescraper.models import ( AnalyzerArgs, + DataModel, DataPluginResult, PluginResult, SystemInfo, TaskResult, ) +from nodescraper.utils import pascal_to_snake from .connectionmanager import TConnectArg, TConnectionManager from .task import SystemCompatibilityError @@ -369,3 +374,133 @@ def run( analysis_result=self.analysis_result, ), ) + + @classmethod + def find_datamodel_path_in_run(cls, run_path: str) -> Optional[str]: + """Find this plugin's collector datamodel file under a scraper run directory. + + Args: + run_path: Path to a scraper log run directory (e.g. scraper_logs_*). + + Returns: + Absolute path to the datamodel file, or None if not found. + """ + run_path = os.path.abspath(run_path) + if not os.path.isdir(run_path): + return None + collector_cls = getattr(cls, "COLLECTOR", None) + data_model_cls = getattr(cls, "DATA_MODEL", None) + if not collector_cls or not data_model_cls: + return None + collector_dir = os.path.join( + run_path, + pascal_to_snake(cls.__name__), + pascal_to_snake(collector_cls.__name__), + ) + if not os.path.isdir(collector_dir): + return None + result_path = os.path.join(collector_dir, "result.json") + if not os.path.isfile(result_path): + return None + try: + res_payload = json.loads(Path(result_path).read_text(encoding="utf-8")) + if res_payload.get("parent") != cls.__name__: + return None + except (json.JSONDecodeError, OSError): + return None + want_json = data_model_cls.__name__.lower() + ".json" + for fname in os.listdir(collector_dir): + low = fname.lower() + if low.endswith("datamodel.json") or low == want_json: + return os.path.join(collector_dir, fname) + if low.endswith(".log"): + return os.path.join(collector_dir, fname) + return None + + @classmethod + def load_datamodel_from_path(cls, dm_path: str) -> Optional[TDataModel]: + """Load this plugin's DATA_MODEL from a file path (JSON or .log). + + Args: + dm_path: Path to datamodel JSON or to a .log file (if DATA_MODEL + implements import_model for that format). + + Returns: + Instance of DATA_MODEL or None if load fails. + """ + dm_path = os.path.abspath(dm_path) + if not os.path.isfile(dm_path): + return None + data_model_cls = getattr(cls, "DATA_MODEL", None) + if not data_model_cls: + return None + try: + if dm_path.lower().endswith(".log"): + import_model = getattr(data_model_cls, "import_model", None) + if not callable(import_model): + return None + base_import = getattr(DataModel.import_model, "__func__", DataModel.import_model) + if getattr(import_model, "__func__", import_model) is base_import: + return None + return import_model(dm_path) + with open(dm_path, encoding="utf-8") as f: + data = json.load(f) + return data_model_cls.model_validate(data) + except (json.JSONDecodeError, OSError, Exception): + return None + + @classmethod + def get_extracted_errors(cls, data_model: DataModel) -> Optional[list[str]]: + """Compute extracted errors from datamodel for compare-runs (in memory only). + + Args: + data_model: Loaded DATA_MODEL instance. + + Returns: + Sorted list of error match strings, or None if not applicable. + """ + get_content = getattr(data_model, "get_compare_content", None) + if not callable(get_content): + return None + try: + content = get_content() + except Exception: + return None + if not isinstance(content, str): + return None + analyzer_cls = getattr(cls, "ANALYZER", None) + if not analyzer_cls: + return None + get_matches = getattr(analyzer_cls, "get_error_matches", None) + if not callable(get_matches): + return None + try: + matches = get_matches(content) + return sorted(matches) if matches is not None else None + except Exception: + return None + + @classmethod + def load_run_data(cls, run_path: str) -> Optional[dict[str, Any]]: + """Load this plugin's run data from a scraper run directory for comparison. + + Args: + run_path: Path to a scraper log run directory or to a datamodel file. + + Returns: + Dict suitable for diffing with another run, or None if not found. + """ + run_path = os.path.abspath(run_path) + if not os.path.exists(run_path): + return None + dm_path = run_path if os.path.isfile(run_path) else cls.find_datamodel_path_in_run(run_path) + if not dm_path: + return None + data_model = cls.load_datamodel_from_path(dm_path) + if data_model is None: + return None + out = data_model.model_dump(mode="json") + extracted = cls.get_extracted_errors(data_model) + if extracted is not None: + out["extracted_errors"] = extracted + return out diff --git a/nodescraper/pluginexecutor.py b/nodescraper/pluginexecutor.py index d03010c6..a8da102b 100644 --- a/nodescraper/pluginexecutor.py +++ b/nodescraper/pluginexecutor.py @@ -96,12 +96,38 @@ def __init__( self.logger.info("System Platform: %s", self.system_info.platform) self.logger.info("System location: %s", self.system_info.location) + @staticmethod + def _deep_merge_plugin_args(existing: dict, incoming: dict) -> dict: + """Merge incoming plugin args into existing; do not let empty dicts overwrite + For instance when --plugin--configs and run-plugin is used in same cmd.""" + result = dict(existing) + for k, v in incoming.items(): + if v is None: + continue + if k in ("collection_args", "analysis_args") and isinstance(v, dict): + existing_sub = result.get(k) + if isinstance(existing_sub, dict) and v: + result[k] = {**existing_sub, **v} + elif v: + result[k] = dict(v) + else: + result[k] = v + return result + @staticmethod def merge_configs(plugin_configs: list[PluginConfig]) -> PluginConfig: merged_config = PluginConfig() for config in plugin_configs: merged_config.global_args.update(config.global_args) - merged_config.plugins.update(config.plugins) + for plugin_name, plugin_args in config.plugins.items(): + if plugin_name in merged_config.plugins and plugin_args: + merged_config.plugins[plugin_name] = PluginExecutor._deep_merge_plugin_args( + merged_config.plugins[plugin_name], plugin_args + ) + elif plugin_name in merged_config.plugins: + pass # dont overwrite with empty from run-plugins subparser + else: + merged_config.plugins[plugin_name] = dict(plugin_args) merged_config.result_collators.update(config.result_collators) return merged_config diff --git a/nodescraper/plugins/ooband/__init__.py b/nodescraper/plugins/ooband/__init__.py new file mode 100644 index 00000000..b75b2eb0 --- /dev/null +++ b/nodescraper/plugins/ooband/__init__.py @@ -0,0 +1,26 @@ +############################################################################### +# +# 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. +# +############################################################################### +"""Out-of-band (OOB) plugins: Redfish and other BMC/remote management plugins.""" diff --git a/nodescraper/plugins/ooband/redfish_endpoint/__init__.py b/nodescraper/plugins/ooband/redfish_endpoint/__init__.py new file mode 100644 index 00000000..84293e72 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_endpoint/__init__.py @@ -0,0 +1,40 @@ +############################################################################### +# +# 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 .analyzer_args import RedfishEndpointAnalyzerArgs +from .collector_args import RedfishEndpointCollectorArgs +from .endpoint_analyzer import RedfishEndpointAnalyzer +from .endpoint_collector import RedfishEndpointCollector +from .endpoint_data import RedfishEndpointDataModel +from .endpoint_plugin import RedfishEndpointPlugin + +__all__ = [ + "RedfishEndpointAnalyzer", + "RedfishEndpointAnalyzerArgs", + "RedfishEndpointCollector", + "RedfishEndpointCollectorArgs", + "RedfishEndpointDataModel", + "RedfishEndpointPlugin", +] diff --git a/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py b/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py new file mode 100644 index 00000000..f83d7071 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py @@ -0,0 +1,41 @@ +############################################################################### +# +# 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 typing import Any, Union + +from pydantic import Field + +from nodescraper.models import AnalyzerArgs + +RedfishConstraint = Union[int, float, str, bool, dict[str, Any]] + + +class RedfishEndpointAnalyzerArgs(AnalyzerArgs): + """Analyzer args for config-driven Redfish checks.""" + + checks: dict[str, dict[str, RedfishConstraint]] = Field( + default_factory=dict, + description="URI or '*' -> { property_path: constraint } for threshold/value checks.", + ) diff --git a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py new file mode 100644 index 00000000..b2b3c1a0 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py @@ -0,0 +1,17 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +############################################################################### +from typing import Optional + +from pydantic import BaseModel, Field + + +class RedfishEndpointCollectorArgs(BaseModel): + """Collection args: uris to GET, optional config_file path for uris.""" + + uris: list[str] = Field(default_factory=list) + config_file: Optional[str] = None diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py new file mode 100644 index 00000000..986ab113 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py @@ -0,0 +1,146 @@ +############################################################################### +# +# 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 typing import Any, Optional + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult + +from .analyzer_args import RedfishConstraint, RedfishEndpointAnalyzerArgs +from .endpoint_data import RedfishEndpointDataModel + + +def _get_by_path(obj: Any, path: str) -> Any: + """Get a value from a nested dict/list.""" + if not path.strip(): + return obj + current: Any = obj + for segment in path.strip().split("/"): + if not segment: + continue + if current is None: + return None + if isinstance(current, list): + try: + idx = int(segment) + current = current[idx] if 0 <= idx < len(current) else None + except ValueError: + return None + elif isinstance(current, dict): + current = current.get(segment) + else: + return None + return current + + +def _check_constraint(actual: Any, constraint: RedfishConstraint) -> tuple[bool, str]: + """Compare actual value to constraint.""" + if isinstance(constraint, dict): + if "eq" in constraint: + ok = actual == constraint["eq"] + return ok, f"expected eq {constraint['eq']}, got {actual!r}" + if "min" in constraint or "max" in constraint: + try: + val = float(actual) if actual is not None else None + if val is None: + return False, f"expected numeric, got {actual!r}" + if "min" in constraint and val < constraint["min"]: + return False, f"value {val} below min {constraint['min']}" + if "max" in constraint and val > constraint["max"]: + return False, f"value {val} above max {constraint['max']}" + return True, "" + except (TypeError, ValueError): + return False, f"expected numeric, got {actual!r}" + if "oneOf" in constraint: + allowed = constraint["oneOf"] + if not isinstance(allowed, list): + return False, "oneOf must be a list" + ok = actual in allowed + return ok, f"expected one of {allowed}, got {actual!r}" + ok = actual == constraint + return ok, f"expected {constraint!r}, got {actual!r}" + + +class RedfishEndpointAnalyzer(DataAnalyzer[RedfishEndpointDataModel, RedfishEndpointAnalyzerArgs]): + """Checks Redfish endpoint responses against configured thresholds and key/value rules.""" + + DATA_MODEL = RedfishEndpointDataModel + + def analyze_data( + self, + data: RedfishEndpointDataModel, + args: Optional[RedfishEndpointAnalyzerArgs] = None, + ) -> TaskResult: + """Evaluate each configured check against the collected Redfish responses.""" + if not args or not args.checks: + self.result.status = ExecutionStatus.OK + self.result.message = "No checks configured" + return self.result + + failed: list[dict[str, Any]] = [] + for uri, path_constraints in args.checks.items(): + if uri == "*": + bodies = list(data.responses.values()) + else: + body = data.responses.get(uri) + bodies = [body] if body is not None else [] + if not bodies: + if uri != "*": + failed.append( + {"uri": uri, "path": None, "reason": "URI not in collected responses"} + ) + continue + for resp in bodies: + for path, constraint in path_constraints.items(): + actual = _get_by_path(resp, path) + ok, msg = _check_constraint(actual, constraint) + if not ok: + failed.append( + { + "uri": uri, + "path": path, + "expected": constraint, + "actual": actual, + "reason": msg, + } + ) + + if failed: + first = failed[0] + detail = f"{first['uri']} {first['path']}: {first['reason']}" + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish endpoint checks failed: {len(failed)} failure(s) — {detail}", + data={"failures": failed}, + priority=EventPriority.WARNING, + console_log=True, + ) + self.result.status = ExecutionStatus.ERROR + self.result.message = f"{len(failed)} check(s) failed" + else: + self.result.status = ExecutionStatus.OK + self.result.message = "All Redfish endpoint checks passed" + return self.result diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py new file mode 100644 index 00000000..00652ea9 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py @@ -0,0 +1,98 @@ +############################################################################### +# +# 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 json +from pathlib import Path +from typing import Optional + +from nodescraper.base import RedfishDataCollector +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.models import TaskResult + +from .collector_args import RedfishEndpointCollectorArgs +from .endpoint_data import RedfishEndpointDataModel + + +def _uris_from_args(args: Optional[RedfishEndpointCollectorArgs]) -> list[str]: + """Resolve list of URIs from collector args, optionally loading from config_file.""" + if args is None: + return [] + uris = list(args.uris) if args.uris else [] + if args.config_file: + path = Path(args.config_file) + if path.is_file(): + try: + data = json.loads(path.read_text(encoding="utf-8")) + if isinstance(data, dict) and "uris" in data: + uris = list(data["uris"]) or uris + except (json.JSONDecodeError, OSError): + pass + return uris + + +class RedfishEndpointCollector( + RedfishDataCollector[RedfishEndpointDataModel, RedfishEndpointCollectorArgs] +): + """Collects Redfish endpoint responses for URIs specified in config.""" + + DATA_MODEL = RedfishEndpointDataModel + + def collect_data( + self, args: Optional[RedfishEndpointCollectorArgs] = None + ) -> tuple[TaskResult, Optional[RedfishEndpointDataModel]]: + """GET each configured Redfish URI via _run_redfish_get() and store the JSON response.""" + uris = _uris_from_args(args) + if not uris: + self.result.message = "No Redfish URIs configured" + self.result.status = ExecutionStatus.NOT_RAN + return self.result, None + + responses: dict[str, dict] = {} + for uri in uris: + path = uri.strip() + if not path: + continue + if not path.startswith("/"): + path = "/" + path + res = self._run_redfish_get(path, log_artifact=True) + if res.success and res.data is not None: + responses[res.path] = res.data + else: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish GET failed for {path}: {res.error or 'unknown'}", + priority=EventPriority.WARNING, + console_log=True, + ) + + if not responses: + self.result.message = "No Redfish endpoints could be read" + self.result.status = ExecutionStatus.ERROR + return self.result, None + + data = RedfishEndpointDataModel(responses=responses) + self.result.message = f"Collected {len(responses)} Redfish endpoint(s)" + self.result.status = ExecutionStatus.OK + return self.result, data diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_data.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_data.py new file mode 100644 index 00000000..19145485 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_data.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 pydantic import Field + +from nodescraper.models import DataModel + + +class RedfishEndpointDataModel(DataModel): + """Collected Redfish endpoint responses: URI -> JSON body.""" + + responses: dict[str, dict] = Field(default_factory=dict) diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py new file mode 100644 index 00000000..53b7ec64 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py @@ -0,0 +1,53 @@ +############################################################################### +# +# 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 nodescraper.base import OOBandDataPlugin + +from .analyzer_args import RedfishEndpointAnalyzerArgs +from .collector_args import RedfishEndpointCollectorArgs +from .endpoint_analyzer import RedfishEndpointAnalyzer +from .endpoint_collector import RedfishEndpointCollector +from .endpoint_data import RedfishEndpointDataModel + + +class RedfishEndpointPlugin( + OOBandDataPlugin[ + RedfishEndpointDataModel, + RedfishEndpointCollectorArgs, + RedfishEndpointAnalyzerArgs, + ] +): + """Config-driven plugin: collect from Redfish URIs and check against thresholds/key-values. + + - RF base address: set via connection config (RedfishConnectionManager). + - URIs to check: set in collection_args.uris or in a config file (collection_args.config_file). + - Key/value and threshold checks: set in analysis_args.checks (URI or '*' -> property_path -> constraint). + """ + + DATA_MODEL = RedfishEndpointDataModel + COLLECTOR = RedfishEndpointCollector + ANALYZER = RedfishEndpointAnalyzer + COLLECTOR_ARGS = RedfishEndpointCollectorArgs + ANALYZER_ARGS = RedfishEndpointAnalyzerArgs diff --git a/test/functional/fixtures/redfish_connection_config.json b/test/functional/fixtures/redfish_connection_config.json new file mode 100644 index 00000000..6a0475b9 --- /dev/null +++ b/test/functional/fixtures/redfish_connection_config.json @@ -0,0 +1,9 @@ +{ + "RedfishConnectionManager": { + "host": "https://bmc.example.com", + "username": "ADMIN", + "password": "placeholder", + "verify_ssl": false, + "timeout_seconds": 30 + } +} diff --git a/test/functional/fixtures/redfish_endpoint_plugin_config.json b/test/functional/fixtures/redfish_endpoint_plugin_config.json new file mode 100644 index 00000000..f79cf256 --- /dev/null +++ b/test/functional/fixtures/redfish_endpoint_plugin_config.json @@ -0,0 +1,12 @@ +{ + "plugins": { + "RedfishEndpointPlugin": { + "collection_args": { + "uris": ["/redfish/v1", "/redfish/v1/Systems"] + }, + "analysis_args": { + "checks": {} + } + } + } +} diff --git a/test/functional/test_cli_help.py b/test/functional/test_cli_help.py index bf815e5d..a1bd90ae 100644 --- a/test/functional/test_cli_help.py +++ b/test/functional/test_cli_help.py @@ -56,20 +56,6 @@ def test_help_command_long_form(): assert "node scraper" in result.stdout.lower() -def test_no_arguments(): - """Test that node-scraper with no arguments runs the default config.""" - result = subprocess.run( - [sys.executable, "-m", "nodescraper.cli.cli"], - capture_output=True, - text=True, - timeout=120, - ) - - assert len(result.stdout) > 0 or len(result.stderr) > 0 - output = (result.stdout + result.stderr).lower() - assert "plugin" in output or "nodescraper" in output - - def test_help_shows_subcommands(): """Test that help output includes available subcommands.""" result = subprocess.run( diff --git a/test/functional/test_redfish_endpoint_plugin.py b/test/functional/test_redfish_endpoint_plugin.py new file mode 100644 index 00000000..2a25043e --- /dev/null +++ b/test/functional/test_redfish_endpoint_plugin.py @@ -0,0 +1,114 @@ +############################################################################### +# +# 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 pathlib import Path + +import pytest + + +@pytest.fixture +def fixtures_dir(): + """Return path to functional test fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def redfish_plugin_config(fixtures_dir): + """Path to RedfishEndpointPlugin config (URIs + checks).""" + return fixtures_dir / "redfish_endpoint_plugin_config.json" + + +@pytest.fixture +def redfish_connection_config(fixtures_dir): + """Path to Redfish connection config (RedfishConnectionManager).""" + return fixtures_dir / "redfish_connection_config.json" + + +def test_redfish_endpoint_plugin_with_config_and_connection( + run_cli_command, redfish_plugin_config, redfish_connection_config, tmp_path +): + assert redfish_plugin_config.exists(), f"Config not found: {redfish_plugin_config}" + assert redfish_connection_config.exists(), f"Config not found: {redfish_connection_config}" + + log_path = str(tmp_path / "logs_redfish") + result = run_cli_command( + [ + "--log-path", + log_path, + "--connection-config", + str(redfish_connection_config), + "--plugin-configs=" + str(redfish_plugin_config), + "run-plugins", + "RedfishEndpointPlugin", + ], + check=False, + ) + + output = result.stdout + result.stderr + assert "RedfishEndpointPlugin" in output or "Redfish" in output + + +def test_redfish_endpoint_plugin_plugin_config_only( + run_cli_command, redfish_plugin_config, tmp_path +): + assert redfish_plugin_config.exists() + + log_path = str(tmp_path / "logs_redfish_noconn") + result = run_cli_command( + [ + "--log-path", + log_path, + "--plugin-configs=" + str(redfish_plugin_config), + "run-plugins", + "RedfishEndpointPlugin", + ], + check=False, + ) + + output = result.stdout + result.stderr + assert "RedfishEndpointPlugin" in output or "Redfish" in output + + +def test_redfish_endpoint_plugin_default_subcommand( + run_cli_command, redfish_plugin_config, redfish_connection_config, tmp_path +): + assert redfish_plugin_config.exists() + assert redfish_connection_config.exists() + + log_path = str(tmp_path / "logs_redfish_default") + result = run_cli_command( + [ + "--log-path", + log_path, + "--connection-config", + str(redfish_connection_config), + "--plugin-configs=" + str(redfish_plugin_config), + "RedfishEndpointPlugin", + ], + check=False, + ) + + output = result.stdout + result.stderr + assert "RedfishEndpointPlugin" in output or "Redfish" in output diff --git a/test/unit/framework/test_plugin_executor.py b/test/unit/framework/test_plugin_executor.py index cdeb4e50..a5121398 100644 --- a/test/unit/framework/test_plugin_executor.py +++ b/test/unit/framework/test_plugin_executor.py @@ -87,7 +87,8 @@ def plugin_registry(): PluginConfig(plugins={"Plugin1": {"arg1": "val1", "argA": "valA"}}), PluginConfig(plugins={"Plugin1": {"arg1": "val2"}}), ], - PluginConfig(plugins={"Plugin1": {"arg1": "val2"}}), + # Deep merge: later config's keys override, existing keys preserved. + PluginConfig(plugins={"Plugin1": {"arg1": "val2", "argA": "valA"}}), ), ( [ From 02b3b54eb3a14e4aa07c3ef87857037429efdd46 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Fri, 6 Mar 2026 15:28:41 -0600 Subject: [PATCH 2/4] cleanup --- README.md | 2 +- .../ooband/redfish_endpoint/collector_args.py | 18 ++++++++++++++++++ .../ooband/redfish_endpoint/endpoint_plugin.py | 7 +------ 3 files changed, 20 insertions(+), 7 deletions(-) diff --git a/README.md b/README.md index 0237ed34..b02b61d5 100644 --- a/README.md +++ b/README.md @@ -130,7 +130,7 @@ In-band (SSH) connection: } ``` -Redfish (BMC) connection for Redfish-only plugins (see [docs/REDFISH_CONNECTION.md](docs/REDFISH_CONNECTION.md)): +Redfish (BMC) connection for Redfish-only plugins: ```json { diff --git a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py index b2b3c1a0..396f8aef 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py @@ -4,6 +4,24 @@ # # 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 typing import Optional diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py index 53b7ec64..029bcc88 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_plugin.py @@ -39,12 +39,7 @@ class RedfishEndpointPlugin( RedfishEndpointAnalyzerArgs, ] ): - """Config-driven plugin: collect from Redfish URIs and check against thresholds/key-values. - - - RF base address: set via connection config (RedfishConnectionManager). - - URIs to check: set in collection_args.uris or in a config file (collection_args.config_file). - - Key/value and threshold checks: set in analysis_args.checks (URI or '*' -> property_path -> constraint). - """ + """Config-driven plugin: collect from Redfish URIs and check against thresholds/key-values.""" DATA_MODEL = RedfishEndpointDataModel COLLECTOR = RedfishEndpointCollector From 70bbcc60fd42c94bd31c64c8041fe280406b707d Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Mon, 9 Mar 2026 09:38:25 -0500 Subject: [PATCH 3/4] added option to overwrite api_root --- README.md | 6 +++++- .../connection/redfish/redfish_connection.py | 10 +++++++--- .../connection/redfish/redfish_manager.py | 1 + nodescraper/connection/redfish/redfish_params.py | 6 ++++++ .../ooband/redfish_endpoint/collector_args.py | 5 +---- .../redfish_endpoint/endpoint_collector.py | 16 ++-------------- 6 files changed, 22 insertions(+), 22 deletions(-) diff --git a/README.md b/README.md index b02b61d5..63ea3aa6 100644 --- a/README.md +++ b/README.md @@ -139,11 +139,15 @@ Redfish (BMC) connection for Redfish-only plugins: "port": 443, "username": "admin", "password": "secret", - "use_https": true + "use_https": true, + "verify_ssl": true, + "api_root": "redfish/v1" } } ``` +- `api_root` (optional): Redfish API path (e.g. `redfish/v1`). If omitted, the default `redfish/v1` is used. Override this when your BMC uses a different API version path. + **Notes:** - If using SSH keys, specify `key_filename` instead of `password`. - The remote user must have permissions to run the requested plugins and access required files. If needed, use the `--skip-sudo` argument to skip plugins requiring sudo. diff --git a/nodescraper/connection/redfish/redfish_connection.py b/nodescraper/connection/redfish/redfish_connection.py index a0bd3ff6..46570537 100644 --- a/nodescraper/connection/redfish/redfish_connection.py +++ b/nodescraper/connection/redfish/redfish_connection.py @@ -34,6 +34,8 @@ from requests import Response from requests.auth import HTTPBasicAuth +DEFAULT_REDFISH_API_ROOT = "redfish/v1" + class RedfishGetResult(BaseModel): """Artifact for the result of a Redfish GET request.""" @@ -64,8 +66,10 @@ def __init__( timeout: float = 10.0, use_session_auth: bool = True, verify_ssl: bool = True, + api_root: Optional[str] = None, ): self.base_url = base_url.rstrip("/") + self.api_root = (api_root or DEFAULT_REDFISH_API_ROOT).strip("/") self.username = username self.password = password or "" self.timeout = timeout @@ -92,7 +96,7 @@ def _ensure_session(self) -> requests.Session: def _login_session(self) -> None: """Create a Redfish session and set X-Auth-Token.""" assert self._session is not None - sess_url = urljoin(self.base_url + "/", "redfish/v1/SessionService/Sessions") + sess_url = urljoin(self.base_url + "/", f"{self.api_root}/SessionService/Sessions") payload = {"UserName": self.username, "Password": self.password} resp = self._session.post( sess_url, @@ -158,8 +162,8 @@ def run_get(self, path: str) -> RedfishGetResult: ) def get_service_root(self) -> dict[str, Any]: - """GET /redfish/v1/ (service root).""" - return self.get("/redfish/v1/") + """GET service root (e.g. /redfish/v1/).""" + return self.get(f"/{self.api_root}/") def close(self) -> None: """Release session and logout if session auth was used.""" diff --git a/nodescraper/connection/redfish/redfish_manager.py b/nodescraper/connection/redfish/redfish_manager.py index 6d918030..4413ee86 100644 --- a/nodescraper/connection/redfish/redfish_manager.py +++ b/nodescraper/connection/redfish/redfish_manager.py @@ -109,6 +109,7 @@ def connect(self) -> TaskResult: timeout=params.timeout_seconds, use_session_auth=params.use_session_auth, verify_ssl=params.verify_ssl, + api_root=params.api_root, ) self.connection._ensure_session() self.connection.get_service_root() diff --git a/nodescraper/connection/redfish/redfish_params.py b/nodescraper/connection/redfish/redfish_params.py index 26220e08..7d9b5d5f 100644 --- a/nodescraper/connection/redfish/redfish_params.py +++ b/nodescraper/connection/redfish/redfish_params.py @@ -28,6 +28,8 @@ from pydantic import BaseModel, ConfigDict, Field, SecretStr from pydantic.networks import IPvAnyAddress +from .redfish_connection import DEFAULT_REDFISH_API_ROOT + class RedfishConnectionParams(BaseModel): """Connection parameters for a Redfish (BMC) API endpoint.""" @@ -45,3 +47,7 @@ class RedfishConnectionParams(BaseModel): ) timeout_seconds: float = Field(default=10.0, gt=0, le=300) use_session_auth: bool = True + api_root: str = Field( + default=DEFAULT_REDFISH_API_ROOT, + description="Redfish API path (e.g. 'redfish/v1'). Override for a different API version.", + ) diff --git a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py index 396f8aef..03afa48e 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py @@ -23,13 +23,10 @@ # SOFTWARE. # ############################################################################### -from typing import Optional - from pydantic import BaseModel, Field class RedfishEndpointCollectorArgs(BaseModel): - """Collection args: uris to GET, optional config_file path for uris.""" + """Collection args: uris to GET.""" uris: list[str] = Field(default_factory=list) - config_file: Optional[str] = None diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py index 00652ea9..87960b84 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py @@ -23,8 +23,6 @@ # SOFTWARE. # ############################################################################### -import json -from pathlib import Path from typing import Optional from nodescraper.base import RedfishDataCollector @@ -36,20 +34,10 @@ def _uris_from_args(args: Optional[RedfishEndpointCollectorArgs]) -> list[str]: - """Resolve list of URIs from collector args, optionally loading from config_file.""" + """Return list of URIs from collector args.uris.""" if args is None: return [] - uris = list(args.uris) if args.uris else [] - if args.config_file: - path = Path(args.config_file) - if path.is_file(): - try: - data = json.loads(path.read_text(encoding="utf-8")) - if isinstance(data, dict) and "uris" in data: - uris = list(data["uris"]) or uris - except (json.JSONDecodeError, OSError): - pass - return uris + return list(args.uris) if args.uris else [] class RedfishEndpointCollector( From a4798a49935fc4e845cec8b9c27d71793550ba9f Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Mon, 9 Mar 2026 18:14:57 -0500 Subject: [PATCH 4/4] addressd reviews + utest --- README.md | 58 +++ nodescraper/connection/redfish/__init__.py | 2 + .../connection/redfish/redfish_connection.py | 48 ++- .../connection/redfish/redfish_path.py | 61 +++ nodescraper/enums/eventcategory.py | 3 + nodescraper/models/taskresult.py | 9 +- .../ooband/redfish_endpoint/analyzer_args.py | 28 +- .../ooband/redfish_endpoint/collector_args.py | 10 +- .../redfish_endpoint/endpoint_analyzer.py | 28 +- .../redfish_endpoint/endpoint_collector.py | 2 +- .../plugin/test_redfish_endpoint_plugin.py | 372 ++++++++++++++++++ 11 files changed, 590 insertions(+), 31 deletions(-) create mode 100644 nodescraper/connection/redfish/redfish_path.py create mode 100644 test/unit/plugin/test_redfish_endpoint_plugin.py diff --git a/README.md b/README.md index 63ea3aa6..4a22910a 100644 --- a/README.md +++ b/README.md @@ -14,6 +14,7 @@ system debug. - ['run-plugins' sub command](#run-plugins-sub-command) - ['gen-plugin-config' sub command](#gen-plugin-config-sub-command) - ['compare-runs' subcommand](#compare-runs-subcommand) + - ['show-redfish-oem-allowable' subcommand](#show-redfish-oem-allowable-subcommand) - ['summary' sub command](#summary-sub-command) - [Configs](#configs) - [Global args](#global-args) @@ -339,6 +340,63 @@ node-scraper compare-runs path1 path2 --include-plugins DmesgPlugin --dont-trunc You can pass multiple plugin names to `--skip-plugins` or `--include-plugins`. +#### **'show-redfish-oem-allowable' subcommand** +The `show-redfish-oem-allowable` subcommand fetches the list of OEM diagnostic types supported by your BMC (from the Redfish LogService `OEMDiagnosticDataType@Redfish.AllowableValues`). Use it to discover which types you can put in `oem_diagnostic_types_allowable` and `oem_diagnostic_types` in the Redfish OEM diag plugin config. + +**Requirements:** A Redfish connection config (same as for RedfishOemDiagPlugin). + +**Command:** +```sh +node-scraper --connection-config connection-config.json show-redfish-oem-allowable --log-service-path "redfish/v1/Systems/UBB/LogServices/DiagLogs" +``` + +Output is a JSON array of allowable type names (e.g. `["Dmesg", "JournalControl", "AllLogs", ...]`). Copy that list into your plugin config’s `oem_diagnostic_types_allowable` if you want to match your BMC. + +**Redfish OEM diag plugin config example** + +Use a plugin config that points at your LogService and lists the types to collect. Logs are written under the run log path (see `--log-path`). + +```json +{ + "name": "Redfish OEM diagnostic logs", + "desc": "Collect OEM diagnostic logs from Redfish LogService. Requires Redfish connection config.", + "global_args": {}, + "plugins": { + "RedfishOemDiagPlugin": { + "collection_args": { + "log_service_path": "redfish/v1/Systems/UBB/LogServices/DiagLogs", + "oem_diagnostic_types_allowable": [ + "JournalControl", + "AllLogs", + ... + ], + "oem_diagnostic_types": ["JournalControl", "AllLogs"], + "task_timeout_s": 600 + }, + "analysis_args": { + "require_all_success": false + } + } + }, + "result_collators": {} +} +``` + +- **`log_service_path`**: Redfish path to the LogService (e.g. DiagLogs). Must match your system (e.g. `UBB` vs. another system id). +- **`oem_diagnostic_types_allowable`**: Full list of types the BMC supports (from `show-redfish-oem-allowable` or vendor docs). +- **`oem_diagnostic_types`**: Subset of types to collect on each run (e.g. `["JournalControl", "AllLogs"]`). +- **`task_timeout_s`**: Max seconds to wait per collection task. + +**How to use** + +1. **Discover allowable types** (optional): run `show-redfish-oem-allowable` and paste the output into `oem_diagnostic_types_allowable` in your plugin config. +2. **Set `oem_diagnostic_types`** to the list you want to collect (e.g. `["JournalControl", "AllLogs"]`). +3. **Run the plugin** with a Redfish connection config and your plugin config: + ```sh + node-scraper --connection-config connection-config.json --plugin-config plugin_config_redfish_oem_diag.json run-plugins RedfishOemDiagPlugin + ``` +4. Use **`--log-path`** to choose where run logs (and OEM diag archives) are written. + #### **'summary' sub command** The 'summary' subcommand can be used to combine results from multiple runs of node-scraper to a single summary.csv file. Sample run: diff --git a/nodescraper/connection/redfish/__init__.py b/nodescraper/connection/redfish/__init__.py index 1f4419e0..5832eb86 100644 --- a/nodescraper/connection/redfish/__init__.py +++ b/nodescraper/connection/redfish/__init__.py @@ -30,6 +30,7 @@ ) from .redfish_manager import RedfishConnectionManager from .redfish_params import RedfishConnectionParams +from .redfish_path import RedfishPath __all__ = [ "RedfishConnection", @@ -37,4 +38,5 @@ "RedfishGetResult", "RedfishConnectionManager", "RedfishConnectionParams", + "RedfishPath", ] diff --git a/nodescraper/connection/redfish/redfish_connection.py b/nodescraper/connection/redfish/redfish_connection.py index 46570537..8711ff4a 100644 --- a/nodescraper/connection/redfish/redfish_connection.py +++ b/nodescraper/connection/redfish/redfish_connection.py @@ -25,7 +25,7 @@ ############################################################################### from __future__ import annotations -from typing import Any, Optional +from typing import Any, ClassVar, Optional, Union from urllib.parse import urljoin import requests @@ -34,11 +34,17 @@ from requests import Response from requests.auth import HTTPBasicAuth +from .redfish_path import RedfishPath + DEFAULT_REDFISH_API_ROOT = "redfish/v1" class RedfishGetResult(BaseModel): - """Artifact for the result of a Redfish GET request.""" + """Artifact for the result of a Redfish GET request. + Logged under the same filename as inband command artifacts (command_artifacts.json). + """ + + ARTIFACT_LOG_BASENAME: ClassVar[str] = "command_artifacts" path: str success: bool @@ -120,25 +126,41 @@ def _login_session(self) -> None: else: self._session.auth = HTTPBasicAuth(self.username, self.password) - def get(self, path: str) -> dict[str, Any]: - """GET a Redfish path and return the JSON body.""" - session = self._ensure_session() - url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) - resp = session.get(url, timeout=self.timeout) + def get(self, path: RedfishPath) -> dict[str, Any]: + """GET a Redfish path and return the JSON body. path must be a RedfishPath.""" + path_str = str(path) + resp = self.get_response(path_str) if not resp.ok: raise RedfishConnectionError( - f"GET {path} failed: {resp.status_code} {resp.reason}", + f"GET {path_str} failed: {resp.status_code} {resp.reason}", response=resp, ) return resp.json() - def run_get(self, path: str) -> RedfishGetResult: - """Run a Redfish GET request and return a result object (no exception on failure).""" - path_norm = path.strip() + def get_response(self, path: Union[str, "RedfishPath"]) -> Response: + """GET a Redfish path and return the raw Response. path may be a string or RedfishPath.""" + path = str(path) + session = self._ensure_session() + url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) + return session.get(url, timeout=self.timeout) + + def post( + self, path: Union[str, "RedfishPath"], json: Optional[dict[str, Any]] = None + ) -> Response: + """POST to a Redfish path and return the raw Response. path may be a string or RedfishPath.""" + path = str(path) + session = self._ensure_session() + url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/")) + return session.post(url, json=json or {}, timeout=self.timeout) + + def run_get(self, path: Union[str, RedfishPath]) -> RedfishGetResult: + """Run a Redfish GET request and return a result object. path may be a string or RedfishPath.""" + path_norm = str(path).strip() if not path_norm.startswith("/"): path_norm = "/" + path_norm + path_obj = RedfishPath(path_norm.strip("/")) if isinstance(path, str) else path try: - data = self.get(path_norm) + data = self.get(path_obj) return RedfishGetResult( path=path_norm, success=True, @@ -163,7 +185,7 @@ def run_get(self, path: str) -> RedfishGetResult: def get_service_root(self) -> dict[str, Any]: """GET service root (e.g. /redfish/v1/).""" - return self.get(f"/{self.api_root}/") + return self.get(RedfishPath(self.api_root)) def close(self) -> None: """Release session and logout if session auth was used.""" diff --git a/nodescraper/connection/redfish/redfish_path.py b/nodescraper/connection/redfish/redfish_path.py new file mode 100644 index 00000000..0cd14024 --- /dev/null +++ b/nodescraper/connection/redfish/redfish_path.py @@ -0,0 +1,61 @@ +############################################################################### +# +# 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. +# +############################################################################### +"""Fluent Redfish path builder with pathlib-like / syntax and optional parameter substitution.""" +from __future__ import annotations + +from typing import Dict + + +class RedfishPath: + """Fluent interface for building Redfish URI paths.""" + + def __init__(self, base: str = "") -> None: + self._path = (base or "").strip().strip("/") + self._params: Dict[str, str] = {} + + def __truediv__(self, segment: str) -> RedfishPath: + """Allow path / \"segment\" syntax. Leading/trailing slashes on segment are stripped.""" + seg = (segment or "").strip().strip("/") + if not seg: + return RedfishPath(self._path) + new_path = RedfishPath() + new_path._path = f"{self._path}/{seg}" if self._path else seg + new_path._params = dict(self._params) + return new_path + + def __call__(self, **params: str) -> str: + """Substitute placeholders in the path and return the final path string. + + Placeholders use {key}; e.g. path \"Systems/{id}/LogServices/DiagLogs\" with (id=\"UBB\") + returns \"Systems/UBB/LogServices/DiagLogs\". + """ + result = self._path + for key, value in params.items(): + result = result.replace(f"{{{key}}}", value) + return result + + def __str__(self) -> str: + return self._path diff --git a/nodescraper/enums/eventcategory.py b/nodescraper/enums/eventcategory.py index 553119a8..42aa6c98 100644 --- a/nodescraper/enums/eventcategory.py +++ b/nodescraper/enums/eventcategory.py @@ -65,6 +65,8 @@ class EventCategory(AutoNameStrEnum): Network, IT issues, Downtime - NETWORK Network configuration, interfaces, routing, neighbors, ethtool data + - TELEMETRY + Telemetry / monitored data checks (e.g. Redfish endpoint constraint violations) - RUNTIME Framework issues, does not include content failures - UNKNOWN @@ -85,5 +87,6 @@ class EventCategory(AutoNameStrEnum): BIOS = auto() INFRASTRUCTURE = auto() NETWORK = auto() + TELEMETRY = auto() RUNTIME = auto() UNKNOWN = auto() diff --git a/nodescraper/models/taskresult.py b/nodescraper/models/taskresult.py index afb534e5..3a4a2952 100644 --- a/nodescraper/models/taskresult.py +++ b/nodescraper/models/taskresult.py @@ -175,7 +175,14 @@ def log_result(self, log_path: str) -> None: if isinstance(artifact, BaseFileArtifact): artifact.log_model(log_path) else: - name = f"{pascal_to_snake(artifact.__class__.__name__)}s" + name = ( + getattr( + artifact.__class__, + "ARTIFACT_LOG_BASENAME", + None, + ) + or f"{pascal_to_snake(artifact.__class__.__name__)}s" + ) if name in artifact_map: artifact_map[name].append(artifact.model_dump(mode="json")) else: diff --git a/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py b/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py index f83d7071..9162980e 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py @@ -23,12 +23,27 @@ # SOFTWARE. # ############################################################################### +from enum import Enum from typing import Any, Union from pydantic import Field from nodescraper.models import AnalyzerArgs + +class ConstraintKey(str, Enum): + """Keys used in Redfish constraint dicts (e.g. in analyzer checks config). + + Naming aligns with JSON Schema combining: anyOf = value must match any of the list (OR). + oneOf in JSON Schema means exactly one (XOR); we use anyOf for \"value in allowed list\". + """ + + EQ = "eq" + MIN = "min" + MAX = "max" + ANY_OF = "anyOf" + + RedfishConstraint = Union[int, float, str, bool, dict[str, Any]] @@ -37,5 +52,16 @@ class RedfishEndpointAnalyzerArgs(AnalyzerArgs): checks: dict[str, dict[str, RedfishConstraint]] = Field( default_factory=dict, - description="URI or '*' -> { property_path: constraint } for threshold/value checks.", + description=( + "Map: URI or '*' -> { property_path: constraint }. " + "URI keys must match a key in the collected responses (exact match). " + "Use '*' as the key to apply the inner constraints to every collected response body. " + "Property paths use '/' for nesting and indices, e.g. 'Status/Health', 'PowerControl/0/PowerConsumedWatts'. " + "Constraints: " + "'eq' — value must equal the given literal (int, float, str, bool). " + "'min' — value must be numeric and >= the given number. " + "'max' — value must be numeric and <= the given number. " + "'anyOf' — value must be in the given list (OR; any match passes). " + 'Example: { "/redfish/v1/Systems/1": { "Status/Health": { "anyOf": ["OK", "Warning"] }, "PowerState": "On" }, "*": { "Status/Health": { "anyOf": ["OK"] } } }.' + ), ) diff --git a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py index 03afa48e..c63cd8db 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py @@ -23,10 +23,18 @@ # SOFTWARE. # ############################################################################### -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, field_validator class RedfishEndpointCollectorArgs(BaseModel): """Collection args: uris to GET.""" uris: list[str] = Field(default_factory=list) + + @field_validator("uris", mode="before") + @classmethod + def strip_uris(cls, v: list[str]) -> list[str]: + """Strip whitespace from each URI in the list.""" + if not v: + return v + return [str(uri).strip() for uri in v] diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py index 986ab113..9229e0c1 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py @@ -29,7 +29,7 @@ from nodescraper.interfaces import DataAnalyzer from nodescraper.models import TaskResult -from .analyzer_args import RedfishConstraint, RedfishEndpointAnalyzerArgs +from .analyzer_args import ConstraintKey, RedfishConstraint, RedfishEndpointAnalyzerArgs from .endpoint_data import RedfishEndpointDataModel @@ -59,27 +59,27 @@ def _get_by_path(obj: Any, path: str) -> Any: def _check_constraint(actual: Any, constraint: RedfishConstraint) -> tuple[bool, str]: """Compare actual value to constraint.""" if isinstance(constraint, dict): - if "eq" in constraint: - ok = actual == constraint["eq"] - return ok, f"expected eq {constraint['eq']}, got {actual!r}" - if "min" in constraint or "max" in constraint: + if ConstraintKey.EQ in constraint: + ok = actual == constraint[ConstraintKey.EQ] + return ok, f"expected eq {constraint[ConstraintKey.EQ]}, got {actual!r}" + if ConstraintKey.MIN in constraint or ConstraintKey.MAX in constraint: try: val = float(actual) if actual is not None else None if val is None: return False, f"expected numeric, got {actual!r}" - if "min" in constraint and val < constraint["min"]: - return False, f"value {val} below min {constraint['min']}" - if "max" in constraint and val > constraint["max"]: - return False, f"value {val} above max {constraint['max']}" + if ConstraintKey.MIN in constraint and val < constraint[ConstraintKey.MIN]: + return False, f"value {val} below min {constraint[ConstraintKey.MIN]}" + if ConstraintKey.MAX in constraint and val > constraint[ConstraintKey.MAX]: + return False, f"value {val} above max {constraint[ConstraintKey.MAX]}" return True, "" except (TypeError, ValueError): return False, f"expected numeric, got {actual!r}" - if "oneOf" in constraint: - allowed = constraint["oneOf"] + if ConstraintKey.ANY_OF in constraint: + allowed = constraint[ConstraintKey.ANY_OF] if not isinstance(allowed, list): - return False, "oneOf must be a list" + return False, "anyOf must be a list" ok = actual in allowed - return ok, f"expected one of {allowed}, got {actual!r}" + return ok, f"expected any of {allowed}, got {actual!r}" ok = actual == constraint return ok, f"expected {constraint!r}, got {actual!r}" @@ -132,7 +132,7 @@ def analyze_data( first = failed[0] detail = f"{first['uri']} {first['path']}: {first['reason']}" self._log_event( - category=EventCategory.RUNTIME, + category=EventCategory.TELEMETRY, description=f"Redfish endpoint checks failed: {len(failed)} failure(s) — {detail}", data={"failures": failed}, priority=EventPriority.WARNING, diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py index 87960b84..39dacf79 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py @@ -59,7 +59,7 @@ def collect_data( responses: dict[str, dict] = {} for uri in uris: - path = uri.strip() + path = uri if not path: continue if not path.startswith("/"): diff --git a/test/unit/plugin/test_redfish_endpoint_plugin.py b/test/unit/plugin/test_redfish_endpoint_plugin.py new file mode 100644 index 00000000..d7bbb3aa --- /dev/null +++ b/test/unit/plugin/test_redfish_endpoint_plugin.py @@ -0,0 +1,372 @@ +############################################################################### +# +# 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 unittest.mock import MagicMock + +import pytest + +from nodescraper.connection.redfish import RedfishGetResult +from nodescraper.enums import EventCategory, ExecutionStatus +from nodescraper.models import SystemInfo +from nodescraper.models.systeminfo import OSFamily +from nodescraper.plugins.ooband.redfish_endpoint import ( + RedfishEndpointAnalyzer, + RedfishEndpointAnalyzerArgs, + RedfishEndpointCollector, + RedfishEndpointCollectorArgs, + RedfishEndpointDataModel, + RedfishEndpointPlugin, +) +from nodescraper.plugins.ooband.redfish_endpoint.endpoint_analyzer import ( + _check_constraint, + _get_by_path, +) + + +def test_redfish_endpoint_collector_args_default(): + args = RedfishEndpointCollectorArgs() + assert args.uris == [] + + +def test_redfish_endpoint_collector_args_uris_stripped(): + args = RedfishEndpointCollectorArgs(uris=[" /redfish/v1 ", "/Systems/1 ", " Chassis "]) + assert args.uris == ["/redfish/v1", "/Systems/1", "Chassis"] + + +def test_redfish_endpoint_collector_args_uris_empty_list(): + args = RedfishEndpointCollectorArgs(uris=[]) + assert args.uris == [] + + +def test_redfish_endpoint_data_model_default(): + model = RedfishEndpointDataModel() + assert model.responses == {} + + +def test_redfish_endpoint_data_model_responses(): + model = RedfishEndpointDataModel(responses={"/redfish/v1": {"Name": "Root"}}) + assert model.responses["/redfish/v1"]["Name"] == "Root" + + +def test_redfish_endpoint_plugin_class_attributes(): + assert RedfishEndpointPlugin.DATA_MODEL is RedfishEndpointDataModel + assert RedfishEndpointPlugin.COLLECTOR is RedfishEndpointCollector + assert RedfishEndpointPlugin.ANALYZER is RedfishEndpointAnalyzer + assert RedfishEndpointPlugin.COLLECTOR_ARGS is RedfishEndpointCollectorArgs + assert RedfishEndpointPlugin.ANALYZER_ARGS is RedfishEndpointAnalyzerArgs + + +@pytest.fixture +def system_info(): + return SystemInfo(name="test_host", platform="X", os_family=OSFamily.LINUX, sku="GOOD") + + +@pytest.fixture +def redfish_conn_mock(): + return MagicMock() + + +@pytest.fixture +def redfish_endpoint_collector(system_info, redfish_conn_mock): + return RedfishEndpointCollector( + system_info=system_info, + connection=redfish_conn_mock, + ) + + +def test_redfish_endpoint_collector_no_uris(redfish_endpoint_collector): + result, data = redfish_endpoint_collector.collect_data() + assert result.status == ExecutionStatus.NOT_RAN + assert result.message == "No Redfish URIs configured" + assert data is None + + +def test_redfish_endpoint_collector_no_uris_with_args(redfish_endpoint_collector): + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=[]) + ) + assert result.status == ExecutionStatus.NOT_RAN + assert data is None + + +def test_redfish_endpoint_collector_one_uri_success(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=True, + data={"Name": "Root"}, + status_code=200, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) + ) + assert result.status == ExecutionStatus.OK + assert result.message == "Collected 1 Redfish endpoint(s)" + assert data is not None + assert data.responses["/redfish/v1"]["Name"] == "Root" + redfish_conn_mock.run_get.assert_called_once() + call_path = redfish_conn_mock.run_get.call_args[0][0] + assert call_path == "/redfish/v1" or call_path.strip("/") == "redfish/v1" + + +def test_redfish_endpoint_collector_uri_normalized_with_leading_slash( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1/Systems", + success=True, + data={"Members": []}, + status_code=200, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["redfish/v1/Systems"]) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert "/redfish/v1/Systems" in data.responses or "redfish/v1/Systems" in data.responses + + +def test_redfish_endpoint_collector_one_fail_no_success( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", + success=False, + error="Connection refused", + status_code=None, + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"]) + ) + assert result.status == ExecutionStatus.ERROR + assert result.message.startswith("No Redfish endpoints could be read") + assert data is None + assert len(result.events) >= 1 + assert any( + e.category == EventCategory.RUNTIME.value or "Redfish GET failed" in (e.description or "") + for e in result.events + ) + + +def test_redfish_endpoint_collector_mixed_success_fail( + redfish_endpoint_collector, redfish_conn_mock +): + def run_get_side_effect(path): + path_str = str(path) + if "Systems" in path_str: + return RedfishGetResult( + path=path_str if path_str.startswith("/") else "/" + path_str, + success=True, + data={"Id": "1"}, + status_code=200, + ) + return RedfishGetResult( + path=path_str if path_str.startswith("/") else "/" + path_str, + success=False, + error="Not Found", + status_code=404, + ) + + redfish_conn_mock.run_get.side_effect = run_get_side_effect + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1/Systems", "/redfish/v1/Bad"]) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert len(data.responses) == 1 + keys = list(data.responses.keys()) + assert any("Systems" in k for k in keys) + assert list(data.responses.values())[0].get("Id") == "1" + + +def test_get_by_path_empty_returns_obj(): + obj = {"a": 1} + assert _get_by_path(obj, "") == obj + assert _get_by_path(obj, " ") == obj + + +def test_get_by_path_single_key(): + assert _get_by_path({"x": 42}, "x") == 42 + assert _get_by_path({"Status": {"Health": "OK"}}, "Status") == {"Health": "OK"} + + +def test_get_by_path_nested_slash(): + obj = {"Status": {"Health": "OK", "State": "Enabled"}} + assert _get_by_path(obj, "Status/Health") == "OK" + assert _get_by_path(obj, "Status/State") == "Enabled" + + +def test_get_by_path_list_index(): + obj = {"PowerControl": [{"PowerConsumedWatts": 100}, {"PowerConsumedWatts": 200}]} + assert _get_by_path(obj, "PowerControl/0/PowerConsumedWatts") == 100 + assert _get_by_path(obj, "PowerControl/1/PowerConsumedWatts") == 200 + + +def test_get_by_path_missing_returns_none(): + assert _get_by_path({"a": 1}, "b") is None + assert _get_by_path({"a": {"b": 2}}, "a/c") is None + assert _get_by_path(None, "a") is None + + +def test_get_by_path_invalid_list_index(): + obj = {"list": [1, 2, 3]} + assert _get_by_path(obj, "list/10") is None + assert _get_by_path(obj, "list/xyz") is None + + +def test_check_constraint_eq_pass(): + ok, msg = _check_constraint("On", {"eq": "On"}) + assert ok is True + + +def test_check_constraint_eq_fail(): + ok, msg = _check_constraint("Off", {"eq": "On"}) + assert ok is False + assert "On" in msg and "Off" in msg + + +def test_check_constraint_min_max_pass(): + ok, _ = _check_constraint(50, {"min": 0, "max": 100}) + assert ok is True + ok, _ = _check_constraint(0, {"min": 0}) + assert ok is True + ok, _ = _check_constraint(100, {"max": 100}) + assert ok is True + + +def test_check_constraint_min_fail(): + ok, msg = _check_constraint(10, {"min": 20}) + assert ok is False + assert "below min" in msg or "20" in msg + + +def test_check_constraint_max_fail(): + ok, msg = _check_constraint(150, {"max": 100}) + assert ok is False + assert "above max" in msg or "100" in msg + + +def test_check_constraint_any_of_pass(): + ok, _ = _check_constraint("OK", {"anyOf": ["OK", "Warning"]}) + assert ok is True + ok, _ = _check_constraint("Warning", {"anyOf": ["OK", "Warning"]}) + assert ok is True + + +def test_check_constraint_any_of_fail(): + ok, msg = _check_constraint("Critical", {"anyOf": ["OK", "Warning"]}) + assert ok is False + assert "any of" in msg or "OK" in msg + + +def test_check_constraint_literal_match(): + ok, _ = _check_constraint("On", "On") + assert ok is True + ok, msg = _check_constraint("Off", "On") + assert ok is False + + +@pytest.fixture +def redfish_endpoint_analyzer(system_info): + return RedfishEndpointAnalyzer(system_info=system_info) + + +def test_redfish_endpoint_analyzer_no_checks(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel(responses={"/redfish/v1": {}}) + result = redfish_endpoint_analyzer.analyze_data(data, args=None) + assert result.status == ExecutionStatus.OK + assert result.message == "No checks configured" + + +def test_redfish_endpoint_analyzer_empty_checks(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel(responses={"/redfish/v1": {"Status": {"Health": "OK"}}}) + result = redfish_endpoint_analyzer.analyze_data( + data, args=RedfishEndpointAnalyzerArgs(checks={}) + ) + assert result.status == ExecutionStatus.OK + assert result.message == "No checks configured" + + +def test_redfish_endpoint_analyzer_all_pass(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel( + responses={ + "/redfish/v1/Systems/1": {"Status": {"Health": "OK"}, "PowerState": "On"}, + } + ) + args = RedfishEndpointAnalyzerArgs( + checks={ + "/redfish/v1/Systems/1": { + "Status/Health": {"anyOf": ["OK", "Warning"]}, + "PowerState": "On", + }, + } + ) + result = redfish_endpoint_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.OK + assert result.message == "All Redfish endpoint checks passed" + + +def test_redfish_endpoint_analyzer_one_fail(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel( + responses={ + "/redfish/v1/Systems/1": {"Status": {"Health": "Critical"}}, + } + ) + args = RedfishEndpointAnalyzerArgs( + checks={ + "/redfish/v1/Systems/1": {"Status/Health": {"anyOf": ["OK", "Warning"]}}, + } + ) + result = redfish_endpoint_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.ERROR + assert "check(s) failed" in result.message + + +def test_redfish_endpoint_analyzer_uri_not_in_responses(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel(responses={"/redfish/v1": {}}) + args = RedfishEndpointAnalyzerArgs( + checks={ + "/redfish/v1/Systems/1": {"Status/Health": "OK"}, + } + ) + result = redfish_endpoint_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.ERROR + assert "check(s) failed" in result.message or "failed" in result.message + + +def test_redfish_endpoint_analyzer_wildcard_applies_to_all_bodies(redfish_endpoint_analyzer): + data = RedfishEndpointDataModel( + responses={ + "/redfish/v1/Chassis/1": {"Status": {"Health": "OK"}}, + "/redfish/v1/Chassis/2": {"Status": {"Health": "OK"}}, + } + ) + args = RedfishEndpointAnalyzerArgs( + checks={ + "*": {"Status/Health": {"anyOf": ["OK", "Warning"]}}, + } + ) + result = redfish_endpoint_analyzer.analyze_data(data, args=args) + assert result.status == ExecutionStatus.OK + assert result.message == "All Redfish endpoint checks passed"