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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 32 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ system debug. For details on what data is collected and analyzed, see the [plugi
- [Execution Methods](#execution-methods)
- [Example: Remote Execution](#example-remote-execution)
- [Example: connection\_config.json](#example-connection_configjson)
- [Redfish multi-target](#redfish-multi-target)
- [Redfish through a BMC SSH proxy](#redfish-through-a-bmc-ssh-proxy)
- [Subcommands](#subcommands)
- [**'describe' subcommand**](#describe-subcommand)
- [**'run-plugins' sub command**](#run-plugins-sub-command)
Expand Down Expand Up @@ -263,6 +265,34 @@ Per-target results are written to `<plugin>/<collector>/<target_key>/`. Single-t

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`.

#### Redfish through a BMC SSH proxy

When the Redfish service is only reachable from the BMC, use `RedfishSshProxyConnectionManager`. node-scraper opens SSH to the BMC (`ssh`) and runs `curl` from there against `host`.

```json
{
"RedfishSshProxyConnectionManager": {
"host": "192.0.2.10",
"ssh": {
"hostname": "bmc.example.com",
"username": "root",
"password": "placeholder"
}
}
}
```

Redfish plugins (those based on `OOBandDataPlugin`) pick their connection from the connection config:

| `RedfishConnectionManager` | `RedfishSshProxyConnectionManager` | Connection used |
|---|---|---|
| present | absent | direct Redfish |
| absent | present | SSH proxy |
| present | present | direct Redfish |
| absent | absent | plugin is skipped |

Plugins that declare `RedfishSshProxyConnectionManager` as their connection type (such as `AmcRedfishDiagPlugin`) always use the proxy, even when both blocks are configured. The proxy also accepts a `targets` list, in the same shape as Redfish multi-target. OOB SSH plugins are unaffected and still require `RedfishConnectionManager`.

**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 Expand Up @@ -529,7 +559,7 @@ The RedfishEndpointPlugin collects Redfish URIs (GET responses) and optionally r

**How to run**

1. Create a connection config (e.g. `connection-config.json`) with `RedfishConnectionManager` and your BMC host (single target) or `targets` list (multi-target).
1. Create a connection config (e.g. `connection-config.json`) with `RedfishConnectionManager` and your BMC host (single target) or `targets` list (multi-target). If Redfish is only reachable through the BMC, use `RedfishSshProxyConnectionManager` instead (see [Redfish through a BMC SSH proxy](#redfish-through-a-bmc-ssh-proxy)).
2. Create a plugin config with `uris` to collect and optional `checks` for analysis (see example below). For example save as `plugin_config_redfish_endpoint.json`.
3. Run:
```sh
Expand Down Expand Up @@ -575,7 +605,7 @@ The RedfishEndpointPlugin collects Redfish URIs (GET responses) and optionally r
- **`max_pages`**: Optional (default `200`). Safety cap on the number of pages to follow per URI when `follow_next_link` is enabled.

**`analysis_args`**
- **`checks`**: Optional. Map of URI to expected values or constraints for analysis. Supports exact match (e.g. `"PowerState": "On"`), `anyOf`, `min`/`max`, etc.
- **`checks`**: Optional. Map of URI to expected values or constraints for analysis. Supports exact match (e.g. `"PowerState": "On"`), `anyOf`, `min`/`max`, `regex` (e.g. `{"regex": "^[0-9a-fA-F-]{36}$"}`), and `exists` (`{"exists": true}` requires the property to be present and non-null).

#### **'summary' sub command**
The 'summary' subcommand can be used to combine results from multiple runs of node-scraper to a
Expand Down
40 changes: 40 additions & 0 deletions nodescraper/pluginexecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,10 @@
from nodescraper.connection.inband import InBandConnectionManager
from nodescraper.connection.inband.osdetection import discover_and_write_os_family
from nodescraper.connection.oob_ssh import OobSshConnectionManager
from nodescraper.connection.redfish import (
RedfishConnectionManager,
RedfishSshProxyConnectionManager,
)
from nodescraper.constants import DEFAULT_LOGGER
from nodescraper.enums import ExecutionStatus, OSFamily
from nodescraper.helpers.plugin_execution_target import (
Expand Down Expand Up @@ -251,6 +255,35 @@ def discover_os_info(self) -> None:
if inband_connection is not None:
inband_connection.disconnect()

def _resolve_redfish_manager(self, plugin_name: str) -> Optional[ConnectionManager]:
"""Pick the Redfish transport for a plugin declaring ``RedfishConnectionManager``.

The direct ``RedfishConnectionManager`` wins when configured; otherwise
``RedfishSshProxyConnectionManager`` is used. With neither configured the plugin is skipped.

Args:
plugin_name: Registry key for the plugin (for logging).

Returns:
The connection manager instance, or None if no Redfish connection is configured.
"""
for name in (
RedfishConnectionManager.__name__,
RedfishSshProxyConnectionManager.__name__,
):
if name not in self.connection_configs:
continue
mgr_impl = self.plugin_registry.connection_managers.get(name)
conn_mgr = self.connection_library.get(mgr_impl) if mgr_impl else None
if conn_mgr is not None:
return conn_mgr
self.logger.error(
"%s requires RedfishConnectionManager or RedfishSshProxyConnectionManager in the "
"connection config",
plugin_name,
)
return None

def _get_connection_manager_for_plugin(
self,
plugin_class: type,
Expand All @@ -264,6 +297,11 @@ def _get_connection_manager_for_plugin(

The resolved manager is stored in ``self.connection_library`` so that it can
be shared across plugins that require the same type.

Plugins declaring ``RedfishConnectionManager`` fall back to
``RedfishSshProxyConnectionManager`` when only the proxy is configured
(see :meth:`_resolve_redfish_manager`). Plugins declaring
``RedfishSshProxyConnectionManager`` always use the proxy.
"""
if not plugin_class.CONNECTION_TYPE:
return None
Expand All @@ -279,6 +317,8 @@ def _get_connection_manager_for_plugin(
return None
else:
connection_manager_class: Type[ConnectionManager] = plugin_class.CONNECTION_TYPE
if connection_manager_class is RedfishConnectionManager:
return self._resolve_redfish_manager(plugin_name)
if connection_manager_class.__name__ in self.plugin_registry.connection_managers:
mgr_impl = self.plugin_registry.connection_managers[
connection_manager_class.__name__
Expand Down
4 changes: 4 additions & 0 deletions nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,8 @@ class ConstraintKey(str, Enum):
MIN = "min"
MAX = "max"
ANY_OF = "anyOf"
REGEX = "regex"
EXISTS = "exists"


RedfishConstraint = Union[int, float, str, bool, dict[str, Any]]
Expand All @@ -62,6 +64,8 @@ class RedfishEndpointAnalyzerArgs(AnalyzerArgs):
"'min' — value must be numeric and >= the given number. "
"'max' — value must be numeric and <= the given number. "
"'anyOf' — value must be in the given list (OR; any match passes). "
"'regex' — value must be a string matching the pattern (Python re.search; anchor with ^ and $ for a full match). "
"'exists' — true: property must be present and not null; false: property must be absent or null. "
'Example: { "/redfish/v1/Systems/1": { "Status/Health": { "anyOf": ["OK", "Warning"] }, "PowerState": "On" }, "*": { "Status/Health": { "anyOf": ["OK"] } } }.'
),
)
16 changes: 15 additions & 1 deletion nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
# SOFTWARE.
#
###############################################################################
import re
from typing import Any, Optional

from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus
Expand Down Expand Up @@ -59,6 +60,19 @@ def _get_by_path(obj: Any, path: str) -> Any:
def _check_constraint(actual: Any, constraint: RedfishConstraint) -> tuple[bool, str]:
"""Compare actual value to constraint."""
if isinstance(constraint, dict):
if ConstraintKey.EXISTS in constraint:
want = bool(constraint[ConstraintKey.EXISTS])
ok = (actual is not None) == want
return ok, f"expected {'present' if want else 'absent'}, got {actual!r}"
if ConstraintKey.REGEX in constraint:
pattern = constraint[ConstraintKey.REGEX]
if not isinstance(actual, str):
return False, f"expected string matching {pattern!r}, got {actual!r}"
try:
ok = re.search(pattern, actual) is not None
except re.error as exc:
return False, f"invalid regex {pattern!r}: {exc}"
return ok, f"expected match for {pattern!r}, got {actual!r}"
if ConstraintKey.EQ in constraint:
ok = actual == constraint[ConstraintKey.EQ]
return ok, f"expected eq {constraint[ConstraintKey.EQ]}, got {actual!r}"
Expand Down Expand Up @@ -91,7 +105,7 @@ class RedfishEndpointAnalyzer(DataAnalyzer[RedfishEndpointDataModel, RedfishEndp

DOCUMENTATION_ANALYSIS_ITEMS: tuple[str, ...] = (
"For each entry in analysis_args.checks, reads JSON paths in collected responses and "
"compares values to constraints (eq, min/max, anyOf, regex, etc.).",
"compares values to constraints (eq, min/max, anyOf, regex, exists).",
"URI key `*` runs checks against every collected response body.",
)

Expand Down
106 changes: 106 additions & 0 deletions test/unit/framework/test_redfish_connection_selection.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
###############################################################################
#
# 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 unittest
from typing import Any, Optional

from nodescraper.connection.redfish import (
RedfishConnectionManager,
RedfishSshProxyConnectionManager,
)
from nodescraper.enums import OSFamily, SystemLocation
from nodescraper.interfaces import ConnectionManager
from nodescraper.models import PluginConfig, SystemInfo
from nodescraper.pluginexecutor import PluginExecutor

REDFISH_PLUGIN = "RedfishEndpointPlugin"
PROXY_PLUGIN = "AmcRedfishDiagPlugin"

DIRECT_CONFIG: dict[str, Any] = {
"RedfishConnectionManager": {"host": "bmc.example.com", "username": "root", "password": "x"}
}
PROXY_CONFIG: dict[str, Any] = {
"RedfishSshProxyConnectionManager": {
"host": "192.0.2.10",
"ssh": {"hostname": "bmc.example.com", "username": "root", "password": "x"},
}
}
BOTH_CONFIG: dict[str, Any] = {**DIRECT_CONFIG, **PROXY_CONFIG}


def _executor(connections: dict) -> PluginExecutor:
return PluginExecutor(
plugin_configs=[PluginConfig(plugins={REDFISH_PLUGIN: {}})],
connections=connections,
system_info=SystemInfo(
name="test", location=SystemLocation.REMOTE, os_family=OSFamily.LINUX
),
)


def _resolve(ex: PluginExecutor, plugin_name: str = REDFISH_PLUGIN) -> Optional[ConnectionManager]:
plugin_cls = ex.plugin_registry.plugins[plugin_name]
return ex._get_connection_manager_for_plugin(plugin_cls, plugin_name)


class TestRedfishConnectionSelection(unittest.TestCase):
def test_plugin_declares_direct_redfish(self):
ex = _executor({})
plugin_cls = ex.plugin_registry.plugins[REDFISH_PLUGIN]
self.assertIs(plugin_cls.CONNECTION_TYPE, RedfishConnectionManager)

def test_direct_only_uses_direct(self):
ex = _executor(DIRECT_CONFIG)
self.assertIs(_resolve(ex), ex.connection_library[RedfishConnectionManager])

def test_proxy_only_falls_back_to_proxy(self):
ex = _executor(PROXY_CONFIG)
self.assertIs(_resolve(ex), ex.connection_library[RedfishSshProxyConnectionManager])

def test_both_prefers_direct(self):
ex = _executor(BOTH_CONFIG)
self.assertIs(_resolve(ex), ex.connection_library[RedfishConnectionManager])

def test_neither_skips_plugin(self):
ex = _executor({})
self.assertIsNone(_resolve(ex))
self.assertNotIn(RedfishConnectionManager, ex.connection_library)
self.assertFalse(
ex._run_single_plugin(
REDFISH_PLUGIN, ex.plugin_config.plugins[REDFISH_PLUGIN], [], queue_callback=None
)
)

def test_plugin_declaring_proxy_keeps_proxy_when_both_configured(self):
ex = _executor(BOTH_CONFIG)
plugin_cls = ex.plugin_registry.plugins[PROXY_PLUGIN]
self.assertIs(plugin_cls.CONNECTION_TYPE, RedfishSshProxyConnectionManager)
self.assertIs(
_resolve(ex, PROXY_PLUGIN), ex.connection_library[RedfishSshProxyConnectionManager]
)


if __name__ == "__main__":
unittest.main()
60 changes: 60 additions & 0 deletions test/unit/plugin/test_redfish_endpoint_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -129,6 +129,66 @@ def test_check_constraint_literal_match():
assert ok is False


def test_check_constraint_regex_pass():
ok, _ = _check_constraint("123e4567-e89b-12d3-a456-426614174000", {"regex": r"^[0-9a-f-]{36}$"})
assert ok is True
ok, _ = _check_constraint("SN1234", {"regex": r"\d"})
assert ok is True


def test_check_constraint_regex_fail():
ok, msg = _check_constraint("000000000000", {"regex": r"^(?!0+$).+$"})
assert ok is False
assert "expected match" in msg


def test_check_constraint_regex_non_string_fails():
for actual in (None, 42, {"a": 1}):
ok, msg = _check_constraint(actual, {"regex": ".*"})
assert ok is False
assert "expected string" in msg


def test_check_constraint_regex_invalid_pattern_fails():
ok, msg = _check_constraint("abc", {"regex": "("})
assert ok is False
assert "invalid regex" in msg


def test_check_constraint_exists():
for present in ("x", 0, False, {}, {"@odata.id": "/redfish/v1/Chassis/1/Sensors"}):
assert _check_constraint(present, {"exists": True})[0] is True
assert _check_constraint(present, {"exists": False})[0] is False
assert _check_constraint(None, {"exists": True})[0] is False
assert _check_constraint(None, {"exists": False})[0] is True


def test_redfish_endpoint_analyzer_regex_and_exists(redfish_endpoint_analyzer):
data = RedfishEndpointDataModel(
responses={
"/redfish/v1/Chassis/1": {
"UUID": "123e4567-e89b-12d3-a456-426614174000",
"SerialNumber": "000000000000",
"Sensors": {"@odata.id": "/redfish/v1/Chassis/1/Sensors"},
},
}
)
args = RedfishEndpointAnalyzerArgs(
checks={
"/redfish/v1/Chassis/1": {
"UUID": {"regex": r"^[0-9a-fA-F-]{36}$"},
"SerialNumber": {"regex": r"^(?!0+$).+$"},
"Sensors": {"exists": True},
"Assembly": {"exists": True},
},
}
)
result = redfish_endpoint_analyzer.analyze_data(data, args=args)
assert result.status == ExecutionStatus.ERROR
failures = result.events[-1].data["failures"]
assert sorted(f["path"] for f in failures) == ["Assembly", "SerialNumber"]


def test_redfish_endpoint_analyzer_no_checks(redfish_endpoint_analyzer):
data = RedfishEndpointDataModel(responses={"/redfish/v1": {}})
result = redfish_endpoint_analyzer.analyze_data(data, args=None)
Expand Down
Loading
Loading