Skip to content
Merged
10 changes: 7 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -269,17 +269,21 @@ Redfish plugins can collect from multiple BMCs concurrently. In-band plugins and
}
```

Multi-target mode applies to Redfish plugins (`RedfishEndpointPlugin`, `RedfishOemDiagPlugin`, and other plugins based on `OOBandDataPlugin`). Targets are collected concurrently. Wall-clock time follows the slowest target.
List each Redfish plugin once. Put the hosts on that plugin's connection manager `targets` list. `RedfishConnectionManager` is the direct BMC session used by `RedfishEndpointPlugin`, `RedfishInventoryPlugin`, and `RedfishOemDiagPlugin`. `RedfishSshProxyConnectionManager` is the SSH proxy used by `AmcRedfishInventoryPlugin`, `AmcRedfishEndpointPlugin`, and `AmcRedfishDiagPlugin`. Those two managers are separate lists, so a plugin compares and collects only the targets it connected.

A target that fails to connect or collect does not fail the run when another target succeeds. That plugin result is a warning, and analysis still runs for the targets that returned data. The run fails when every target fails, or when analysis of collected data reports an error.
Targets are collected concurrently. Connect failures stay on the connection result. The plugin result shows collection and analysis for the targets that connected, and the run fails when every target fails to connect or analysis reports an error.

- `targets`: list of per-target connection parameters. Each entry accepts the same fields as the single-target config plus an optional `target_key` (used as the result key; defaults to the host string).
- `max_workers` (optional): maximum concurrent collection threads. Defaults to `min(len(targets), 32)` and is capped at 32.

Per-target results are written to `<plugin>/<collector>/<target_key>/`. Single-target results stay in `<plugin>/<collector>/`. The same layout is used for the analyzer directory and for the AMC SSH-proxy plugin.
Per-target results are written to `<plugin>/<collector>/<target_key>/`. Single-target results stay in `<plugin>/<collector>/`. The same layout is used for the analyzer directory and for the AMC SSH-proxy plugin. Inventory collection writes `redfishinventorydatamodel.json` in the collector directory.

A ready-to-edit sample is in `config/connection-config_redfish_multi_target.example.json`. Replace the example hosts and password, then pass it with `--connection-config`.

**Firmware comparison:** `RedfishInventoryPlugin` and `AmcRedfishInventoryPlugin` compare `FirmwareInventory` across the targets that plugin connected. `analysis_args.compare_firmware` defaults to `true`. Each member is compared by its published `Id`. A missing or empty `Version` is recorded as `N/A`. The warning also records `Status.Health`, `Status.State`, `SoftwareId`, `Updateable`, and `Oem.SVN`. A different version, or a member present on one connected target and absent on another, logs one `FW` warning per component. BMC firmware is not compared with AMC firmware. A target that failed to connect is excluded. One connected target does not warn. Set `compare_firmware` to `false` to skip the check.

The console summary reports the warning count. Each component is in the analyzer `events.json`, and `result.json` has the summary. Collection profiles and the other inventory switches are in the [plugin reference](docs/PLUGIN_DOC.md).

**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.
Expand Down
72 changes: 72 additions & 0 deletions docs/PLUGIN_DOC.md

Large diffs are not rendered by default.

10 changes: 2 additions & 8 deletions nodescraper/base/redfishcollectortask.py
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ def combine_target_results(parent: str, results: list[TaskResult]) -> TaskResult
aggregated.message = prefix + "\n".join(messages)
if hard_fail and succeeded:
aggregated.status = ExecutionStatus.WARNING
aggregated.details["per_target_messages"] = True

return aggregated

Expand Down Expand Up @@ -172,14 +173,7 @@ def _multi_target_wrapper(

multi_conn: MultiTargetRedfishConnection = collector.connection # type: ignore[assignment]
parent_name = collector.parent or type(collector).__name__
all_results: list[TaskResult] = [
TaskResult(
status=ExecutionStatus.WARNING,
parent=f"{parent_name}[{target_key}]",
message=message,
)
for target_key, message in multi_conn.failed_targets.items()
]
all_results: list[TaskResult] = []
target_count = len(multi_conn.target_connections)
configured_workers = multi_conn.max_workers or target_count
max_workers = min(configured_workers, target_count, 32) if target_count else 1
Expand Down
72 changes: 69 additions & 3 deletions nodescraper/connection/inband/inbandremote.py
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@
import logging
import os
import socket
from typing import Type, Union
from typing import Any, Type, Union

import paramiko
from paramiko.ssh_exception import (
Expand All @@ -47,10 +47,14 @@ class SSHConnectionError(Exception):
"""A general exception for ssh connection failures"""


CHANNEL_OPEN_TIMEOUT = 60


class RemoteShell(InBandConnection):
"""Utility class for running shell commands"""

host_key_policy: Type[paramiko.MissingHostKeyPolicy] = paramiko.RejectPolicy
_proxy_ssh_down: bool = False

def __init__(
self,
Expand All @@ -62,10 +66,31 @@ def __init__(
ssh_params (SSHConnectionParams): The parameters used to initiate the SSH connection username/password/ect.
"""
self.ssh_params = ssh_params
self._new_client()

def _new_client(self) -> None:
"""Replace the SSH client with a new unconnected client.

Returns:
None.
"""
self.client = paramiko.SSHClient()
self.client.load_system_host_keys()
self.client.set_missing_host_key_policy(self.host_key_policy())

def _arm_channel_timeout(self) -> None:
"""Bound channel opens that do not pass their own timeout.

Returns:
None.
"""
transport = self.client.get_transport()
if transport is None:
return
runtime_transport: Any = transport
if runtime_transport.channel_timeout is None:
runtime_transport.channel_timeout = CHANNEL_OPEN_TIMEOUT

def connect_ssh(self):
"""Connect to the remote host via SSH"""
try:
Expand All @@ -85,6 +110,7 @@ def connect_ssh(self):
auth_timeout=60,
banner_timeout=200,
)
self._arm_channel_timeout()
except socket.timeout:
raise SSHConnectionError("SSH Request timeout") from socket.timeout
except socket.gaierror as e:
Expand All @@ -104,6 +130,19 @@ def connect_ssh(self):
except Exception as e:
raise SSHConnectionError(f"Unexpected error during SSH connection: {str(e)}") from e

def reconnect_ssh(self) -> None:
"""Close the current SSH session and open a new one.

Returns:
None.
"""
try:
self.client.close()
except (OSError, SSHException):
pass
self._new_client()
self.connect_ssh()

def read_file(
self,
filename: str,
Expand All @@ -120,15 +159,34 @@ def read_file(
Returns:
BaseFileArtifact: Object representing file contents
"""
with self.client.open_sftp().open(filename, "rb") as remote_file:
raw_contents = remote_file.read()
raw_contents = self.read_files([filename])[0]
return BaseFileArtifact.from_bytes(
filename=os.path.basename(filename),
raw_contents=raw_contents,
encoding=encoding,
strip=strip,
)

def read_files(self, filenames: list[str]) -> list[bytes]:
"""Read remote files through one SFTP session.

Args:
filenames: Remote paths to read.

Returns:
File contents in the same order as filenames.
"""
self._arm_channel_timeout()
sftp = self.client.open_sftp()
try:
contents: list[bytes] = []
for filename in filenames:
with sftp.open(filename, "rb") as remote_file:
contents.append(remote_file.read())
return contents
finally:
sftp.close()

def run_command(
self,
command: str,
Expand All @@ -155,8 +213,10 @@ def run_command(
elif sudo:
cmd_str = f"sudo {cmd_str}"

channel = None
try:
stdin, stdout, stderr = self.client.exec_command(cmd_str, timeout=timeout)
channel = stdout.channel
if write_password:
stdin.write(
(
Expand Down Expand Up @@ -184,6 +244,12 @@ def run_command(
stderr_str = f"{type(exc).__name__}: {exc}"
stdout_str = ""
exit_code = 124
finally:
if channel is not None:
try:
channel.close()
except (OSError, SSHException):
pass

return CommandArtifact(
command=cmd_str,
Expand Down
29 changes: 28 additions & 1 deletion nodescraper/connection/redfish/redfish_connection.py
Original file line number Diff line number Diff line change
Expand Up @@ -239,6 +239,21 @@ def _login_session(self) -> None:
else:
session.auth = HTTPBasicAuth(self.username, self.password)

def _drop_session(self) -> None:
"""Discard the HTTP session without logging out of Redfish.

Args:
None

Returns:
None
"""
if self._session is not None:
self._session.close()
self._session = None
self._session_token = None
self._session_uri = None

def get(self, path: RedfishPath) -> dict[str, Any]:
"""GET a Redfish path and return the JSON body. path must be a RedfishPath."""
path_str = str(path)
Expand All @@ -256,10 +271,22 @@ def get(self, path: RedfishPath) -> dict[str, Any]:
) from exc

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."""
"""GET a Redfish path and refresh the session once after HTTP 401.

Args:
path: Redfish path or absolute URL.

Returns:
The HTTP response, including a second 401 when reauthentication fails.
"""
path = str(path)
session = self._ensure_session()
url = path if path.startswith("http") else urljoin(self.base_url + "/", path.lstrip("/"))
response = self._execute_request(lambda: session.get(url, timeout=self.timeout))
if response.status_code != 401 or not self.use_session_auth or not self.password:
return response
self._drop_session()
session = self._ensure_session()
return self._execute_request(lambda: session.get(url, timeout=self.timeout))

def post(
Expand Down
Loading
Loading