diff --git a/.github/workflows/update-plugin-docs.yml b/.github/workflows/update-plugin-docs.yml
index a4da869c..97c1e48a 100644
--- a/.github/workflows/update-plugin-docs.yml
+++ b/.github/workflows/update-plugin-docs.yml
@@ -37,7 +37,6 @@ jobs:
run: |
source venv/bin/activate
python docs/generate_plugin_doc_bundle.py \
- --package nodescraper.plugins.inband \
--output docs/PLUGIN_DOC.md \
--update-readme-help
diff --git a/docs/PLUGIN_DOC.md b/docs/PLUGIN_DOC.md
index 0ca1366f..e15ce92a 100644
--- a/docs/PLUGIN_DOC.md
+++ b/docs/PLUGIN_DOC.md
@@ -1,6 +1,6 @@
# Plugin Documentation
-# Plugin Table
+# IB Plugins
| Plugin | Collection | Analyzer Args | Collection Args | DataModel | Collector | Analyzer |
| --- | --- | --- | --- | --- | --- | --- |
@@ -24,6 +24,7 @@
| PciePlugin | lspci -d {vendor_id}: -nn
lspci -x
lspci -xxxx
lspci -PP
lspci -PP -d {vendor_id}:{dev_id}
lspci -PP -D -d {vendor_id}:{dev_id}
lspci -PP -D
lspci -vvv
lspci -vvvt | **Analyzer Args:**
- `exp_speed`: int — Expected PCIe link speed (generation 1–5).
- `exp_width`: int — Expected PCIe link width in lanes (1–16).
- `exp_sriov_count`: int — Expected SR-IOV virtual function count.
- `exp_gpu_count_override`: Optional[int] — Override expected GPU count for validation.
- `exp_max_payload_size`: Union[Dict[int, int], int, NoneType] — Expected max payload size: int for all devices, or dict keyed by device ID.
- `exp_max_rd_req_size`: Union[Dict[int, int], int, NoneType] — Expected max read request size: int for all devices, or dict keyed by device ID.
- `exp_ten_bit_tag_req_en`: Union[Dict[int, int], int, NoneType] — Expected 10-bit tag request enable: int for all devices, or dict keyed by device ID. | - | [PcieDataModel](#PcieDataModel-Model) | [PcieCollector](#Collector-Class-PcieCollector) | [PcieAnalyzer](#Data-Analyzer-Class-PcieAnalyzer) |
| ProcessPlugin | top -b -n 1
rocm-smi --showpids
top -b -n 1 -o %CPU | **Analyzer Args:**
- `max_kfd_processes`: int — Maximum allowed number of KFD (Kernel Fusion Driver) processes; 0 disables the check.
- `max_cpu_usage`: float — Maximum allowed CPU usage (percent) for process checks. | **Collection Args:**
- `top_n_process`: int — Number of top processes by CPU usage to collect (e.g. for top -b -n 1 -o %%CPU). | [ProcessDataModel](#ProcessDataModel-Model) | [ProcessCollector](#Collector-Class-ProcessCollector) | [ProcessAnalyzer](#Data-Analyzer-Class-ProcessAnalyzer) |
| RdmaPlugin | rdma link -j
rdma dev
rdma link
rdma statistic -j | - | - | [RdmaDataModel](#RdmaDataModel-Model) | [RdmaCollector](#Collector-Class-RdmaCollector) | [RdmaAnalyzer](#Data-Analyzer-Class-RdmaAnalyzer) |
+| RegexSearchPlugin | - | Runs RegexSearchAnalyzer: user-defined patterns via analysis_args.error_regex (same shape as Dmesg).
Emits regex match events with optional per-file source in the description when scanning directories.
**Analyzer Args:**
- `error_regex`: Optional[list[dict[str, Any]]] — Regex patterns to search for; each dict may include regex (str), message, event_category, event_priority (same as Dmesg analyzer error_regex).
- `interval_to_collapse_event`: int — Seconds within which repeated events are collapsed into one.
- `num_timestamps`: int — Number of timestamps to include per event in output. | - | [RegexSearchData](#RegexSearchData-Model) | - | [RegexSearchAnalyzer](#Data-Analyzer-Class-RegexSearchAnalyzer) |
| RocmPlugin | {rocm_path}/opencl/bin/*/clinfo
env | grep -Ei 'rocm|hsa|hip|mpi|openmp|ucx|miopen'
ls /sys/class/kfd/kfd/proc/
grep -i -E 'rocm' /etc/ld.so.conf.d/*
{rocm_path}/bin/rocminfo
ls -v -d {rocm_path}*
ls -v -d {rocm_path}-[3-7]* | tail -1
ldconfig -p | grep -i -E 'rocm'
grep . -H -r -i {rocm_path}/.info/* | **Analyzer Args:**
- `exp_rocm`: Union[str, list] — Expected ROCm version string(s) to match (e.g. from rocminfo).
- `exp_rocm_latest`: str — Expected 'latest' ROCm path or version string for versioned installs.
- `exp_rocm_sub_versions`: dict[str, Union[str, list]] — Map sub-version name (e.g. version_rocm) to expected string or list of allowed strings. | **Collection Args:**
- `rocm_path`: str — Base path to ROCm installation (e.g. /opt/rocm). Used for rocminfo, clinfo, and version discovery. | [RocmDataModel](#RocmDataModel-Model) | [RocmCollector](#Collector-Class-RocmCollector) | [RocmAnalyzer](#Data-Analyzer-Class-RocmAnalyzer) |
| StoragePlugin | sh -c 'df -lH -B1 | grep -v 'boot''
wmic LogicalDisk Where DriveType="3" Get DeviceId,Size,FreeSpace | - | **Collection Args:**
- `skip_sudo`: bool — If True, do not use sudo when running df and related storage commands. | [StorageDataModel](#StorageDataModel-Model) | [StorageCollector](#Collector-Class-StorageCollector) | [StorageAnalyzer](#Data-Analyzer-Class-StorageAnalyzer) |
| SysSettingsPlugin | cat /sys/{}
ls -1 /sys/{}
ls -l /sys/{} | **Analyzer Args:**
- `checks`: Optional[list[nodescraper.plugins.inband.sys_settings.analyzer_args.SysfsCheck]] — List of sysfs checks (path, expected values or pattern, display name). | **Collection Args:**
- `paths`: list[str] — Sysfs paths to read (cat). Paths with '*' are collected with ls -l (e.g. class/net/*/device).
- `directory_paths`: list[str] — Sysfs paths to list (ls -1); used for checks that match entry names by regex. | [SysSettingsDataModel](#SysSettingsDataModel-Model) | [SysSettingsCollector](#Collector-Class-SysSettingsCollector) | [SysSettingsAnalyzer](#Data-Analyzer-Class-SysSettingsAnalyzer) |
@@ -31,6 +32,14 @@
| SyslogPlugin | ls -1 /var/log/syslog* 2>/dev/null | grep -E '^/var/log/syslog(\.[0-9]+(\.gz)?)?$' || true
ls -1 /var/log/messages* 2>/dev/null | grep -E '^/var/log/messages(\.[0-9]+(\.gz)?)?$' || true | - | - | [SyslogData](#SyslogData-Model) | [SyslogCollector](#Collector-Class-SyslogCollector) | - |
| UptimePlugin | uptime | - | - | [UptimeDataModel](#UptimeDataModel-Model) | [UptimeCollector](#Collector-Class-UptimeCollector) | - |
+# OOB plugins
+
+| Plugin | Collection | Analyzer Args | Collection Args | DataModel | Collector | Analyzer |
+| --- | --- | --- | --- | --- | --- | --- |
+| OobBmcArchivePlugin | SSH (BMC) shell: tar+gzip archives for each path in collection_args (see PathSpec entries).
Uses sudo on the BMC when collection_args paths require elevated access. | - | **Collection Args:**
- `paths`: list[nodescraper.plugins.ooband.bmc_archive.collector_args.PathSpec] — Named BMC paths to archive with tar czf -. Configure in plugin config under plugins.OobBmcArchivePlugin.collection_ar...
- `sudo`: bool — Default sudo setting for paths that do not specify sudo.
- `timeout`: int — Default per-path tar timeout in seconds.
- `skip_if_missing`: bool — Skip paths that do not exist on the BMC instead of failing collection.
- `ignore_failed_read`: bool — When true, pass GNU tar's --ignore-failed-read when the remote tar supports it. | [BmcArchiveDataModel](#BmcArchiveDataModel-Model) | [BmcArchiveCollector](#Collector-Class-BmcArchiveCollector) | - |
+| RedfishEndpointPlugin | Redfish GET: explicit paths from collection_args.uris (parallel when max_workers>1).
Optional paged GET following the Members collection OData nextLink field when follow_next_link is true.
Redfish GET tree: when discover_tree is true, walks from api_root using OData resource id links and Members navigation (depth and endpoint caps from collection_args). | For each entry in analysis_args.checks, reads JSON paths in collected responses and compares values to constraints (eq, min/max, anyOf, regex, etc.).
URI key "*" runs checks against every collected response body.
**Analyzer Args:**
- `checks`: dict[str, dict[str, Union[int, float, str, bool, dict[str, Any]]]] — Map: URI or '*' -> { property_path: constraint }. URI keys must match a key in the collected responses (exact match).... | **Collection Args:**
- `uris`: list[str] — Redfish URIs to GET. Ignored when discover_tree is True.
- `discover_tree`: bool — If True, discover endpoints from the BMC Redfish tree (service root and links) instead of using uris.
- `tree_max_depth`: int — When discover_tree is True: max traversal depth (1=service root only, 2=root + collections, 3=+ members).
- `tree_max_endpoints`: int — When discover_tree is True: max endpoints to discover (0=no limit).
- `max_workers`: int — Max concurrent GETs (1=sequential). Use >1 for async endpoint fetches.
- `follow_next_link`: bool — If True, follow Redfish Members collection OData nextLink pagination for each URI and merge all pages into a single response.
- `max_pages`: int — When follow_next_link is True: safety cap on the number of pages to follow per URI (default 200). | [RedfishEndpointDataModel](#RedfishEndpointDataModel-Model) | [RedfishEndpointCollector](#Collector-Class-RedfishEndpointCollector) | [RedfishEndpointAnalyzer](#Data-Analyzer-Class-RedfishEndpointAnalyzer) |
+| RedfishOemDiagPlugin | Redfish LogService.CollectDiagnosticData for each entry in collection_args.oem_diagnostic_types (collection_args.log_service_path selects the LogService).
Optional binary archives under the plugin log path when log_path is set. | Summarizes success/failure per OEM diagnostic type from collected results.
When analysis_args.require_all_success is true, fails the run if any type failed collection.
**Analyzer Args:**
- `require_all_success`: bool — If True, analysis fails when any OEM type collection failed. | **Collection Args:**
- `log_service_path`: str — Redfish path to the LogService (e.g. DiagLogs).
- `oem_diagnostic_types_allowable`: Optional[list[str]] — Allowable OEM diagnostic types for this architecture/BMC. When set, used for validation and as default for oem_diagno...
- `oem_diagnostic_types`: list[str] — OEM diagnostic types to collect. When empty and oem_diagnostic_types_allowable is set, defaults to that list.
- `task_timeout_s`: int — Max seconds to wait for each BMC task. | [RedfishOemDiagDataModel](#RedfishOemDiagDataModel-Model) | [RedfishOemDiagCollector](#Collector-Class-RedfishOemDiagCollector) | [RedfishOemDiagAnalyzer](#Data-Analyzer-Class-RedfishOemDiagAnalyzer) |
+
# Collectors
## Collector Class AmdSmiCollector
@@ -947,6 +956,70 @@ UptimeDataModel
- uptime
+## Collector Class BmcArchiveCollector
+
+### Description
+
+Archive BMC directories over SSH using tar czf - .
+
+**Bases**: ['InBandDataCollector']
+
+**Link to code**: [bmc_archive_collector.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py)
+
+### Class Variables
+
+- **SUPPORTED_OS_FAMILY**: `{, }`
+- **REMOTE_ARCHIVE_TEMPLATE**: `/tmp/node_scraper_{name}.tar.gz`
+- **_tar_ignore_failed_read_supported**: `None`
+
+### Provides Data
+
+BmcArchiveDataModel
+
+### Documented collection
+
+- SSH (BMC) shell: tar+gzip archives for each path in collection_args (see PathSpec entries).
+- Uses sudo on the BMC when collection_args paths require elevated access.
+
+## Collector Class RedfishEndpointCollector
+
+### Description
+
+Collects Redfish endpoint responses for URIs specified in config.
+
+**Bases**: ['RedfishDataCollector']
+
+**Link to code**: [endpoint_collector.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py)
+
+### Provides Data
+
+RedfishEndpointDataModel
+
+### Documented collection
+
+- Redfish GET: explicit paths from collection_args.uris (parallel when max_workers>1).
+- Optional paged GET following Members@odata.nextLink when follow_next_link is true.
+- Redfish GET tree: when discover_tree is true, walks from api_root using @odata.id / Members links (depth and endpoint caps from collection_args).
+
+## Collector Class RedfishOemDiagCollector
+
+### Description
+
+Collects Redfish OEM diagnostic logs (e.g. JournalControl, AllLogs) via LogService.CollectDiagnosticData.
+
+**Bases**: ['RedfishDataCollector']
+
+**Link to code**: [oem_diag_collector.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py)
+
+### Provides Data
+
+RedfishOemDiagDataModel
+
+### Documented collection
+
+- Redfish LogService.CollectDiagnosticData for each entry in collection_args.oem_diagnostic_types (collection_args.log_service_path selects the LogService).
+- Optional binary archives under the plugin log path when log_path is set.
+
# Data Models
## AmdSmiDataModel Model
@@ -1286,6 +1359,22 @@ Data model for RDMA (Remote Direct Memory Access) statistics and link informatio
- **dev_list**: `list[nodescraper.plugins.inband.rdma.rdmadata.RdmaDevice]`
- **link_list_text**: `list[nodescraper.plugins.inband.rdma.rdmadata.RdmaLinkText]`
+## RegexSearchData Model
+
+### Description
+
+Loaded file or directory contents passed to the analyzer (via --data).
+
+**Link to code**: [regex_search_data.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/regex_search/regex_search_data.py)
+
+**Bases**: ['DataModel']
+
+### Model annotations and fields
+
+- **content**: `str`
+- **data_root**: `str`
+- **files**: `dict[str, str]`
+
## RocmDataModel Model
**Link to code**: [rocmdata.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/rocm/rocmdata.py)
@@ -1378,6 +1467,49 @@ Data model for in band syslog logs
- **current_time**: `str`
- **uptime**: `str`
+## BmcArchiveDataModel Model
+
+### Description
+
+Collected BMC directory archives.
+
+**Link to code**: [bmc_archive_data.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/bmc_archive/bmc_archive_data.py)
+
+**Bases**: ['DataModel']
+
+### Model annotations and fields
+
+- **results**: `list[nodescraper.plugins.ooband.bmc_archive.bmc_archive_data.ArchiveCollectionResult]`
+- **archives**: `list[nodescraper.connection.inband.inband.BinaryFileArtifact]`
+
+## RedfishEndpointDataModel Model
+
+### Description
+
+Collected Redfish endpoint responses: URI -> JSON body.
+
+**Link to code**: [endpoint_data.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/redfish_endpoint/endpoint_data.py)
+
+**Bases**: ['DataModel']
+
+### Model annotations and fields
+
+- **responses**: `dict[str, dict]`
+
+## RedfishOemDiagDataModel Model
+
+### Description
+
+Collected Redfish OEM diagnostic log results: OEM type -> result (success, error, metadata).
+
+**Link to code**: [oem_diag_data.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_data.py)
+
+**Bases**: ['DataModel']
+
+### Model annotations and fields
+
+- **results**: `dict[str, nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_data.OemDiagTypeResult]`
+
# Data Analyzers
## Data Analyzer Class AmdSmiAnalyzer
@@ -1709,6 +1841,20 @@ Check RDMA statistics for errors (RoCE and other RDMA error counters).
**Link to code**: [rdma_analyzer.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/rdma/rdma_analyzer.py)
+## Data Analyzer Class RegexSearchAnalyzer
+
+### Description
+
+Run user-provided regexes against text loaded from --data (file or directory).
+
+**Bases**: ['RegexAnalyzer']
+
+**Link to code**: [regex_search_analyzer.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/regex_search/regex_search_analyzer.py)
+
+### Class Variables
+
+- **ERROR_REGEX**: `[]`
+
## Data Analyzer Class RocmAnalyzer
### Description
@@ -1753,6 +1899,36 @@ Check sysctl matches expected sysctl details
**Link to code**: [sysctl_analyzer.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/inband/sysctl/sysctl_analyzer.py)
+## Data Analyzer Class RedfishEndpointAnalyzer
+
+### Description
+
+Checks Redfish endpoint responses against configured thresholds and key/value rules.
+
+**Bases**: ['DataAnalyzer']
+
+**Link to code**: [endpoint_analyzer.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py)
+
+### Documented analysis
+
+- For each entry in analysis_args.checks, reads JSON paths in collected responses and compares values to constraints (eq, min/max, anyOf, regex, etc.).
+- URI key "*" runs checks against every collected response body.
+
+## Data Analyzer Class RedfishOemDiagAnalyzer
+
+### Description
+
+Analyzes Redfish OEM diagnostic log collection results.
+
+**Bases**: ['DataAnalyzer']
+
+**Link to code**: [oem_diag_analyzer.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_analyzer.py)
+
+### Documented analysis
+
+- Summarizes success/failure per OEM diagnostic type from collected results.
+- When analysis_args.require_all_success is true, fails the run if any type failed collection.
+
# Analyzer Args
## Analyzer Args Class AmdSmiAnalyzerArgs
@@ -2021,3 +2197,31 @@ Sysfs settings for analysis via a list of checks (path, expected values, name).
- **exp_vm_dirty_ratio**: `Optional[int]` — Expected vm.dirty_ratio value.
- **exp_vm_dirty_writeback_centisecs**: `Optional[int]` — Expected vm.dirty_writeback_centisecs value.
- **exp_kernel_numa_balancing**: `Optional[int]` — Expected kernel.numa_balancing value.
+
+## Analyzer Args Class RedfishEndpointAnalyzerArgs
+
+### Description
+
+Analyzer args for config-driven Redfish checks.
+
+**Bases**: ['AnalyzerArgs']
+
+**Link to code**: [analyzer_args.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/redfish_endpoint/analyzer_args.py)
+
+### Annotations / fields
+
+- **checks**: `dict[str, dict[str, Union[int, float, str, bool, dict[str, Any]]]]` — Map: URI or '*' -> { property_path: constraint }. URI keys must match a key in the collected responses (exact match). Use '*' as the key to apply the inner constraints to every collected response body. Property paths use '/' for nesting and indices, e.g. 'Status/Health', 'PowerControl/0/PowerConsumedWatts'. Constraints: 'eq' — value must equal the given literal (int, float, str, bool). 'min' — value must be numeric and >= the given number. 'max' — value must be numeric and <= the given number. 'anyOf' — value must be in the given list (OR; any match passes). Example: { "/redfish/v1/Systems/1": { "Status/Health": { "anyOf": ["OK", "Warning"] }, "PowerState": "On" }, "*": { "Status/Health": { "anyOf": ["OK"] } } }.
+
+## Analyzer Args Class RedfishOemDiagAnalyzerArgs
+
+### Description
+
+Analyzer args for Redfish OEM diagnostic log results.
+
+**Bases**: ['AnalyzerArgs']
+
+**Link to code**: [analyzer_args.py](https://github.com/amd/node-scraper/blob/HEAD/nodescraper/plugins/ooband/redfish_oem_diag/analyzer_args.py)
+
+### Annotations / fields
+
+- **require_all_success**: `bool` — If True, analysis fails when any OEM type collection failed.
diff --git a/docs/generate_plugin_doc_bundle.py b/docs/generate_plugin_doc_bundle.py
index b7676b6a..96042329 100644
--- a/docs/generate_plugin_doc_bundle.py
+++ b/docs/generate_plugin_doc_bundle.py
@@ -26,10 +26,8 @@
"""
Usage
python generate_plugin_doc_bundle.py \
- --package /home/alexbara/node-scraper/nodescraper/plugins/inband \
- --output PLUGIN_DOC.md \
+ --output docs/PLUGIN_DOC.md \
--update-readme-help
-
"""
import argparse
import importlib
@@ -44,7 +42,10 @@
LINK_BASE_DEFAULT = "https://github.com/amd/node-scraper/blob/HEAD/"
REL_ROOT_DEFAULT = "nodescraper/plugins/inband"
-DEFAULT_ROOT_PACKAGE = "nodescraper.plugins"
+# Default packages scanned for plugin tables (IB: full inband tree; OOB: ooband).
+PACKAGE_IB_INBAND = "nodescraper.plugins.inband"
+PACKAGE_OOB = "nodescraper.plugins.ooband"
+DEFAULT_PACKAGES = (PACKAGE_IB_INBAND, PACKAGE_OOB)
def get_attr(obj: Any, name: str, default: Any = None) -> Any:
@@ -182,6 +183,54 @@ def find_inband_plugin_base():
return get_attr(base_mod, "InBandDataPlugin")
+def find_oob_plugin_bases() -> tuple[type, ...]:
+ """Return OOB plugin base classes under ``nodescraper.plugins.ooband`` (Redfish + BMC SSH)."""
+ base_mod = importlib.import_module("nodescraper.base")
+ oob = get_attr(base_mod, "OOBandDataPlugin")
+ oob_ssh = get_attr(base_mod, "OOBSSHDataPlugin")
+ bases = [b for b in (oob, oob_ssh) if b is not None]
+ return tuple(bases)
+
+
+def is_concrete_plugin_class(cls: type) -> bool:
+ if not inspect.isclass(cls):
+ return False
+ return not bool(get_attr(cls, "__abstractmethods__", set()))
+
+
+def all_subclasses_union(bases: Iterable[type]) -> set[type]:
+ """All distinct concrete descendants across one or more base classes (transitive)."""
+ merged: set[type] = set()
+ for base in bases:
+ merged |= all_subclasses_single(base)
+ return merged
+
+
+def all_subclasses_single(cls: type) -> set[type]:
+ seen, out, work = set(), set(), [cls]
+ while work:
+ parent = work.pop()
+ for sub in parent.__subclasses__():
+ if sub not in seen:
+ seen.add(sub)
+ out.add(sub)
+ work.append(sub)
+ return out
+
+
+def plugins_for_package_prefix(base_classes: Iterable[type], package_prefix: str) -> List[type]:
+ """Non-abstract plugin classes under ``base_classes`` whose ``__module__`` starts with *package_prefix*."""
+ found: List[type] = []
+ for cls in all_subclasses_union(base_classes):
+ mod = getattr(cls, "__module__", "") or ""
+ if not mod.startswith(package_prefix):
+ continue
+ if not is_concrete_plugin_class(cls):
+ continue
+ found.append(cls)
+ return found
+
+
def link_anchor(obj: Any, kind: str) -> str:
if obj is None or not inspect.isclass(obj):
return "-"
@@ -228,6 +277,126 @@ def add_cmd(s: Any):
return cmds
+# Optional human-readable bullets for plugins without CMD_* shell snippets (e.g. Redfish).
+DOCUMENTATION_COLLECTION_ITEMS_ATTR = "DOCUMENTATION_COLLECTION_ITEMS"
+DOCUMENTATION_ANALYSIS_ITEMS_ATTR = "DOCUMENTATION_ANALYSIS_ITEMS"
+
+
+def _documentation_lines_for_attr(cls: Any, attr_name: str) -> List[str]:
+ if cls is None or not inspect.isclass(cls):
+ return []
+ raw = get_attr(cls, attr_name, None)
+ if raw is None:
+ return []
+ if isinstance(raw, str):
+ return [ln.strip() for ln in raw.splitlines() if ln.strip()]
+ if isinstance(raw, (list, tuple)):
+ return [str(x).strip() for x in raw if isinstance(x, str) and str(x).strip()]
+ return []
+
+
+def merge_unique_lines(*line_groups: Iterable[str]) -> List[str]:
+ """Concatenate line groups, dropping exact duplicates while preserving order."""
+ seen: set[str] = set()
+ out: List[str] = []
+ for group in line_groups:
+ for line in group:
+ if line not in seen:
+ seen.add(line)
+ out.append(line)
+ return out
+
+
+def extract_collection_lines_for_table(plugin_cls: type, collector_cls: Any) -> List[str]:
+ """Shell CMD_* lines plus optional DOCUMENTATION_COLLECTION_ITEMS (collector then plugin)."""
+ cmd_lines: List[str] = []
+ if inspect.isclass(collector_cls):
+ cmd_lines = extract_cmds_from_classvars(collector_cls)
+ doc_collector = _documentation_lines_for_attr(
+ collector_cls, DOCUMENTATION_COLLECTION_ITEMS_ATTR
+ )
+ doc_plugin = _documentation_lines_for_attr(plugin_cls, DOCUMENTATION_COLLECTION_ITEMS_ATTR)
+ return merge_unique_lines(cmd_lines, doc_collector, doc_plugin)
+
+
+def extract_analysis_doc_lines_for_table(plugin_cls: type, analyzer_cls: Any) -> List[str]:
+ """Optional DOCUMENTATION_ANALYSIS_ITEMS (analyzer then plugin) for the analyzer column."""
+ doc_an = _documentation_lines_for_attr(analyzer_cls, DOCUMENTATION_ANALYSIS_ITEMS_ATTR)
+ doc_pl = _documentation_lines_for_attr(plugin_cls, DOCUMENTATION_ANALYSIS_ITEMS_ATTR)
+ return merge_unique_lines(doc_an, doc_pl)
+
+
+def iter_plugin_collector_classes(plugin_cls: type) -> List[type]:
+ """Return collector class(es) for a plugin (supports tuple COLLECTOR via DataPlugin.get_collector_classes)."""
+ gcs = getattr(plugin_cls, "get_collector_classes", None)
+ if callable(gcs):
+ try:
+ return [c for c in gcs() if inspect.isclass(c)]
+ except Exception:
+ return []
+ return []
+
+
+def collector_has_table_collection_coverage(plugin_cls: type, collector_cls: type) -> bool:
+ """True if the plugin table Collection cell would be non-empty from CMD_* or documentation lines."""
+ if extract_cmds_from_classvars(collector_cls):
+ return True
+ if _documentation_lines_for_attr(collector_cls, DOCUMENTATION_COLLECTION_ITEMS_ATTR):
+ return True
+ if _documentation_lines_for_attr(plugin_cls, DOCUMENTATION_COLLECTION_ITEMS_ATTR):
+ return True
+ return False
+
+
+def analyzer_has_table_analysis_coverage(
+ plugin_cls: type, analyzer_cls: type, analyzer_args_cls: Any
+) -> bool:
+ """True if the Analyzer Args table cell would be non-empty from regex/args extraction or doc lines."""
+ if _documentation_lines_for_attr(analyzer_cls, DOCUMENTATION_ANALYSIS_ITEMS_ATTR):
+ return True
+ if _documentation_lines_for_attr(plugin_cls, DOCUMENTATION_ANALYSIS_ITEMS_ATTR):
+ return True
+ if extract_regexes_and_args_from_analyzer(analyzer_cls, analyzer_args_cls):
+ return True
+ return False
+
+
+def collect_plugin_doc_table_coverage_messages(plugins: List[type]) -> List[str]:
+ """Messages for plugins whose generated table would show '-' for collection or analysis unjustifiably."""
+ msgs: List[str] = []
+ for p in plugins:
+ pname = p.__name__
+ for c in iter_plugin_collector_classes(p):
+ if not collector_has_table_collection_coverage(p, c):
+ msgs.append(
+ f"{pname}: collector {c.__name__} has no CMD_* command strings and no "
+ f"{DOCUMENTATION_COLLECTION_ITEMS_ATTR} on the collector or plugin."
+ )
+ an = get_attr(p, "ANALYZER", None)
+ aargs = get_attr(p, "ANALYZER_ARGS", None)
+ if inspect.isclass(an) and not analyzer_has_table_analysis_coverage(p, an, aargs):
+ msgs.append(
+ f"{pname}: analyzer {an.__name__} has no extractable analyzer table content "
+ f"(built-in regexes / *REGEX* attrs / analyzer args fields) and no "
+ f"{DOCUMENTATION_ANALYSIS_ITEMS_ATTR} on the analyzer or plugin."
+ )
+ return msgs
+
+
+def emit_plugin_doc_coverage_warnings(msgs: List[str], *, strict: bool) -> None:
+ if not msgs:
+ return
+ sys.stderr.write("PLUGIN_DOC.md table coverage warnings:\n")
+ for m in msgs:
+ sys.stderr.write(f" WARNING: {m}\n")
+ if strict:
+ sys.stderr.write(
+ f"error: {len(msgs)} plugin documentation coverage warning(s) "
+ "(--strict-plugin-doc-coverage)\n"
+ )
+ sys.exit(1)
+
+
def extract_regexes_and_args_from_analyzer(
analyzer_cls: type, args_cls: Optional[type]
) -> List[str]:
@@ -335,7 +504,8 @@ def escape_table_cell(s: str) -> str:
"""
if not s:
return s
- return s.replace("|", "|").replace("\n", " ").replace("\r", " ")
+ # Avoid @ in cells (e.g. OData property names) being turned into mail/mention links in Outlook/HTML viewers.
+ return s.replace("|", "|").replace("@", "@").replace("\n", " ").replace("\r", " ")
def md_header(text: str, level: int = 2) -> str:
@@ -454,14 +624,14 @@ def generate_plugin_table_rows(plugins: List[type]) -> List[List[str]]:
an = get_attr(p, "ANALYZER", None)
args = get_attr(p, "ANALYZER_ARGS", None)
collector_args_cls = get_attr(p, "COLLECTOR_ARGS", None)
- cmds: List[str] = []
- if inspect.isclass(col):
- cmds = extract_cmds_from_classvars(col)
+ cmds = extract_collection_lines_for_table(p, col)
- # Extract regexes and args from analyzer
- regex_and_args = []
+ # Extract regexes and args from analyzer; optional DOCUMENTATION_ANALYSIS_* lines first
+ regex_and_args: List[str] = extract_analysis_doc_lines_for_table(
+ p, an if inspect.isclass(an) else None
+ )
if inspect.isclass(an):
- regex_and_args = extract_regexes_and_args_from_analyzer(an, args)
+ regex_and_args.extend(extract_regexes_and_args_from_analyzer(an, args))
# Extract collection args from collector args class
collection_args_lines = extract_collection_args_from_collector_args(collector_args_cls)
@@ -504,7 +674,13 @@ def render_collector_section(col: type, link_base: str, rel_root: Optional[str])
_url = setup_link(col, link_base, rel_root)
s += md_kv("Link to code", f"[{Path(_url).name}]({_url})")
- exclude = {"__doc__", "__module__", "__weakref__", "__dict__"}
+ exclude = {
+ "__doc__",
+ "__module__",
+ "__weakref__",
+ "__dict__",
+ DOCUMENTATION_COLLECTION_ITEMS_ATTR,
+ }
cv = class_vars_dump(col, exclude)
if cv:
s += md_header("Class Variables", 3) + md_list(cv)
@@ -516,6 +692,10 @@ def render_collector_section(col: type, link_base: str, rel_root: Optional[str])
if cmds:
s += md_header("Commands", 3) + md_list(cmds)
+ doc_coll = _documentation_lines_for_attr(col, DOCUMENTATION_COLLECTION_ITEMS_ATTR)
+ if doc_coll:
+ s += md_header("Documented collection", 3) + md_list(doc_coll)
+
return s
@@ -529,11 +709,21 @@ def render_analyzer_section(an: type, link_base: str, rel_root: Optional[str]) -
_url = setup_link(an, link_base, rel_root)
s += md_kv("Link to code", f"[{Path(_url).name}]({_url})")
- exclude = {"__doc__", "__module__", "__weakref__", "__dict__"}
+ exclude = {
+ "__doc__",
+ "__module__",
+ "__weakref__",
+ "__dict__",
+ DOCUMENTATION_ANALYSIS_ITEMS_ATTR,
+ }
cv = class_vars_dump(an, exclude)
if cv:
s += md_header("Class Variables", 3) + md_list(cv)
+ doc_an = _documentation_lines_for_attr(an, DOCUMENTATION_ANALYSIS_ITEMS_ATTR)
+ if doc_an:
+ s += md_header("Documented analysis", 3) + md_list(doc_an)
+
# Add regex patterns if present (pass None for args_cls since we don't have context here)
regex_info = extract_regexes_and_args_from_analyzer(an, None)
if regex_info:
@@ -648,9 +838,21 @@ def main():
description="Generate Plugin Table and detail sections with setup_link + rel-root."
)
ap.add_argument(
- "--package", default=DEFAULT_ROOT_PACKAGE, help="Dotted package or filesystem path"
+ "--package",
+ action="append",
+ dest="packages",
+ default=None,
+ metavar="PKG",
+ help=(
+ "Dotted package or filesystem path to import in addition to the default plugin "
+ f"packages ({', '.join(DEFAULT_PACKAGES)}). Repeatable."
+ ),
+ )
+ ap.add_argument(
+ "--output",
+ default="docs/PLUGIN_DOC.md",
+ help="Output Markdown file (default: docs/PLUGIN_DOC.md under repo root)",
)
- ap.add_argument("--output", default="PLUGIN_DOC.md", help="Output Markdown file")
ap.add_argument(
"--update-readme-help",
action="store_true",
@@ -661,31 +863,57 @@ def main():
default=None,
help="Path to README.md (default: README.md in current working directory)",
)
+ ap.add_argument(
+ "--strict-plugin-doc-coverage",
+ action="store_true",
+ help=(
+ "Exit with status 1 if any plugin lacks CMD_* / DOCUMENTATION_COLLECTION_ITEMS "
+ "for collectors or lacks analyzer table content / DOCUMENTATION_ANALYSIS_ITEMS "
+ "when an analyzer is defined."
+ ),
+ )
args = ap.parse_args()
- root = args.package
- root_path = Path(root)
- if os.sep in root or root_path.exists():
- root = dotted_from_path(root_path)
- base = find_inband_plugin_base()
- import_all_modules(root)
-
- def all_subclasses(cls: Type) -> set[type]:
- seen, out, work = set(), set(), [cls]
- while work:
- parent = work.pop()
- for sub in parent.__subclasses__():
- if sub not in seen:
- seen.add(sub)
- out.add(sub)
- work.append(sub)
- return out
-
- plugins = [c for c in all_subclasses(base) if c is not base]
- plugins = [c for c in plugins if not get_attr(c, "__abstractmethods__", set())]
- plugins.sort(key=lambda c: f"{c.__module__}.{c.__name__}".lower())
-
- rows = generate_plugin_table_rows(plugins)
+ normalized_extra: List[str] = []
+ if args.packages:
+ for root in args.packages:
+ root_path = Path(root)
+ if os.sep in root or root_path.exists():
+ root = dotted_from_path(root_path)
+ normalized_extra.append(root)
+
+ # Always import core plugin trees so IB/OOB tables are complete; append optional extras.
+ to_import: List[str] = []
+ seen_pkg: set[str] = set()
+ for pkg in list(DEFAULT_PACKAGES) + normalized_extra:
+ if pkg not in seen_pkg:
+ seen_pkg.add(pkg)
+ to_import.append(pkg)
+
+ for pkg in to_import:
+ import_all_modules(pkg)
+
+ inband_base = find_inband_plugin_base()
+ oob_bases = find_oob_plugin_bases()
+
+ ib_plugins = sorted(
+ plugins_for_package_prefix((inband_base,), PACKAGE_IB_INBAND),
+ key=lambda c: f"{c.__module__}.{c.__name__}".lower(),
+ )
+ oob_plugins = sorted(
+ plugins_for_package_prefix(oob_bases, PACKAGE_OOB),
+ key=lambda c: f"{c.__module__}.{c.__name__}".lower(),
+ )
+ plugins = sorted(
+ set(ib_plugins) | set(oob_plugins),
+ key=lambda c: f"{c.__module__}.{c.__name__}".lower(),
+ )
+
+ coverage_msgs = collect_plugin_doc_table_coverage_messages(plugins)
+ emit_plugin_doc_coverage_warnings(coverage_msgs, strict=args.strict_plugin_doc_coverage)
+
+ ib_rows = generate_plugin_table_rows(ib_plugins)
+ oob_rows = generate_plugin_table_rows(oob_plugins)
headers = [
"Plugin",
"Collection",
@@ -718,8 +946,10 @@ def all_subclasses(cls: Type) -> set[type]:
out = []
out.append(md_header("Plugin Documentation", 1))
- out.append(md_header("Plugin Table", 1))
- out.append(render_table(headers, rows))
+ out.append(md_header("IB Plugins", 1))
+ out.append(render_table(headers, ib_rows))
+ out.append(md_header("OOB plugins", 1))
+ out.append(render_table(headers, oob_rows))
if collectors:
out.append(md_header("Collectors", 1))
diff --git a/nodescraper/plugins/generic_collection/generic_collection_collector.py b/nodescraper/plugins/generic_collection/generic_collection_collector.py
index 873f572a..1c15462b 100644
--- a/nodescraper/plugins/generic_collection/generic_collection_collector.py
+++ b/nodescraper/plugins/generic_collection/generic_collection_collector.py
@@ -41,6 +41,11 @@ class GenericCollectionCollector(
DATA_MODEL = GenericCollectionDataModel
SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.WINDOWS, OSFamily.LINUX, OSFamily.UNKNOWN}
+ DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = (
+ "Runs each command from collection_args.commands on the target (in-band host or BMC over OOB SSH).",
+ "Commands are user-configured; there are no fixed CMD_* class fields.",
+ )
+
def collect_data(
self, args: Optional[GenericCollectionCollectorArgs] = None
) -> tuple[TaskResult, Optional[GenericCollectionDataModel]]:
diff --git a/nodescraper/plugins/regex_search/__init__.py b/nodescraper/plugins/inband/regex_search/__init__.py
similarity index 97%
rename from nodescraper/plugins/regex_search/__init__.py
rename to nodescraper/plugins/inband/regex_search/__init__.py
index 708b6b04..b8ee4a8e 100644
--- a/nodescraper/plugins/regex_search/__init__.py
+++ b/nodescraper/plugins/inband/regex_search/__init__.py
@@ -1,28 +1,28 @@
-###############################################################################
-#
-# 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 .regex_search_plugin import RegexSearchPlugin
-
-__all__ = ["RegexSearchPlugin"]
+###############################################################################
+#
+# 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 .regex_search_plugin import RegexSearchPlugin
+
+__all__ = ["RegexSearchPlugin"]
diff --git a/nodescraper/plugins/regex_search/analyzer_args.py b/nodescraper/plugins/inband/regex_search/analyzer_args.py
similarity index 97%
rename from nodescraper/plugins/regex_search/analyzer_args.py
rename to nodescraper/plugins/inband/regex_search/analyzer_args.py
index b30acb7e..254d6a13 100644
--- a/nodescraper/plugins/regex_search/analyzer_args.py
+++ b/nodescraper/plugins/inband/regex_search/analyzer_args.py
@@ -1,50 +1,50 @@
-###############################################################################
-#
-# MIT License
-#
-# Copyright (c) 2026 Advanced Micro Devices, Inc.
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-#
-###############################################################################
-from typing import Any, Optional
-
-from pydantic import Field
-
-from nodescraper.models import AnalyzerArgs
-
-
-class RegexSearchAnalyzerArgs(AnalyzerArgs):
- """Arguments for RegexSearchAnalyzer (dict items match Dmesg-style error_regex)."""
-
- error_regex: Optional[list[dict[str, Any]]] = Field(
- default=None,
- description=(
- "Regex patterns to search for; each dict may include regex (str), message, "
- "event_category, event_priority (same as Dmesg analyzer error_regex). "
- ),
- )
- interval_to_collapse_event: int = Field(
- default=60,
- description="Seconds within which repeated events are collapsed into one.",
- )
- num_timestamps: int = Field(
- default=3,
- description="Number of timestamps to include per event in output.",
- )
+###############################################################################
+#
+# MIT License
+#
+# Copyright (c) 2026 Advanced Micro Devices, Inc.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+###############################################################################
+from typing import Any, Optional
+
+from pydantic import Field
+
+from nodescraper.models import AnalyzerArgs
+
+
+class RegexSearchAnalyzerArgs(AnalyzerArgs):
+ """Arguments for RegexSearchAnalyzer (dict items match Dmesg-style error_regex)."""
+
+ error_regex: Optional[list[dict[str, Any]]] = Field(
+ default=None,
+ description=(
+ "Regex patterns to search for; each dict may include regex (str), message, "
+ "event_category, event_priority (same as Dmesg analyzer error_regex). "
+ ),
+ )
+ interval_to_collapse_event: int = Field(
+ default=60,
+ description="Seconds within which repeated events are collapsed into one.",
+ )
+ num_timestamps: int = Field(
+ default=3,
+ description="Number of timestamps to include per event in output.",
+ )
diff --git a/nodescraper/plugins/regex_search/regex_search_analyzer.py b/nodescraper/plugins/inband/regex_search/regex_search_analyzer.py
similarity index 97%
rename from nodescraper/plugins/regex_search/regex_search_analyzer.py
rename to nodescraper/plugins/inband/regex_search/regex_search_analyzer.py
index 0b4384f4..85da6501 100644
--- a/nodescraper/plugins/regex_search/regex_search_analyzer.py
+++ b/nodescraper/plugins/inband/regex_search/regex_search_analyzer.py
@@ -1,102 +1,102 @@
-###############################################################################
-#
-# 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 os
-from typing import Optional, Union
-
-from nodescraper.base.regexanalyzer import ErrorRegex, RegexAnalyzer, RegexEvent
-from nodescraper.enums import ExecutionStatus
-from nodescraper.models import TaskResult
-
-from .analyzer_args import RegexSearchAnalyzerArgs
-from .regex_search_data import RegexSearchData
-
-
-class RegexSearchAnalyzer(RegexAnalyzer[RegexSearchData, RegexSearchAnalyzerArgs]):
- """Run user-provided regexes against text loaded from --data (file or directory)."""
-
- DATA_MODEL = RegexSearchData
-
- ERROR_REGEX: list[ErrorRegex] = []
-
- def _build_regex_event(
- self, regex_obj: ErrorRegex, match: Union[str, list[str]], source: str
- ) -> RegexEvent:
- """Augment the default event text with a file path when the origin is a concrete path.
-
- Args:
- regex_obj: Metadata for the rule that produced the match.
- match: Substring or grouped capture text from the pattern.
- source: Origin label, or an absolute path when matching per file.
-
- Returns:
- Match record with an extended description when a path-like source is present.
- """
- event = super()._build_regex_event(regex_obj, match, source)
- if source and source != "regex_search":
- event.description = f"{regex_obj.message} [file: {source}]"
- return event
-
- def analyze_data(
- self,
- data: RegexSearchData,
- args: Optional[RegexSearchAnalyzerArgs] = None,
- ) -> TaskResult:
- """Scan loaded inputs with the given patterns, or mark the task not run if inputs are incomplete.
-
- Args:
- data: Aggregated and per-file text loaded from the user data path.
- args: Optional pattern list and timing knobs; omitted or empty patterns skip work.
-
- Returns:
- Work outcome with match events, or a not-run status when patterns are absent.
- """
- if args is None or not args.error_regex:
- self.result.status = ExecutionStatus.NOT_RAN
- self.result.message = "Analysis args need to be provided for the analyzer to run"
- return self.result
-
- final_regex = self._convert_and_extend_error_regex(args.error_regex, [])
-
- if data.files:
- for rel_path in sorted(data.files.keys()):
- file_content = data.files[rel_path]
- abs_source = os.path.normpath(os.path.join(data.data_root, rel_path))
- self.result.events += self.check_all_regexes(
- content=file_content,
- source=abs_source,
- error_regex=final_regex,
- num_timestamps=args.num_timestamps,
- interval_to_collapse_event=args.interval_to_collapse_event,
- )
- else:
- self.result.events += self.check_all_regexes(
- content=data.content,
- source=data.data_root or "regex_search",
- error_regex=final_regex,
- num_timestamps=args.num_timestamps,
- interval_to_collapse_event=args.interval_to_collapse_event,
- )
- return self.result
+###############################################################################
+#
+# 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 os
+from typing import Optional, Union
+
+from nodescraper.base.regexanalyzer import ErrorRegex, RegexAnalyzer, RegexEvent
+from nodescraper.enums import ExecutionStatus
+from nodescraper.models import TaskResult
+
+from .analyzer_args import RegexSearchAnalyzerArgs
+from .regex_search_data import RegexSearchData
+
+
+class RegexSearchAnalyzer(RegexAnalyzer[RegexSearchData, RegexSearchAnalyzerArgs]):
+ """Run user-provided regexes against text loaded from --data (file or directory)."""
+
+ DATA_MODEL = RegexSearchData
+
+ ERROR_REGEX: list[ErrorRegex] = []
+
+ def _build_regex_event(
+ self, regex_obj: ErrorRegex, match: Union[str, list[str]], source: str
+ ) -> RegexEvent:
+ """Augment the default event text with a file path when the origin is a concrete path.
+
+ Args:
+ regex_obj: Metadata for the rule that produced the match.
+ match: Substring or grouped capture text from the pattern.
+ source: Origin label, or an absolute path when matching per file.
+
+ Returns:
+ Match record with an extended description when a path-like source is present.
+ """
+ event = super()._build_regex_event(regex_obj, match, source)
+ if source and source != "regex_search":
+ event.description = f"{regex_obj.message} [file: {source}]"
+ return event
+
+ def analyze_data(
+ self,
+ data: RegexSearchData,
+ args: Optional[RegexSearchAnalyzerArgs] = None,
+ ) -> TaskResult:
+ """Scan loaded inputs with the given patterns, or mark the task not run if inputs are incomplete.
+
+ Args:
+ data: Aggregated and per-file text loaded from the user data path.
+ args: Optional pattern list and timing knobs; omitted or empty patterns skip work.
+
+ Returns:
+ Work outcome with match events, or a not-run status when patterns are absent.
+ """
+ if args is None or not args.error_regex:
+ self.result.status = ExecutionStatus.NOT_RAN
+ self.result.message = "Analysis args need to be provided for the analyzer to run"
+ return self.result
+
+ final_regex = self._convert_and_extend_error_regex(args.error_regex, [])
+
+ if data.files:
+ for rel_path in sorted(data.files.keys()):
+ file_content = data.files[rel_path]
+ abs_source = os.path.normpath(os.path.join(data.data_root, rel_path))
+ self.result.events += self.check_all_regexes(
+ content=file_content,
+ source=abs_source,
+ error_regex=final_regex,
+ num_timestamps=args.num_timestamps,
+ interval_to_collapse_event=args.interval_to_collapse_event,
+ )
+ else:
+ self.result.events += self.check_all_regexes(
+ content=data.content,
+ source=data.data_root or "regex_search",
+ error_regex=final_regex,
+ num_timestamps=args.num_timestamps,
+ interval_to_collapse_event=args.interval_to_collapse_event,
+ )
+ return self.result
diff --git a/nodescraper/plugins/regex_search/regex_search_data.py b/nodescraper/plugins/inband/regex_search/regex_search_data.py
similarity index 97%
rename from nodescraper/plugins/regex_search/regex_search_data.py
rename to nodescraper/plugins/inband/regex_search/regex_search_data.py
index a12b2841..1e094d45 100644
--- a/nodescraper/plugins/regex_search/regex_search_data.py
+++ b/nodescraper/plugins/inband/regex_search/regex_search_data.py
@@ -1,107 +1,107 @@
-###############################################################################
-#
-# 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 os
-from pathlib import Path
-from typing import Union
-
-from pydantic import Field
-
-from nodescraper.models import DataModel
-from nodescraper.utils import get_unique_filename
-
-
-class RegexSearchData(DataModel):
- """Loaded file or directory contents passed to the analyzer (via --data)."""
-
- content: str
- data_root: str = ""
- files: dict[str, str] = Field(default_factory=dict)
-
- def log_model(self, log_path: str) -> None:
- """Persist the aggregated text payload as one log file under the given base path.
-
- Args:
- log_path: Directory where the log file should be written.
-
- Returns:
- None.
- """
- log_name = os.path.join(log_path, get_unique_filename(log_path, "regex_search_source.log"))
- with open(log_name, "w", encoding="utf-8") as log_file:
- log_file.write(self.content)
-
- @classmethod
- def import_model(cls, model_input: Union[dict, str]) -> "RegexSearchData":
- """Import datamodel.
-
- Args:
- model_input: Keyed fields for direct validation, or a path string to load from disk.
-
- Returns:
- Instance with content, root path, and per-file bodies filled in.
- """
- if isinstance(model_input, dict):
- return cls.model_validate(model_input)
- if isinstance(model_input, str):
- return cls._from_filesystem_path(model_input)
- raise ValueError("Invalid input for regex search data")
-
- @classmethod
- def _from_filesystem_path(cls, path: str) -> "RegexSearchData":
- """Read one file or every file under a directory into a merged view plus a path-to-text map.
-
- Args:
- path: Absolute or resolvable path to a file or directory.
-
- Returns:
- Instance built from the read text and discovered relative paths.
-
- """
- path = os.path.abspath(path)
- if not os.path.exists(path):
- raise FileNotFoundError(f"Path not found: {path}")
- if os.path.isfile(path):
- text = Path(path).read_text(encoding="utf-8", errors="replace")
- rel = os.path.basename(path)
- data_root = os.path.dirname(path) or os.path.abspath(os.path.curdir)
- return cls(content=text, data_root=data_root, files={rel: text})
- if os.path.isdir(path):
- files: dict[str, str] = {}
- parts: list[str] = []
- for root, _dirs, filenames in os.walk(path):
- for name in sorted(filenames):
- fp = os.path.join(root, name)
- if not os.path.isfile(fp):
- continue
- rel = os.path.relpath(fp, path)
- try:
- text = Path(fp).read_text(encoding="utf-8", errors="replace")
- except OSError:
- continue
- files[rel] = text
- parts.append(f"===== {rel} =====\n{text}")
- return cls(content="\n".join(parts), data_root=path, files=files)
- raise ValueError(f"Unsupported path type: {path}")
+###############################################################################
+#
+# 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 os
+from pathlib import Path
+from typing import Union
+
+from pydantic import Field
+
+from nodescraper.models import DataModel
+from nodescraper.utils import get_unique_filename
+
+
+class RegexSearchData(DataModel):
+ """Loaded file or directory contents passed to the analyzer (via --data)."""
+
+ content: str
+ data_root: str = ""
+ files: dict[str, str] = Field(default_factory=dict)
+
+ def log_model(self, log_path: str) -> None:
+ """Persist the aggregated text payload as one log file under the given base path.
+
+ Args:
+ log_path: Directory where the log file should be written.
+
+ Returns:
+ None.
+ """
+ log_name = os.path.join(log_path, get_unique_filename(log_path, "regex_search_source.log"))
+ with open(log_name, "w", encoding="utf-8") as log_file:
+ log_file.write(self.content)
+
+ @classmethod
+ def import_model(cls, model_input: Union[dict, str]) -> "RegexSearchData":
+ """Import datamodel.
+
+ Args:
+ model_input: Keyed fields for direct validation, or a path string to load from disk.
+
+ Returns:
+ Instance with content, root path, and per-file bodies filled in.
+ """
+ if isinstance(model_input, dict):
+ return cls.model_validate(model_input)
+ if isinstance(model_input, str):
+ return cls._from_filesystem_path(model_input)
+ raise ValueError("Invalid input for regex search data")
+
+ @classmethod
+ def _from_filesystem_path(cls, path: str) -> "RegexSearchData":
+ """Read one file or every file under a directory into a merged view plus a path-to-text map.
+
+ Args:
+ path: Absolute or resolvable path to a file or directory.
+
+ Returns:
+ Instance built from the read text and discovered relative paths.
+
+ """
+ path = os.path.abspath(path)
+ if not os.path.exists(path):
+ raise FileNotFoundError(f"Path not found: {path}")
+ if os.path.isfile(path):
+ text = Path(path).read_text(encoding="utf-8", errors="replace")
+ rel = os.path.basename(path)
+ data_root = os.path.dirname(path) or os.path.abspath(os.path.curdir)
+ return cls(content=text, data_root=data_root, files={rel: text})
+ if os.path.isdir(path):
+ files: dict[str, str] = {}
+ parts: list[str] = []
+ for root, _dirs, filenames in os.walk(path):
+ for name in sorted(filenames):
+ fp = os.path.join(root, name)
+ if not os.path.isfile(fp):
+ continue
+ rel = os.path.relpath(fp, path)
+ try:
+ text = Path(fp).read_text(encoding="utf-8", errors="replace")
+ except OSError:
+ continue
+ files[rel] = text
+ parts.append(f"===== {rel} =====\n{text}")
+ return cls(content="\n".join(parts), data_root=path, files=files)
+ raise ValueError(f"Unsupported path type: {path}")
diff --git a/nodescraper/plugins/regex_search/regex_search_plugin.py b/nodescraper/plugins/inband/regex_search/regex_search_plugin.py
similarity index 85%
rename from nodescraper/plugins/regex_search/regex_search_plugin.py
rename to nodescraper/plugins/inband/regex_search/regex_search_plugin.py
index 36d650c6..2a101ff8 100644
--- a/nodescraper/plugins/regex_search/regex_search_plugin.py
+++ b/nodescraper/plugins/inband/regex_search/regex_search_plugin.py
@@ -1,76 +1,73 @@
-###############################################################################
-#
-# MIT License
-#
-# Copyright (c) 2026 Advanced Micro Devices, Inc.
-#
-# Permission is hereby granted, free of charge, to any person obtaining a copy
-# of this software and associated documentation files (the "Software"), to deal
-# in the Software without restriction, including without limitation the rights
-# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
-# copies of the Software, and to permit persons to whom the Software is
-# furnished to do so, subject to the following conditions:
-#
-# The above copyright notice and this permission notice shall be included in all
-# copies or substantial portions of the Software.
-#
-# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
-# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
-# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
-# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
-# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
-# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
-# SOFTWARE.
-#
-###############################################################################
-from typing import Optional, Union
-
-from nodescraper.connection.inband import InBandConnectionManager, SSHConnectionParams
-from nodescraper.enums import EventPriority
-from nodescraper.interfaces import DataPlugin
-from nodescraper.models import CollectorArgs, TaskResult
-
-from .analyzer_args import RegexSearchAnalyzerArgs
-from .regex_search_analyzer import RegexSearchAnalyzer
-from .regex_search_data import RegexSearchData
-
-
-class RegexSearchPlugin(
- DataPlugin[
- InBandConnectionManager,
- SSHConnectionParams,
- RegexSearchData,
- CollectorArgs,
- RegexSearchAnalyzerArgs,
- ]
-):
- """Analyzer-only plugin: search user regexes against a file or directory (--data)."""
-
- DATA_MODEL = RegexSearchData
- ANALYZER = RegexSearchAnalyzer
-
- def analyze(
- self,
- max_event_priority_level: Optional[Union[EventPriority, str]] = EventPriority.CRITICAL,
- analysis_args: Optional[Union[RegexSearchAnalyzerArgs, dict]] = None,
- data: Optional[Union[str, dict, RegexSearchData]] = None,
- ) -> TaskResult:
- if analysis_args is None:
- missing_error_regex = True
- elif isinstance(analysis_args, RegexSearchAnalyzerArgs):
- missing_error_regex = not bool(analysis_args.error_regex)
- elif isinstance(analysis_args, dict):
- er = analysis_args.get("error_regex")
- missing_error_regex = er is None or er == []
- else:
- missing_error_regex = True
- if missing_error_regex:
- self.logger.warning(
- "RegexSearchPlugin: analysis args need to be provided for the analyzer to run "
- "(e.g. --error-regex for each pattern)."
- )
- return super().analyze(
- max_event_priority_level=max_event_priority_level,
- analysis_args=analysis_args,
- data=data,
- )
+###############################################################################
+#
+# MIT License
+#
+# Copyright (c) 2026 Advanced Micro Devices, Inc.
+#
+# Permission is hereby granted, free of charge, to any person obtaining a copy
+# of this software and associated documentation files (the "Software"), to deal
+# in the Software without restriction, including without limitation the rights
+# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+# copies of the Software, and to permit persons to whom the Software is
+# furnished to do so, subject to the following conditions:
+#
+# The above copyright notice and this permission notice shall be included in all
+# copies or substantial portions of the Software.
+#
+# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+# SOFTWARE.
+#
+###############################################################################
+from typing import Optional, Union
+
+from nodescraper.base import InBandDataPlugin
+from nodescraper.enums import EventPriority
+from nodescraper.models import CollectorArgs, TaskResult
+
+from .analyzer_args import RegexSearchAnalyzerArgs
+from .regex_search_analyzer import RegexSearchAnalyzer
+from .regex_search_data import RegexSearchData
+
+
+class RegexSearchPlugin(InBandDataPlugin[RegexSearchData, CollectorArgs, RegexSearchAnalyzerArgs]):
+ """Analyzer-only plugin: search user regexes against a file or directory (--data)."""
+
+ DATA_MODEL = RegexSearchData
+ ANALYZER = RegexSearchAnalyzer
+ ANALYZER_ARGS = RegexSearchAnalyzerArgs
+
+ DOCUMENTATION_ANALYSIS_ITEMS: tuple[str, ...] = (
+ "Runs RegexSearchAnalyzer: user-defined patterns via analysis_args.error_regex (same shape as Dmesg).",
+ "Emits regex match events with optional per-file source in the description when scanning directories.",
+ )
+
+ def analyze(
+ self,
+ max_event_priority_level: Optional[Union[EventPriority, str]] = EventPriority.CRITICAL,
+ analysis_args: Optional[Union[RegexSearchAnalyzerArgs, dict]] = None,
+ data: Optional[Union[str, dict, RegexSearchData]] = None,
+ ) -> TaskResult:
+ if analysis_args is None:
+ missing_error_regex = True
+ elif isinstance(analysis_args, RegexSearchAnalyzerArgs):
+ missing_error_regex = not bool(analysis_args.error_regex)
+ elif isinstance(analysis_args, dict):
+ er = analysis_args.get("error_regex")
+ missing_error_regex = er is None or er == []
+ else:
+ missing_error_regex = True
+ if missing_error_regex:
+ self.logger.warning(
+ "RegexSearchPlugin: analysis args need to be provided for the analyzer to run "
+ "(e.g. --error-regex for each pattern)."
+ )
+ return super().analyze(
+ max_event_priority_level=max_event_priority_level,
+ analysis_args=analysis_args,
+ data=data,
+ )
diff --git a/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py
index 547ba80d..722122ca 100644
--- a/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py
+++ b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py
@@ -41,6 +41,11 @@ class BmcArchiveCollector(InBandDataCollector[BmcArchiveDataModel, BmcArchiveCol
DATA_MODEL = BmcArchiveDataModel
SUPPORTED_OS_FAMILY = {OSFamily.LINUX, OSFamily.UNKNOWN}
+ DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = (
+ "SSH (BMC) shell: tar+gzip archives for each path in collection_args (see PathSpec entries).",
+ "Uses sudo on the BMC when collection_args paths require elevated access.",
+ )
+
REMOTE_ARCHIVE_TEMPLATE = "/tmp/node_scraper_{name}.tar.gz"
# None until first probe in a run; collect_data resets so each collection re-probes.
_tar_ignore_failed_read_supported: Optional[bool] = None
diff --git a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py
index 189c5edf..6583075e 100644
--- a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py
+++ b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py
@@ -59,7 +59,10 @@ class RedfishEndpointCollectorArgs(CollectorArgs):
)
follow_next_link: bool = Field(
default=False,
- description="If True, follow Members@odata.nextLink pagination for each URI and merge all pages into a single response.",
+ description=(
+ "If True, follow Redfish Members collection OData nextLink pagination for each URI "
+ "and merge all pages into a single response."
+ ),
)
max_pages: int = Field(
default=200,
diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py
index 59dd7a8d..1e43a71a 100644
--- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py
+++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_analyzer.py
@@ -89,6 +89,12 @@ class RedfishEndpointAnalyzer(DataAnalyzer[RedfishEndpointDataModel, RedfishEndp
DATA_MODEL = RedfishEndpointDataModel
+ 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.).",
+ 'URI key "*" runs checks against every collected response body.',
+ )
+
def analyze_data(
self,
data: RedfishEndpointDataModel,
diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py
index e0878c1a..37bd839b 100644
--- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py
+++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py
@@ -152,6 +152,13 @@ class RedfishEndpointCollector(
DATA_MODEL = RedfishEndpointDataModel
+ DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = (
+ "Redfish GET: explicit paths from collection_args.uris (parallel when max_workers>1).",
+ "Optional paged GET following the Members collection OData nextLink field when follow_next_link is true.",
+ "Redfish GET tree: when discover_tree is true, walks from api_root using OData resource id links and "
+ "Members navigation (depth and endpoint caps from collection_args).",
+ )
+
def collect_data(
self, args: Optional[RedfishEndpointCollectorArgs] = None
) -> tuple[TaskResult, Optional[RedfishEndpointDataModel]]:
diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_analyzer.py b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_analyzer.py
index c54d9e2f..11aaa1e8 100644
--- a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_analyzer.py
+++ b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_analyzer.py
@@ -38,6 +38,11 @@ class RedfishOemDiagAnalyzer(DataAnalyzer[RedfishOemDiagDataModel, RedfishOemDia
DATA_MODEL = RedfishOemDiagDataModel
+ DOCUMENTATION_ANALYSIS_ITEMS: tuple[str, ...] = (
+ "Summarizes success/failure per OEM diagnostic type from collected results.",
+ "When analysis_args.require_all_success is true, fails the run if any type failed collection.",
+ )
+
def analyze_data(
self,
data: RedfishOemDiagDataModel,
diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py
index b406ef38..f2e3d1d2 100644
--- a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py
+++ b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py
@@ -43,6 +43,12 @@ class RedfishOemDiagCollector(
DATA_MODEL = RedfishOemDiagDataModel
+ DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = (
+ "Redfish LogService.CollectDiagnosticData for each entry in collection_args.oem_diagnostic_types "
+ "(collection_args.log_service_path selects the LogService).",
+ "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)
diff --git a/test/unit/plugin/test_regex_search_analyzer.py b/test/unit/plugin/test_regex_search_analyzer.py
index ac018ee1..e93b93da 100644
--- a/test/unit/plugin/test_regex_search_analyzer.py
+++ b/test/unit/plugin/test_regex_search_analyzer.py
@@ -28,10 +28,16 @@
import tempfile
from nodescraper.enums.executionstatus import ExecutionStatus
-from nodescraper.plugins.regex_search.analyzer_args import RegexSearchAnalyzerArgs
-from nodescraper.plugins.regex_search.regex_search_analyzer import RegexSearchAnalyzer
-from nodescraper.plugins.regex_search.regex_search_data import RegexSearchData
-from nodescraper.plugins.regex_search.regex_search_plugin import RegexSearchPlugin
+from nodescraper.plugins.inband.regex_search.analyzer_args import (
+ RegexSearchAnalyzerArgs,
+)
+from nodescraper.plugins.inband.regex_search.regex_search_analyzer import (
+ RegexSearchAnalyzer,
+)
+from nodescraper.plugins.inband.regex_search.regex_search_data import RegexSearchData
+from nodescraper.plugins.inband.regex_search.regex_search_plugin import (
+ RegexSearchPlugin,
+)
EXPECTED_MISSING_ANALYSIS_MSG = "Analysis args need to be provided for the analyzer to run"