From 0c848a1febfb3a4e6539f2beacd0d333b0bd32f9 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 16 Sep 2026 13:33:41 -0500 Subject: [PATCH] initial commit --- nodescraper/connection/redfish/__init__.py | 6 + .../connection/redfish/redfish_connection.py | 30 +- .../connection/redfish/redfish_oem_diag.py | 194 ++++++++---- .../redfish/ssh_proxy_connection.py | 282 ++++++++++++++++++ .../connection/redfish/ssh_proxy_manager.py | 175 +++++++++++ .../connection/redfish/ssh_proxy_params.py | 58 ++++ .../ooband/amc_redfish_diag/__init__.py | 17 ++ .../amc_redfish_diag/amc_diag_collector.py | 205 +++++++++++++ .../amc_redfish_diag/amc_diag_plugin.py | 65 ++++ .../ooband/amc_redfish_diag/collector_args.py | 86 ++++++ .../redfish/test_redfish_connection_ssl.py | 80 +++++ .../redfish/test_redfish_oem_diag.py | 106 ++++++- .../redfish/test_redfish_transport_errors.py | 118 ++++++++ .../redfish/test_ssh_proxy_connection.py | 157 ++++++++++ .../framework/test_plugin_execution_target.py | 4 +- test/unit/plugin/test_amc_redfish_diag.py | 139 +++++++++ 16 files changed, 1662 insertions(+), 60 deletions(-) create mode 100644 nodescraper/connection/redfish/ssh_proxy_connection.py create mode 100644 nodescraper/connection/redfish/ssh_proxy_manager.py create mode 100644 nodescraper/connection/redfish/ssh_proxy_params.py create mode 100644 nodescraper/plugins/ooband/amc_redfish_diag/__init__.py create mode 100644 nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_collector.py create mode 100644 nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_plugin.py create mode 100644 nodescraper/plugins/ooband/amc_redfish_diag/collector_args.py create mode 100644 test/unit/connection/redfish/test_redfish_connection_ssl.py create mode 100644 test/unit/connection/redfish/test_redfish_transport_errors.py create mode 100644 test/unit/connection/redfish/test_ssh_proxy_connection.py create mode 100644 test/unit/plugin/test_amc_redfish_diag.py diff --git a/nodescraper/connection/redfish/__init__.py b/nodescraper/connection/redfish/__init__.py index 12b5af16..5bdc4544 100644 --- a/nodescraper/connection/redfish/__init__.py +++ b/nodescraper/connection/redfish/__init__.py @@ -41,12 +41,18 @@ ) from .redfish_params import RedfishConnectionParams, redfish_params_to_ssh from .redfish_path import RedfishPath +from .ssh_proxy_connection import SshProxyRedfishConnection +from .ssh_proxy_manager import RedfishSshProxyConnectionManager +from .ssh_proxy_params import RedfishSshProxyConnectionParams __all__ = [ "RedfishConnection", "RedfishConnectionError", "RedfishGetResult", "RedfishConnectionManager", + "RedfishSshProxyConnectionManager", + "RedfishSshProxyConnectionParams", + "SshProxyRedfishConnection", "RedfishConnectionParams", "redfish_params_to_ssh", "RedfishPath", diff --git a/nodescraper/connection/redfish/redfish_connection.py b/nodescraper/connection/redfish/redfish_connection.py index eabee716..587879fa 100644 --- a/nodescraper/connection/redfish/redfish_connection.py +++ b/nodescraper/connection/redfish/redfish_connection.py @@ -27,13 +27,12 @@ import json import socket -from typing import Any, Callable, ClassVar, Optional, TypeVar, Union +from typing import Any, Callable, ClassVar, Optional, Protocol, TypeVar, Union from urllib.parse import urljoin, urlparse import requests import urllib3 # type: ignore[import-untyped] from pydantic import BaseModel -from requests import Response from requests.auth import HTTPBasicAuth from .redfish_constants import RF_MEMBERS, RF_MEMBERS_COUNT, RF_MEMBERS_NEXT_LINK @@ -71,10 +70,31 @@ def to_html_entry(self) -> dict: } +class RedfishHttpResponse(Protocol): + """Status, headers, and body from a Redfish GET or POST.""" + + status_code: int + headers: Any + + @property + def content(self) -> bytes: ... + + @property + def ok(self) -> bool: ... + + @property + def reason(self) -> str: ... + + @property + def text(self) -> str: ... + + def json(self) -> Any: ... + + class RedfishConnectionError(Exception): """Raised when a Redfish API request fails.""" - def __init__(self, message: str, response: Optional[Response] = None): + def __init__(self, message: str, response: Optional[RedfishHttpResponse] = None): super().__init__(message) self.response = response @@ -195,7 +215,7 @@ def get(self, path: RedfishPath) -> dict[str, Any]: ) return resp.json() - def get_response(self, path: Union[str, "RedfishPath"]) -> Response: + def get_response(self, path: Union[str, "RedfishPath"]) -> RedfishHttpResponse: """GET a Redfish path and return the raw Response. path may be a string or RedfishPath.""" path = str(path) session = self._ensure_session() @@ -204,7 +224,7 @@ def get_response(self, path: Union[str, "RedfishPath"]) -> Response: def post( self, path: Union[str, "RedfishPath"], json: Optional[dict[str, Any]] = None - ) -> Response: + ) -> RedfishHttpResponse: """POST to a Redfish path and return the raw Response. path may be a string or RedfishPath.""" path = str(path) session = self._ensure_session() diff --git a/nodescraper/connection/redfish/redfish_oem_diag.py b/nodescraper/connection/redfish/redfish_oem_diag.py index affabf6e..30c7810f 100644 --- a/nodescraper/connection/redfish/redfish_oem_diag.py +++ b/nodescraper/connection/redfish/redfish_oem_diag.py @@ -32,12 +32,15 @@ from pathlib import Path from typing import Any, Optional -from requests import Response from requests.status_codes import codes from nodescraper.enums import TaskState -from .redfish_connection import RedfishConnection, RedfishConnectionError +from .redfish_connection import ( + RedfishConnection, + RedfishConnectionError, + RedfishHttpResponse, +) from .redfish_constants import RF_ODATA_ID from .redfish_path import RedfishPath @@ -167,6 +170,64 @@ def _resolve_uri(uri: str) -> str: return None +def _task_resource_path(path: str) -> Optional[str]: + """Return a TaskService/Tasks path when path is a Task member, else None. + + Args: + path: Absolute or relative Redfish URI. + + Returns: + Normalized Task path, or None. + """ + stripped = path.strip().lstrip("/") + if "TaskService/Tasks/" in stripped and "TaskMonitors" not in stripped: + return stripped + return None + + +def _poll_task_resource( + conn: RedfishConnection, + task_path: str, + timeout_s: int, + sleep_s: int, +) -> tuple[Optional[dict[str, Any]], Optional[str]]: + """GET a Task resource until TaskState is Completed or a terminal failure. + + Args: + conn: Redfish connection. + task_path: Path to a TaskService/Tasks member. + timeout_s: Max seconds to wait. + sleep_s: Seconds between GETs. + + Returns: + (task JSON, None) on success, or (None, error). + """ + start = time.time() + interval = max(int(sleep_s), 1) + while True: + if time.time() - start > timeout_s: + return None, f"Task did not complete within {timeout_s}s" + poll_resp = conn.get_response(task_path) + if poll_resp.status_code == codes.ok: + try: + body = poll_resp.json() + except Exception: + body = {} + if isinstance(body, dict): + state = body.get("TaskState") + if state == TaskState.completed.value: + return body, None + if state in ( + TaskState.exception.value, + TaskState.cancelled.value, + TaskState.killed.value, + ): + return None, f"Task did not complete: TaskState={state}" + elif poll_resp.status_code != codes.accepted: + return None, f"Task GET failed: {poll_resp.status_code}" + time.sleep(interval) + + # Workaround for LogEntry URL: some BMCs 404 when URL includes port def _strip_port_from_url(url: str) -> Optional[str]: """Return URL with port removed from authority (e.g. host:443 -> host).""" @@ -219,40 +280,46 @@ def collect_oem_diagnostic_data( validate_type: bool = False, allowed_types: Optional[list[str]] = None, logger: Optional[logging.Logger] = None, + diagnostic_data_type: str = "OEM", ) -> tuple[Optional[bytes], Optional[dict[str, Any]], Optional[str]]: """ - Initiate OEM diagnostic collection, poll until done, download log and metadata. + Initiate CollectDiagnosticData, poll until done, download log and metadata. Args: conn: Redfish connection (session already established). log_service_path: Path to LogService under Systems, e.g. "redfish/v1/Systems/UBB/LogServices/DiagLogs" (no leading slash). - oem_diagnostic_type: OEM type for DiagnosticDataType OEM (e.g. "JournalControl", "AllLogs"). Required. + oem_diagnostic_type: OEM type when diagnostic_data_type is OEM (e.g. JournalControl, AllLogs). task_timeout_s: Max seconds to wait for BMC task output_dir: If set, save log archive and LogEntry JSON here. validate_type: If True, require oem_diagnostic_type to be in allowed_types. allowed_types: Allowable OEM diagnostic types for validation when validate_type is True. logger: Logger + diagnostic_data_type: DMTF DiagnosticDataType (OEM, Manager, and similar). Returns: (log_bytes, log_entry_metadata_dict, error_message). On success: (bytes, dict, None). On failure: (None, None, error_str). """ log = logger if logger is not None else _module_logger - if not oem_diagnostic_type or not oem_diagnostic_type.strip(): + diag_type = (diagnostic_data_type or "OEM").strip() or "OEM" + oem_type = (oem_diagnostic_type or "").strip() + if diag_type == "OEM" and not oem_type: return None, None, "oem_diagnostic_type is required" - if validate_type and allowed_types and oem_diagnostic_type not in allowed_types: + if validate_type and allowed_types and oem_type and oem_type not in allowed_types: return ( None, None, - f"oem_diagnostic_type {oem_diagnostic_type!r} not in allowed types", + f"oem_diagnostic_type {oem_type!r} not in allowed types", ) path_prefix = log_service_path.rstrip("/") action_path = f"{path_prefix}/Actions/LogService.CollectDiagnosticData" - payload = {"DiagnosticDataType": "OEM", "OEMDiagnosticDataType": oem_diagnostic_type} + payload: dict[str, Any] = {"DiagnosticDataType": diag_type} + if oem_type: + payload["OEMDiagnosticDataType"] = oem_type try: - resp: Response = conn.post(action_path, json=payload) + resp: RedfishHttpResponse = conn.post(action_path, json=payload) except RedfishConnectionError as e: return None, None, str(e) @@ -281,56 +348,78 @@ def collect_oem_diagnostic_data( if any(isinstance(h, str) and "Location:" in h for h in headers_list): task_json = oem_response - # When TaskMonitor is implemented task_monitor: Optional[str] = None task_path: Optional[str] = None if task_json is None: - task_monitor = location_header or _get_task_monitor_uri(oem_response, conn) - if oem_response.get(RF_ODATA_ID): - task_path = _get_path_from_connection(conn, oem_response[RF_ODATA_ID]) - if not task_monitor and task_path: - task_resp = conn.get_response(task_path) - if task_resp.status_code == codes.ok: - fetched = task_resp.json() - task_monitor = _get_task_monitor_uri(fetched, conn) - if not task_monitor: + if isinstance(oem_response, dict) and oem_response.get(RF_ODATA_ID): + task_path = _task_resource_path( + _get_path_from_connection(conn, oem_response[RF_ODATA_ID]) + ) + if location_header: + loc_path = _get_path_from_connection(conn, location_header) + loc_task = _task_resource_path(loc_path) + if loc_task: + task_path = loc_task + else: + task_monitor = location_header + if not task_monitor and not task_path and isinstance(oem_response, dict): + task_monitor = _get_task_monitor_uri(oem_response, conn) + if task_path: + task_json, poll_err = _poll_task_resource(conn, task_path, task_timeout_s, sleep_s) + if poll_err: + return None, None, poll_err + elif task_monitor: + start = time.time() + poll_resp = None + while True: + if time.time() - start > task_timeout_s: + return None, None, f"Task did not complete within {task_timeout_s}s" + monitor_path = _get_path_from_connection(conn, task_monitor) + poll_resp = conn.get_response(monitor_path) + if poll_resp.status_code == codes.not_found: + return None, None, f"TaskMonitor GET failed: status {codes.not_found}" + if poll_resp.status_code != codes.accepted: + break + time.sleep(max(int(sleep_s), 1)) + try: + monitor_body = poll_resp.json() if poll_resp else {} + except Exception: + monitor_body = {} + task_uri_from_monitor = ( + monitor_body.get(RF_ODATA_ID) if isinstance(monitor_body, dict) else None + ) + if isinstance(task_uri_from_monitor, str) and task_uri_from_monitor.strip(): + follow_path = _get_path_from_connection(conn, task_uri_from_monitor.strip()) + else: + follow_path = _get_path_from_connection( + conn, task_monitor.rstrip("/").rsplit("/", 1)[0] + ) + follow_task = _task_resource_path(follow_path) + if follow_task: + task_json, poll_err = _poll_task_resource( + conn, follow_task, task_timeout_s, sleep_s + ) + if poll_err: + return None, None, poll_err + else: + task_resp = conn.get_response(follow_path) + if task_resp.status_code != codes.ok: + return None, None, f"Task GET failed: {task_resp.status_code}" + task_json = task_resp.json() + if task_json.get("TaskState") != TaskState.completed.value: + return ( + None, + None, + f"Task did not complete: TaskState={task_json.get('TaskState')}", + ) + else: _log_collect_diag_response( log, resp.status_code, oem_response, getattr(resp, "text", "") or "" ) return None, None, "No TaskMonitor in response and no Location header" - if task_json is None: - assert task_monitor is not None - # Poll task monitor until no longer 202/404 (e.g. GET /redfish/v1/TaskService/TaskMonitors/378) - start = time.time() - poll_resp = None - while True: - if time.time() - start > task_timeout_s: - return None, None, f"Task did not complete within {task_timeout_s}s" - time.sleep(sleep_s) - monitor_path = _get_path_from_connection(conn, task_monitor) - poll_resp = conn.get_response(monitor_path) - if poll_resp.status_code not in (codes.accepted, codes.not_found): - break - - # TaskMonitor response body has @odata.id pointing to the Task (e.g. /redfish/v1/TaskService/Tasks/5) - try: - monitor_body = poll_resp.json() if poll_resp else {} - except Exception: - monitor_body = {} - task_uri_from_monitor = ( - monitor_body.get(RF_ODATA_ID) if isinstance(monitor_body, dict) else None - ) - if isinstance(task_uri_from_monitor, str) and task_uri_from_monitor.strip(): - task_path = _get_path_from_connection(conn, task_uri_from_monitor.strip()) - elif not task_path: - task_path = _get_path_from_connection(conn, task_monitor.rstrip("/").rsplit("/", 1)[0]) - task_resp = conn.get_response(task_path) - if task_resp.status_code != codes.ok: - return None, None, f"Task GET failed: {task_resp.status_code}" - task_json = task_resp.json() - if task_json.get("TaskState") != TaskState.completed.value: - return None, None, f"Task did not complete: TaskState={task_json.get('TaskState')}" + if not isinstance(task_json, dict): + return None, None, "Task did not complete: missing task body" # LogEntry location from Payload.HttpHeaders headers_list = task_json.get("Payload", {}).get("HttpHeaders", []) or [] @@ -375,5 +464,6 @@ def collect_oem_diagnostic_data( err = first_error if first_status is None else f"status {first_status}" return None, None, f"LogEntry GET failed: {err} (GET {log_entry_path})" - log_bytes = _download_log_and_save(conn, log_entry_json, oem_diagnostic_type, output_dir, log) + file_stem = oem_type or diag_type + log_bytes = _download_log_and_save(conn, log_entry_json, file_stem, output_dir, log) return log_bytes, log_entry_json, None diff --git a/nodescraper/connection/redfish/ssh_proxy_connection.py b/nodescraper/connection/redfish/ssh_proxy_connection.py new file mode 100644 index 00000000..77c9f87c --- /dev/null +++ b/nodescraper/connection/redfish/ssh_proxy_connection.py @@ -0,0 +1,282 @@ +############################################################################### +# +# 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 + +import json as json_lib +import shlex +from http import HTTPStatus +from typing import Any, Optional, Union +from urllib.parse import urljoin, urlparse + +from requests.structures import CaseInsensitiveDict + +from nodescraper.connection.inband.inband import BinaryFileArtifact, CommandArtifact +from nodescraper.connection.inband.inbandremote import RemoteShell + +from .redfish_connection import RedfishConnection, RedfishConnectionError +from .redfish_path import RedfishPath + + +def parse_curl_headers(header_text: str) -> CaseInsensitiveDict: + """Parse curl -D header blocks into a case-insensitive map (last hop wins). + + Args: + header_text: Raw HTTP header dump from curl -D. + + Returns: + CaseInsensitiveDict of header name to value. + """ + headers: CaseInsensitiveDict = CaseInsensitiveDict() + for block in header_text.replace("\r\n", "\n").split("\n\n"): + for line in block.split("\n"): + if not line or line.lower().startswith("http/"): + continue + if ":" not in line: + continue + name, value = line.split(":", 1) + headers[name.strip()] = value.strip() + return headers + + +def _url_path(url: str) -> str: + """Return the URL path for log messages (no host). + + Args: + url: Absolute URL. + + Returns: + Path component or the original string. + """ + return urlparse(url).path or url + + +class CurlResponse: + """Minimal requests.Response stand-in for curl over SSH.""" + + def __init__( + self, + status_code: int, + content: bytes, + headers: Optional[CaseInsensitiveDict] = None, + ): + self.status_code = status_code + self.content = content + self.headers = headers or CaseInsensitiveDict() + + @property + def ok(self) -> bool: + return 200 <= self.status_code < 400 + + @property + def reason(self) -> str: + try: + return HTTPStatus(self.status_code).phrase + except ValueError: + return "" + + @property + def text(self) -> str: + return self.content.decode("utf-8", errors="replace") + + def json(self) -> Any: + """Parse the body as JSON. + + Returns: + Parsed object, or empty dict when the body is empty. + """ + if not self.content or not self.text.strip(): + return {} + return json_lib.loads(self.text) + + +class SshProxyRedfishConnection(RedfishConnection): + """Redfish client that runs curl on a remote BMC to reach an internal AMC URL.""" + + def __init__( + self, + shell: RemoteShell, + base_url: str, + timeout: float = 60.0, + api_root: Optional[str] = None, + ): + super().__init__( + base_url=base_url, + username="", + password=None, + timeout=timeout, + use_session_auth=False, + verify_ssl=False, + api_root=api_root, + ) + self._shell = shell + + def _cmd_timeout(self) -> int: + return max(int(self.timeout) + 15, 30) + + def _mktemp(self) -> str: + artifact = self._shell.run_command("mktemp", timeout=self._cmd_timeout()) + path = (artifact.stdout or "").strip() + if artifact.exit_code != 0 or not path: + raise RedfishConnectionError("Failed to create remote temp file via mktemp") + return path + + def _rm(self, *paths: str) -> None: + quoted = " ".join(shlex.quote(p) for p in paths if p) + if quoted: + self._shell.run_command(f"rm -f {quoted}", timeout=self._cmd_timeout()) + + def _curl(self, url: str, extra_flags: str = "") -> CurlResponse: + header_path = self._mktemp() + body_path = self._mktemp() + try: + max_time = max(int(self.timeout), 1) + cmd = ( + f"curl -sS --max-time {max_time} -D {shlex.quote(header_path)} " + f"-o {shlex.quote(body_path)} -w '%{{http_code}}' {extra_flags}" + f"{shlex.quote(url)}" + ) + result: CommandArtifact = self._shell.run_command( + cmd, timeout=self._cmd_timeout(), strip=True + ) + status_str = (result.stdout or "").strip() + try: + status_code = int(status_str) + except (TypeError, ValueError) as exc: + detail = result.stderr or result.stdout or str(exc) + raise RedfishConnectionError(f"curl to {_url_path(url)} failed: {detail}") from exc + if result.exit_code != 0 and status_code == 0: + raise RedfishConnectionError( + f"curl to {_url_path(url)} failed: {result.stderr or result.stdout}" + ) + header_art = self._shell.read_file(header_path, encoding="utf-8", strip=False) + body_art = self._shell.read_file(body_path, encoding=None) + if isinstance(header_art, BinaryFileArtifact): + header_text = header_art.contents.decode("utf-8", errors="replace") + else: + header_text = header_art.contents_str() + if isinstance(body_art, BinaryFileArtifact): + body = body_art.contents + else: + body = body_art.contents_str().encode("utf-8") + return CurlResponse( + status_code=status_code, + content=body, + headers=parse_curl_headers(header_text), + ) + finally: + self._rm(header_path, body_path) + + def _ensure_session(self): + return None + + def _login_session(self) -> None: + return None + + def get_response(self, path: Union[str, "RedfishPath"]) -> CurlResponse: + """GET a Redfish path via curl on the SSH host. + + Args: + path: Redfish URI or RedfishPath. + + Returns: + CurlResponse with status, headers, and body. + """ + return self._curl(self._abs_url(str(path))) + + def post( + self, path: Union[str, "RedfishPath"], json: Optional[dict[str, Any]] = None + ) -> CurlResponse: + """POST JSON to a Redfish path via curl on the SSH host. + + Args: + path: Redfish URI or RedfishPath. + json: JSON body. + + Returns: + CurlResponse with status, headers, and body. + """ + payload = json_lib.dumps(json or {}) + extra = ( + f"-X POST -H {shlex.quote('Content-Type: application/json')} " + f"-d {shlex.quote(payload)} " + ) + return self._curl(self._abs_url(str(path)), extra_flags=extra) + + def get(self, path: RedfishPath) -> dict[str, Any]: + """GET a Redfish path and return the JSON body. + + Args: + path: Redfish path object. + + Returns: + Parsed JSON object. + + Raises: + RedfishConnectionError: When the GET fails or the body is not JSON. + """ + path_str = str(path) + resp = self.get_response(path_str) + if not resp.ok: + raise RedfishConnectionError( + f"GET {path_str} failed: {resp.status_code} {resp.reason}", + response=resp, + ) + try: + data = resp.json() + except (ValueError, json_lib.JSONDecodeError) as exc: + raise RedfishConnectionError( + f"GET {path_str} returned invalid JSON: {exc}", + response=resp, + ) from exc + if not isinstance(data, dict): + raise RedfishConnectionError( + f"GET {path_str} did not return a JSON object", + response=resp, + ) + return data + + def copy(self) -> "SshProxyRedfishConnection": + """Return a wrapper that shares the same SSH session. + + Returns: + SshProxyRedfishConnection using the same RemoteShell. + """ + return SshProxyRedfishConnection( + shell=self._shell, + base_url=self.base_url, + timeout=self.timeout, + api_root=self.api_root, + ) + + def close(self) -> None: + self._session = None + self._session_token = None + self._session_uri = None + + def _abs_url(self, path: str) -> str: + if path.startswith("http"): + return path + return urljoin(self.base_url + "/", path.lstrip("/")) diff --git a/nodescraper/connection/redfish/ssh_proxy_manager.py b/nodescraper/connection/redfish/ssh_proxy_manager.py new file mode 100644 index 00000000..0c601af4 --- /dev/null +++ b/nodescraper/connection/redfish/ssh_proxy_manager.py @@ -0,0 +1,175 @@ +############################################################################### +# +# 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 ..inband.inbandremote import RemoteShell, SSHConnectionError +from .redfish_connection import RedfishConnectionError +from .redfish_manager import _build_base_url +from .ssh_proxy_connection import SshProxyRedfishConnection +from .ssh_proxy_params import RedfishSshProxyConnectionParams + + +class RedfishSshProxyConnectionManager( + ConnectionManager[SshProxyRedfishConnection, RedfishSshProxyConnectionParams] +): + """SSH to a BMC and curl Redfish on an address reachable only from that host (AMC).""" + + 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[RedfishSshProxyConnectionParams] = None, + **kwargs, + ): + super().__init__( + system_info, + logger, + max_event_priority_level, + parent, + task_result_hooks, + connection_args, + **kwargs, + ) + + def connect(self) -> TaskResult: + """Open BMC SSH and verify AMC Redfish via curl. + + Returns: + TaskResult for the connection attempt. + """ + if not self.connection_args: + self._log_event( + category=EventCategory.RUNTIME, + description="No Redfish SSH-proxy connection parameters provided", + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result + + raw = self.connection_args + if isinstance(raw, dict): + params = RedfishSshProxyConnectionParams.model_validate(raw) + elif isinstance(raw, RedfishSshProxyConnectionParams): + params = raw + else: + self._log_event( + category=EventCategory.RUNTIME, + description=( + "Redfish SSH-proxy connection_args must be dict or " + "RedfishSshProxyConnectionParams" + ), + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result + + base_url = _build_base_url(str(params.host), params.port, params.use_https) + shell: Optional[RemoteShell] = None + try: + self.logger.info( + "Connecting SSH proxy Redfish: ssh=%s curl=%s", + params.ssh.hostname, + base_url, + ) + shell = RemoteShell(params.ssh) + shell.connect_ssh() + conn = SshProxyRedfishConnection( + shell=shell, + base_url=base_url, + timeout=params.timeout_seconds, + api_root=params.api_root, + ) + conn.get_service_root() + self.connection = conn + except SSHConnectionError as exc: + self._log_event( + category=EventCategory.SSH, + description=str(exc), + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + self.connection = None + if shell is not None: + try: + shell.client.close() + except Exception: + pass + except RedfishConnectionError as exc: + self._log_event( + category=EventCategory.RUNTIME, + description=str(exc), + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + self.connection = None + if shell is not None: + try: + shell.client.close() + except Exception: + pass + except Exception as exc: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Redfish SSH-proxy connection failed: {exc}", + data=get_exception_traceback(exc), + priority=EventPriority.CRITICAL, + console_log=True, + ) + self.result.status = ExecutionStatus.EXECUTION_FAILURE + self.connection = None + if shell is not None: + try: + shell.client.close() + except Exception: + pass + return self.result + + def disconnect(self) -> None: + """Close the curl wrapper and the BMC SSH session.""" + conn = self.connection + if isinstance(conn, SshProxyRedfishConnection): + conn.close() + try: + conn._shell.client.close() + except Exception: + pass + super().disconnect() diff --git a/nodescraper/connection/redfish/ssh_proxy_params.py b/nodescraper/connection/redfish/ssh_proxy_params.py new file mode 100644 index 00000000..1c7f1533 --- /dev/null +++ b/nodescraper/connection/redfish/ssh_proxy_params.py @@ -0,0 +1,58 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from __future__ import annotations + +from typing import Optional, Union + +from pydantic import BaseModel, ConfigDict, Field +from pydantic.networks import IPvAnyAddress + +from nodescraper.connection.inband.sshparams import SSHConnectionParams + +from .redfish_connection import DEFAULT_REDFISH_API_ROOT + + +class RedfishSshProxyConnectionParams(BaseModel): + """Redfish over SSH: curl on the BMC to an AMC (or other) address reachable from that host.""" + + model_config = ConfigDict(arbitrary_types_allowed=True) + + host: Union[IPvAnyAddress, str] = Field( + description="Redfish host as seen from the SSH target (internal AMC address).", + ) + ssh: SSHConnectionParams = Field( + description="SSH parameters for the BMC (or other host) that can reach host.", + ) + port: Optional[int] = Field(default=80, ge=1, le=65535) + use_https: bool = Field( + default=False, + description="Use https when curling Redfish from the SSH target.", + ) + timeout_seconds: float = Field(default=60.0, gt=0, le=3600) + api_root: str = Field( + default=DEFAULT_REDFISH_API_ROOT, + description="Redfish API path (e.g. redfish/v1).", + ) diff --git a/nodescraper/plugins/ooband/amc_redfish_diag/__init__.py b/nodescraper/plugins/ooband/amc_redfish_diag/__init__.py new file mode 100644 index 00000000..2c342f0d --- /dev/null +++ b/nodescraper/plugins/ooband/amc_redfish_diag/__init__.py @@ -0,0 +1,17 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +############################################################################### +from .amc_diag_collector import AmcRedfishDiagCollector +from .amc_diag_plugin import AmcRedfishDiagPlugin +from .collector_args import AmcDiagCollectionSpec, AmcRedfishDiagCollectorArgs + +__all__ = [ + "AmcDiagCollectionSpec", + "AmcRedfishDiagCollector", + "AmcRedfishDiagCollectorArgs", + "AmcRedfishDiagPlugin", +] diff --git a/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_collector.py b/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_collector.py new file mode 100644 index 00000000..faaa7f83 --- /dev/null +++ b/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_collector.py @@ -0,0 +1,205 @@ +############################################################################### +# +# 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 +from typing import Any, Optional + +from nodescraper.base import RedfishDataCollector +from nodescraper.connection.redfish import collect_oem_diagnostic_data +from nodescraper.connection.redfish.redfish_constants import RF_MEMBERS, RF_ODATA_ID +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.models import TaskResult +from nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_data import ( + OemDiagTypeResult, + RedfishOemDiagDataModel, +) +from nodescraper.utils import pascal_to_snake + +from .collector_args import AmcDiagCollectionSpec, AmcRedfishDiagCollectorArgs + +_COLLECT_ACTION_KEYS = ( + "LogService.CollectDiagnosticData", + "#LogService.CollectDiagnosticData", +) + + +def _collection_key(spec: AmcDiagCollectionSpec) -> str: + """Build a stable results-dict key for one CollectDiagnosticData job. + + Args: + spec: Collection request. + + Returns: + Root, diagnostic type, and optional OEM type joined by colons. + """ + if spec.oem_data_type: + return f"{spec.root}:{spec.diagnostic_data_type}:{spec.oem_data_type}" + return f"{spec.root}:{spec.diagnostic_data_type}" + + +class AmcRedfishDiagCollector( + RedfishDataCollector[RedfishOemDiagDataModel, AmcRedfishDiagCollectorArgs] +): + """Collect AMC Manager and Systems diagnostic bundles through SSH-proxy Redfish.""" + + DATA_MODEL = RedfishOemDiagDataModel + + DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = ( + "SSH-proxy Redfish GET of Managers/Systems LogServices that advertise CollectDiagnosticData.", + "CollectDiagnosticData for each collection_args.collections entry (Manager dump and OEM AllLogs by default).", + "Optional binary archives under the plugin log path when log_path is set.", + ) + + def __init__(self, *args: Any, **kwargs: Any) -> None: + self.log_path = kwargs.pop("log_path", None) + super().__init__(*args, **kwargs) + + def collect_data( + self, args: Optional[AmcRedfishDiagCollectorArgs] = None + ) -> tuple[TaskResult, Optional[RedfishOemDiagDataModel]]: + """Discover AMC log services and run CollectDiagnosticData jobs. + + Args: + args: Collection args. Defaults to the AMC manager dump plus systems AllLogs. + + Returns: + TaskResult and RedfishOemDiagDataModel keyed by collection spec. + """ + if args is None: + args = AmcRedfishDiagCollectorArgs() + jobs = list(args.collections) if args.collections else [] + if not jobs: + self.result.message = "No AMC diagnostic collections configured" + self.result.status = ExecutionStatus.NOT_RAN + return self.result, None + + if self.log_path: + output_dir = ( + Path(self.log_path) + / pascal_to_snake(self.parent or "") + / pascal_to_snake(self.__class__.__name__) + / "diag_logs" + ).resolve() + output_dir.mkdir(parents=True, exist_ok=True) + self.logger.info( + "(AmcRedfishDiagPlugin) Diagnostic archives will be written to: %s", + output_dir, + ) + else: + output_dir = None + + results: dict[str, OemDiagTypeResult] = {} + for spec in jobs: + key = _collection_key(spec) + log_service = self._find_log_service(spec.root, args) + if not log_service: + missing_err = f"No CollectDiagnosticData LogService under {spec.root}" + self._log_event( + category=EventCategory.RUNTIME, + description=f"AMC diag {key}: {missing_err}", + priority=EventPriority.WARNING, + console_log=True, + ) + results[key] = OemDiagTypeResult(success=False, error=missing_err, metadata=None) + continue + _log_bytes, metadata, collect_err = collect_oem_diagnostic_data( + self.connection, + log_service_path=log_service, + oem_diagnostic_type=spec.oem_data_type or None, + diagnostic_data_type=spec.diagnostic_data_type, + task_timeout_s=args.task_timeout_s, + output_dir=output_dir, + logger=self.logger, + ) + if collect_err: + self._log_event( + category=EventCategory.RUNTIME, + description=f"AMC diag {key}: {collect_err}", + priority=EventPriority.WARNING, + console_log=True, + ) + results[key] = OemDiagTypeResult(success=False, error=collect_err, metadata=None) + else: + results[key] = OemDiagTypeResult(success=True, error=None, metadata=metadata) + + success_count = sum(1 for r in results.values() if r.success) + self.result.message = f"AMC diag: {success_count}/{len(results)} collections succeeded" + self.result.status = ExecutionStatus.OK if success_count else ExecutionStatus.ERROR + return self.result, RedfishOemDiagDataModel(results=results) + + def _member_paths(self, root: str, args: AmcRedfishDiagCollectorArgs) -> list[str]: + """Resolve Systems or Managers member URIs to probe. + + Args: + root: Managers or Systems. + args: Collection args with optional member Ids. + + Returns: + Redfish paths without a leading slash. + """ + api_root = (getattr(self.connection, "api_root", None) or "redfish/v1").strip("/") + ids = args.manager_ids if root == "Managers" else args.system_ids + if ids: + return [f"{api_root}/{root}/{member_id}" for member_id in ids] + listing = self._run_redfish_get(f"/{api_root}/{root}") + if not listing.success or not listing.data: + return [] + paths: list[str] = [] + for member in listing.data.get(RF_MEMBERS) or []: + if not isinstance(member, dict): + continue + odata_id = member.get(RF_ODATA_ID) + if isinstance(odata_id, str) and odata_id.strip(): + paths.append(odata_id.strip().lstrip("/")) + return paths + + def _find_log_service(self, root: str, args: AmcRedfishDiagCollectorArgs) -> Optional[str]: + """Find the first LogService under root that advertises CollectDiagnosticData. + + Args: + root: Managers or Systems. + args: Collection args with optional member Ids. + + Returns: + LogService path, or None if none found. + """ + for member_path in self._member_paths(root, args): + ls_list = self._run_redfish_get(f"/{member_path}/LogServices") + if not ls_list.success or not ls_list.data: + continue + for member in ls_list.data.get(RF_MEMBERS) or []: + if not isinstance(member, dict): + continue + odata_id = member.get(RF_ODATA_ID) + if not isinstance(odata_id, str) or not odata_id.strip(): + continue + ls_path = odata_id.strip().lstrip("/") + ls_body = self._run_redfish_get(f"/{ls_path}") + if not ls_body.success or not ls_body.data: + continue + actions = ls_body.data.get("Actions") or {} + if any(key in actions for key in _COLLECT_ACTION_KEYS): + return ls_path + return None diff --git a/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_plugin.py b/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_plugin.py new file mode 100644 index 00000000..dba2b7a8 --- /dev/null +++ b/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_plugin.py @@ -0,0 +1,65 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +from nodescraper.connection.redfish import ( + RedfishSshProxyConnectionManager, + RedfishSshProxyConnectionParams, +) +from nodescraper.interfaces import DataPlugin +from nodescraper.plugins.ooband.redfish_oem_diag.analyzer_args import ( + RedfishOemDiagAnalyzerArgs, +) +from nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_analyzer import ( + RedfishOemDiagAnalyzer, +) +from nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_data import ( + RedfishOemDiagDataModel, +) + +from .amc_diag_collector import AmcRedfishDiagCollector +from .collector_args import AmcRedfishDiagCollectorArgs + + +class AmcRedfishDiagPlugin( + DataPlugin[ + RedfishSshProxyConnectionManager, + RedfishSshProxyConnectionParams, + RedfishOemDiagDataModel, + AmcRedfishDiagCollectorArgs, + RedfishOemDiagAnalyzerArgs, + ] +): + """AMC CollectDiagnosticData over SSH-proxy Redfish for manager and system diagnostic bundles. + + Configure RedfishSshProxyConnectionManager: ssh to the BMC, host/port of the AMC Redfish + URL reachable from that BMC. collection_args selects Managers Manager dump and Systems OEM AllLogs. + """ + + CONNECTION_TYPE = RedfishSshProxyConnectionManager + DATA_MODEL = RedfishOemDiagDataModel + COLLECTOR = AmcRedfishDiagCollector + ANALYZER = RedfishOemDiagAnalyzer + COLLECTOR_ARGS = AmcRedfishDiagCollectorArgs + ANALYZER_ARGS = RedfishOemDiagAnalyzerArgs diff --git a/nodescraper/plugins/ooband/amc_redfish_diag/collector_args.py b/nodescraper/plugins/ooband/amc_redfish_diag/collector_args.py new file mode 100644 index 00000000..1e8dd5e3 --- /dev/null +++ b/nodescraper/plugins/ooband/amc_redfish_diag/collector_args.py @@ -0,0 +1,86 @@ +############################################################################### +# +# 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 pydantic import BaseModel, Field + +from nodescraper.models import CollectorArgs + +DEFAULT_TASK_TIMEOUT_S = 1800 + + +class AmcDiagCollectionSpec(BaseModel): + """One CollectDiagnosticData request for an AMC telemetry bundle.""" + + root: str = Field( + description="Redfish root collection to search: Managers or Systems.", + ) + diagnostic_data_type: str = Field( + description="DMTF DiagnosticDataType (Manager, OEM, and similar).", + ) + oem_data_type: str = Field( + default="", + description="OEMDiagnosticDataType when diagnostic_data_type is OEM.", + ) + + +def _default_collections() -> list[AmcDiagCollectionSpec]: + """Return the AMC manager dump plus systems AllLogs jobs. + + Returns: + Default CollectDiagnosticData specs. + """ + return [ + AmcDiagCollectionSpec(root="Managers", diagnostic_data_type="Manager"), + AmcDiagCollectionSpec( + root="Systems", + diagnostic_data_type="OEM", + oem_data_type="AllLogs", + ), + ] + + +class AmcRedfishDiagCollectorArgs(CollectorArgs): + """Collector args for AMC Redfish diagnostic dumps via SSH-proxy curl.""" + + manager_ids: list[str] = Field( + default_factory=lambda: ["AMC"], + description="Manager member Ids to probe; empty walks the Managers collection.", + ) + system_ids: list[str] = Field( + default_factory=lambda: ["MI450", "Accelerators"], + description="System member Ids to probe; empty walks the Systems collection.", + ) + collections: list[AmcDiagCollectionSpec] = Field( + default_factory=_default_collections, + description="CollectDiagnosticData jobs to run (Managers manager dump then Systems OEM AllLogs).", + ) + task_timeout_s: int = Field( + default=DEFAULT_TASK_TIMEOUT_S, + ge=1, + le=3600, + description="Max seconds to wait for each CollectDiagnosticData task.", + ) diff --git a/test/unit/connection/redfish/test_redfish_connection_ssl.py b/test/unit/connection/redfish/test_redfish_connection_ssl.py new file mode 100644 index 00000000..a4d0c9c4 --- /dev/null +++ b/test/unit/connection/redfish/test_redfish_connection_ssl.py @@ -0,0 +1,80 @@ +############################################################################### +# +# 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, patch + +import pytest + +from nodescraper.connection.redfish import RedfishConnection + + +def test_verify_ssl_false_disables_trust_env() -> None: + conn = RedfishConnection( + base_url="https://bmc.example", + username="u", + password="p", + verify_ssl=False, + use_session_auth=False, + ) + with patch( + "nodescraper.connection.redfish.redfish_connection.requests.Session" + ) as mock_session_cls: + mock_session = MagicMock() + mock_session_cls.return_value = mock_session + session = conn._ensure_session() + + assert session is mock_session + assert mock_session.verify is False + assert mock_session.trust_env is False + + +@pytest.mark.parametrize("env_var", ["REQUESTS_CA_BUNDLE", "CURL_CA_BUNDLE"]) +def test_verify_ssl_false_passes_verify_on_login( + env_var: str, monkeypatch: pytest.MonkeyPatch +) -> None: + monkeypatch.setenv(env_var, "/etc/ssl/certs/ca-bundle.crt") + conn = RedfishConnection( + base_url="https://bmc.example", + username="u", + password="p", + verify_ssl=False, + ) + with patch( + "nodescraper.connection.redfish.redfish_connection.requests.Session" + ) as mock_session_cls: + mock_session = MagicMock() + mock_response = MagicMock() + mock_response.ok = True + mock_response.headers = { + "X-Auth-Token": "token", + "Location": "/redfish/v1/SessionService/Sessions/1", + } + mock_session.post.return_value = mock_response + mock_session_cls.return_value = mock_session + conn._ensure_session() + + mock_session.post.assert_called_once() + assert mock_session.verify is False + assert mock_session.trust_env is False diff --git a/test/unit/connection/redfish/test_redfish_oem_diag.py b/test/unit/connection/redfish/test_redfish_oem_diag.py index 727c1b69..e0c2509a 100644 --- a/test/unit/connection/redfish/test_redfish_oem_diag.py +++ b/test/unit/connection/redfish/test_redfish_oem_diag.py @@ -24,7 +24,7 @@ # ############################################################################### import logging -from unittest.mock import MagicMock +from unittest.mock import MagicMock, patch from requests.status_codes import codes @@ -35,6 +35,8 @@ _download_log_and_save, _get_task_monitor_uri, _strip_port_from_url, + _task_resource_path, + collect_oem_diagnostic_data, get_oem_diagnostic_allowable_values, ) @@ -184,3 +186,105 @@ def test_writes_archive_and_metadata_to_output_dir(self, tmp_path): assert (tmp_path / "AllLogs.tar.xz").read_bytes() == b"log bytes" metadata = (tmp_path / "AllLogs_log_entry.json").read_text(encoding="utf-8") assert "Id" in metadata and "1" in metadata + + +def test_collect_manager_diagnostic_payload(): + conn = MagicMock() + resp = MagicMock() + resp.status_code = 500 + resp.text = "fail" + conn.post.return_value = resp + collect_oem_diagnostic_data( + conn, + "redfish/v1/Managers/dummy-amc/LogServices/Dump", + diagnostic_data_type="Manager", + ) + payload = conn.post.call_args.kwargs["json"] + assert payload == {"DiagnosticDataType": "Manager"} + + +def test_task_resource_path_accepts_tasks_not_monitors(): + assert ( + _task_resource_path("/redfish/v1/TaskService/Tasks/dummy-1") + == "redfish/v1/TaskService/Tasks/dummy-1" + ) + assert _task_resource_path("redfish/v1/TaskService/TaskMonitors/1") is None + + +def test_collect_polls_task_resource_until_completed(): + conn = MagicMock() + conn.base_url = "https://bmc.example.test" + post_resp = MagicMock() + post_resp.status_code = codes.accepted + post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} + post_resp.text = "" + post_resp.json.return_value = { + "@odata.id": "/redfish/v1/TaskService/Tasks/dummy-1", + "TaskState": "Running", + } + conn.post.return_value = post_resp + + running = MagicMock() + running.status_code = codes.ok + running.json.return_value = {"TaskState": "Running"} + done = MagicMock() + done.status_code = codes.ok + done.json.return_value = { + "TaskState": "Completed", + "Payload": { + "HttpHeaders": [ + "Location: /redfish/v1/Systems/dummy-system/LogServices/DiagLogs/Entries/1" + ] + }, + } + entry = MagicMock() + entry.status_code = codes.ok + entry.json.return_value = { + "Id": "1", + "AdditionalDataURI": ( + "/redfish/v1/Systems/dummy-system/LogServices/DiagLogs/Entries/1/attachment" + ), + } + attachment = MagicMock() + attachment.status_code = codes.ok + attachment.content = b"archive" + conn.get_response.side_effect = [running, done, entry, attachment] + + with patch("nodescraper.connection.redfish.redfish_oem_diag.time.sleep"): + log_bytes, metadata, err = collect_oem_diagnostic_data( + conn, + "redfish/v1/Systems/dummy-system/LogServices/DiagLogs", + oem_diagnostic_type="AllLogs", + task_timeout_s=30, + ) + assert err is None + assert log_bytes == b"archive" + assert metadata is not None + assert metadata["Id"] == "1" + polled = [c.args[0] for c in conn.get_response.call_args_list] + assert polled[0] == "redfish/v1/TaskService/Tasks/dummy-1" + assert polled[1] == "redfish/v1/TaskService/Tasks/dummy-1" + + +def test_collect_taskmonitor_404_does_not_spin(): + conn = MagicMock() + conn.base_url = "https://bmc.example.test" + post_resp = MagicMock() + post_resp.status_code = codes.accepted + post_resp.headers = {"Location": "/redfish/v1/TaskService/TaskMonitors/1"} + post_resp.text = "" + post_resp.json.return_value = {} + conn.post.return_value = post_resp + missing = MagicMock() + missing.status_code = codes.not_found + conn.get_response.return_value = missing + + _log_bytes, _metadata, err = collect_oem_diagnostic_data( + conn, + "redfish/v1/Systems/dummy-system/LogServices/DiagLogs", + oem_diagnostic_type="AllLogs", + task_timeout_s=30, + ) + assert err is not None + assert "404" in err + assert conn.get_response.call_count == 1 diff --git a/test/unit/connection/redfish/test_redfish_transport_errors.py b/test/unit/connection/redfish/test_redfish_transport_errors.py new file mode 100644 index 00000000..56d0b152 --- /dev/null +++ b/test/unit/connection/redfish/test_redfish_transport_errors.py @@ -0,0 +1,118 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +# Permission is hereby granted, free of charge, to any person obtaining a copy +# of this software and associated documentation files (the "Software"), to deal +# in the Software without restriction, including without limitation the rights +# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +# copies of the Software, and to permit persons to whom the Software is +# furnished to do so, subject to the following conditions: +# +# The above copyright notice and this permission notice shall be included in all +# copies or substantial portions of the Software. +# +# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +# SOFTWARE. +# +############################################################################### +import socket +from unittest.mock import MagicMock, patch + +import pytest +import requests +from pydantic import SecretStr + +from nodescraper.connection.redfish import RedfishConnection, RedfishConnectionError +from nodescraper.connection.redfish.redfish_manager import RedfishConnectionManager +from nodescraper.connection.redfish.redfish_params import RedfishConnectionParams +from nodescraper.enums import ExecutionStatus, SystemLocation +from nodescraper.models import SystemInfo + +_TEST_HOST = "10.6.189.112" + + +@pytest.fixture +def rf_conn() -> RedfishConnection: + return RedfishConnection( + base_url=f"https://{_TEST_HOST}", + username="user", + password="pass", + use_session_auth=False, + ) + + +def test_get_response_wraps_connect_timeout(rf_conn: RedfishConnection) -> None: + with patch.object(rf_conn, "_ensure_session") as ensure_session: + session = MagicMock() + session.get.side_effect = requests.exceptions.ConnectTimeout("timed out") + ensure_session.return_value = session + + with pytest.raises(RedfishConnectionError, match="timed out"): + rf_conn.get_response("redfish/v1") + + +def test_get_response_wraps_connection_refused(rf_conn: RedfishConnection) -> None: + root = ConnectionRefusedError(111, "Connection refused") + wrapped = requests.exceptions.ConnectionError("failed", response=None) + wrapped.__cause__ = root + + with patch.object(rf_conn, "_ensure_session") as ensure_session: + session = MagicMock() + session.get.side_effect = wrapped + ensure_session.return_value = session + + with pytest.raises(RedfishConnectionError, match="connection failed"): + rf_conn.get_response("redfish/v1") + + +def test_get_response_wraps_name_resolution_error(rf_conn: RedfishConnection) -> None: + dns_error = requests.exceptions.ConnectionError( + f"HTTPSConnectionPool(host='{_TEST_HOST}', port=443): " + f"Failed to resolve '{_TEST_HOST}' " + "([Errno -5] No address associated with hostname)" + ) + dns_error.__cause__ = socket.gaierror(-5, "No address associated with hostname") + + with patch.object(rf_conn, "_ensure_session") as ensure_session: + session = MagicMock() + session.get.side_effect = dns_error + ensure_session.return_value = session + + with pytest.raises(RedfishConnectionError, match="hostname could not be resolved"): + rf_conn.get_response("redfish/v1") + + +def test_redfish_manager_connect_logs_clean_timeout() -> None: + manager = RedfishConnectionManager( + system_info=SystemInfo(name="test", location=SystemLocation.LOCAL), + connection_args=RedfishConnectionParams( + host=_TEST_HOST, + username="user", + password=SecretStr("pass"), + use_https=True, + use_session_auth=False, + ), + ) + with patch( + "nodescraper.connection.redfish.redfish_manager.RedfishConnection" + ) as connection_cls: + connection = connection_cls.return_value + connection.get_service_root.side_effect = RedfishConnectionError( + f"Redfish connection timed out: {_TEST_HOST}" + ) + + result = manager.connect() + + assert result.status == ExecutionStatus.EXECUTION_FAILURE + assert len(result.events) == 1 + assert "timed out" in result.events[0].description + assert "traceback" not in result.events[0].data + assert "exception_type" not in result.events[0].data diff --git a/test/unit/connection/redfish/test_ssh_proxy_connection.py b/test/unit/connection/redfish/test_ssh_proxy_connection.py new file mode 100644 index 00000000..6ad329fd --- /dev/null +++ b/test/unit/connection/redfish/test_ssh_proxy_connection.py @@ -0,0 +1,157 @@ +############################################################################### +# +# 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 shlex +from unittest.mock import MagicMock, patch + +import pytest +from pydantic import ValidationError + +from nodescraper.connection.inband.inband import BaseFileArtifact, CommandArtifact +from nodescraper.connection.redfish.ssh_proxy_connection import ( + CurlResponse, + SshProxyRedfishConnection, + parse_curl_headers, +) +from nodescraper.connection.redfish.ssh_proxy_manager import ( + RedfishSshProxyConnectionManager, +) +from nodescraper.connection.redfish.ssh_proxy_params import ( + RedfishSshProxyConnectionParams, +) +from nodescraper.enums import ExecutionStatus + + +class FakeRemoteShell: + def __init__(self, status=200, headers="", body=b"{}"): + self.status = status + self.headers = headers + self.body = body + self.files = {} + self.commands = [] + self._n = 0 + self.client = MagicMock() + + def run_command(self, command, sudo=False, timeout=30, strip=True): + self.commands.append(command) + if command == "mktemp": + self._n += 1 + path = f"/tmp/ns-proxy-{self._n}" + self.files[path] = b"" + return CommandArtifact(command=command, stdout=path, stderr="", exit_code=0) + if command.startswith("curl"): + parts = shlex.split(command) + hdr = parts[parts.index("-D") + 1] + body_path = parts[parts.index("-o") + 1] + self.files[hdr] = self.headers.encode("utf-8") + self.files[body_path] = self.body + return CommandArtifact(command=command, stdout=str(self.status), stderr="", exit_code=0) + if command.startswith("rm -f"): + return CommandArtifact(command=command, stdout="", stderr="", exit_code=0) + return CommandArtifact(command=command, stdout="", stderr="unexpected", exit_code=1) + + def read_file(self, filename, encoding="utf-8", strip=True): + return BaseFileArtifact.from_bytes( + filename=filename, + raw_contents=self.files[filename], + encoding=encoding, + strip=strip, + ) + + +def test_parse_curl_headers_last_hop_wins(): + text = ( + "HTTP/1.1 301 Moved\n" + "Location: /old\n" + "\n" + "HTTP/1.1 200 OK\n" + "Location: /redfish/v1/TaskService/Tasks/1\n" + "Content-Type: application/json\n" + "\n" + ) + headers = parse_curl_headers(text) + assert headers["Location"] == "/redfish/v1/TaskService/Tasks/1" + assert headers["Content-Type"] == "application/json" + + +def test_curl_response_json_and_ok(): + resp = CurlResponse(200, b'{"Id": "root"}') + assert resp.ok + assert resp.json() == {"Id": "root"} + assert resp.reason == "OK" + + +def test_ssh_proxy_get_uses_quoted_internal_url(): + shell = FakeRemoteShell( + headers="HTTP/1.1 200 OK\nContent-Type: application/json\n\n", + body=b'{"RedfishVersion": "1.15.0"}', + ) + conn = SshProxyRedfishConnection(shell, "http://192.0.2.10:80", api_root="redfish/v1") + data = conn.get_service_root() + assert data["RedfishVersion"] == "1.15.0" + curl_cmds = [c for c in shell.commands if c.startswith("curl")] + assert len(curl_cmds) == 1 + assert "http://192.0.2.10:80/redfish/v1" in curl_cmds[0] + + +def test_ssh_proxy_post_includes_json_body(): + shell = FakeRemoteShell(body=b'{"TaskState": "Running"}') + conn = SshProxyRedfishConnection(shell, "http://192.0.2.10:80") + resp = conn.post( + "redfish/v1/Managers/dummy-amc/LogServices/Dump/Actions/LogService.CollectDiagnosticData", + json={"DiagnosticDataType": "Manager"}, + ) + assert resp.status_code == 200 + curl_cmds = [c for c in shell.commands if c.startswith("curl")] + assert "-X POST" in curl_cmds[0] + assert "DiagnosticDataType" in curl_cmds[0] + + +def test_ssh_proxy_manager_connect_success(system_info): + shell = FakeRemoteShell(body=b'{"RedfishVersion": "1.15.0"}') + shell.connect_ssh = MagicMock() + params = { + "host": "192.0.2.10", + "port": 80, + "use_https": False, + "ssh": { + "hostname": "bmc.example.test", + "username": "testuser", + "key_filename": "/tmp/dummy_id", + }, + } + mgr = RedfishSshProxyConnectionManager(system_info=system_info, connection_args=params) + with patch( + "nodescraper.connection.redfish.ssh_proxy_manager.RemoteShell", + return_value=shell, + ): + result = mgr.connect() + assert result.status in (ExecutionStatus.UNSET, ExecutionStatus.OK) + assert mgr.connection is not None + + +def test_ssh_proxy_params_require_ssh(): + with pytest.raises(ValidationError): + RedfishSshProxyConnectionParams(host="192.0.2.10") diff --git a/test/unit/framework/test_plugin_execution_target.py b/test/unit/framework/test_plugin_execution_target.py index 6a29bba0..1433c55a 100644 --- a/test/unit/framework/test_plugin_execution_target.py +++ b/test/unit/framework/test_plugin_execution_target.py @@ -53,11 +53,11 @@ def test_format_in_band_target_summary_remote(): SystemInfo(name="workstation01", location=SystemLocation.REMOTE), connection_configs={ "InBandConnectionManager": { - "hostname": "ctheliosp-1b112-b34-1.mnb.dcgpu", + "hostname": "sut.example.com", } }, ) - assert summary == "In-band default: remote host via SSH (ctheliosp-1b112-b34-1.mnb.dcgpu)" + assert summary == "In-band default: remote host via SSH (sut.example.com)" def test_format_plugin_execution_target_redfish(): diff --git a/test/unit/plugin/test_amc_redfish_diag.py b/test/unit/plugin/test_amc_redfish_diag.py new file mode 100644 index 00000000..080a44c1 --- /dev/null +++ b/test/unit/plugin/test_amc_redfish_diag.py @@ -0,0 +1,139 @@ +############################################################################### +# +# 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 patch + +import pytest + +from nodescraper.connection.redfish import ( + RedfishGetResult, + RedfishSshProxyConnectionManager, +) +from nodescraper.enums import ExecutionStatus +from nodescraper.pluginregistry import PluginRegistry +from nodescraper.plugins.ooband.amc_redfish_diag import ( + AmcDiagCollectionSpec, + AmcRedfishDiagCollector, + AmcRedfishDiagCollectorArgs, + AmcRedfishDiagPlugin, +) + + +@pytest.fixture +def amc_collector(system_info, redfish_conn_mock): + redfish_conn_mock.api_root = "redfish/v1" + return AmcRedfishDiagCollector( + system_info=system_info, + connection=redfish_conn_mock, + ) + + +def test_amc_redfish_diag_plugin_registers(): + assert AmcRedfishDiagPlugin.is_valid() + assert AmcRedfishDiagPlugin.CONNECTION_TYPE is RedfishSshProxyConnectionManager + assert "AmcRedfishDiagPlugin" in PluginRegistry().plugins + assert "RedfishSshProxyConnectionManager" in PluginRegistry().connection_managers + + +def test_amc_collector_no_jobs(amc_collector): + result, data = amc_collector.collect_data(args=AmcRedfishDiagCollectorArgs(collections=[])) + assert result.status == ExecutionStatus.NOT_RAN + assert data is None + + +def _get_side_effect(path): + p = str(path) + if p.endswith("/LogServices"): + if "Managers" in p: + oid = "/redfish/v1/Managers/dummy-amc/LogServices/Dump" + else: + oid = "/redfish/v1/Systems/dummy-system/LogServices/DiagLogs" + return RedfishGetResult( + path=p, + success=True, + data={"Members": [{"@odata.id": oid}]}, + status_code=200, + ) + if p.endswith("/Dump") or p.endswith("/DiagLogs"): + return RedfishGetResult( + path=p, + success=True, + data={"Actions": {"#LogService.CollectDiagnosticData": {}}}, + status_code=200, + ) + return RedfishGetResult(path=p, success=False, error="unexpected", status_code=404) + + +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.collect_oem_diagnostic_data") +def test_amc_collector_runs_manager_and_system_jobs(mock_collect, amc_collector): + mock_collect.return_value = (b"archive", {"Id": "1"}, None) + amc_collector.connection.run_get.side_effect = _get_side_effect + result, data = amc_collector.collect_data( + args=AmcRedfishDiagCollectorArgs( + manager_ids=["dummy-amc"], + system_ids=["dummy-system"], + collections=[ + AmcDiagCollectionSpec(root="Managers", diagnostic_data_type="Manager"), + AmcDiagCollectionSpec( + root="Systems", + diagnostic_data_type="OEM", + oem_data_type="AllLogs", + ), + ], + ) + ) + assert result.status == ExecutionStatus.OK + assert data is not None + assert "Managers:Manager" in data.results + assert "Systems:OEM:AllLogs" in data.results + assert mock_collect.call_count == 2 + first_kwargs = mock_collect.call_args_list[0].kwargs + assert first_kwargs["diagnostic_data_type"] == "Manager" + second_kwargs = mock_collect.call_args_list[1].kwargs + assert second_kwargs["diagnostic_data_type"] == "OEM" + assert second_kwargs["oem_diagnostic_type"] == "AllLogs" + + +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.collect_oem_diagnostic_data") +def test_amc_collector_missing_log_service(mock_collect, amc_collector): + amc_collector.connection.run_get.return_value = RedfishGetResult( + path="/redfish/v1/Managers/dummy-amc/LogServices", + success=True, + data={"Members": []}, + status_code=200, + ) + result, data = amc_collector.collect_data( + args=AmcRedfishDiagCollectorArgs( + manager_ids=["dummy-amc"], + system_ids=["dummy-system"], + collections=[ + AmcDiagCollectionSpec(root="Managers", diagnostic_data_type="Manager"), + ], + ) + ) + assert result.status == ExecutionStatus.ERROR + assert data is not None + assert data.results["Managers:Manager"].success is False + mock_collect.assert_not_called()