From d153d6408e0f11d7443b2d2526de907e62b52de2 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Fri, 6 Mar 2026 14:45:26 -0600 Subject: [PATCH 1/3] 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/3] 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/3] 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(