From 89e10dd774d4f1c009082afc5d2dcb85d46b9696 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Fri, 12 Jun 2026 09:48:30 -0500 Subject: [PATCH 1/3] first pass at picking up oob for PLUGIN_DOC --- .github/workflows/update-plugin-docs.yml | 1 - docs/PLUGIN_DOC.md | 206 +++++++++++- docs/generate_plugin_doc_bundle.py | 301 +++++++++++++++--- .../generic_collection_collector.py | 5 + .../{ => inband}/regex_search/__init__.py | 56 ++-- .../regex_search/analyzer_args.py | 100 +++--- .../regex_search/regex_search_analyzer.py | 204 ++++++------ .../regex_search/regex_search_data.py | 214 ++++++------- .../regex_search/regex_search_plugin.py | 143 ++++----- .../bmc_archive/bmc_archive_collector.py | 5 + .../redfish_endpoint/endpoint_analyzer.py | 6 + .../redfish_endpoint/endpoint_collector.py | 7 + .../redfish_oem_diag/oem_diag_analyzer.py | 5 + .../redfish_oem_diag/oem_diag_collector.py | 6 + .../unit/plugin/test_regex_search_analyzer.py | 14 +- 15 files changed, 866 insertions(+), 407 deletions(-) rename nodescraper/plugins/{ => inband}/regex_search/__init__.py (97%) rename nodescraper/plugins/{ => inband}/regex_search/analyzer_args.py (97%) rename nodescraper/plugins/{ => inband}/regex_search/regex_search_analyzer.py (97%) rename nodescraper/plugins/{ => inband}/regex_search/regex_search_data.py (97%) rename nodescraper/plugins/{ => inband}/regex_search/regex_search_plugin.py (87%) 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..e0d84df8 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 | - | - | - | [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 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). | 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 Members@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..fb47a297 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]: @@ -454,14 +623,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 +673,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 +691,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 +708,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,7 +837,15 @@ 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="PLUGIN_DOC.md", help="Output Markdown file") ap.add_argument( @@ -661,31 +858,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 +941,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 87% rename from nodescraper/plugins/regex_search/regex_search_plugin.py rename to nodescraper/plugins/inband/regex_search/regex_search_plugin.py index 36d650c6..3b923550 100644 --- a/nodescraper/plugins/regex_search/regex_search_plugin.py +++ b/nodescraper/plugins/inband/regex_search/regex_search_plugin.py @@ -1,76 +1,67 @@ -############################################################################### -# -# 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 + + 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/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..2a6715c6 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 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).", + ) + 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" From b901cd867431b65d1a6faad62967f008bcbe950e Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Fri, 12 Jun 2026 10:05:03 -0500 Subject: [PATCH 2/3] fixes for doc --- docs/PLUGIN_DOC.md | 4 ++-- docs/generate_plugin_doc_bundle.py | 9 +++++++-- .../plugins/inband/regex_search/regex_search_plugin.py | 9 +++++++++ .../plugins/ooband/redfish_endpoint/collector_args.py | 5 ++++- .../ooband/redfish_endpoint/endpoint_collector.py | 6 +++--- 5 files changed, 25 insertions(+), 8 deletions(-) diff --git a/docs/PLUGIN_DOC.md b/docs/PLUGIN_DOC.md index e0d84df8..94dc5227 100644 --- a/docs/PLUGIN_DOC.md +++ b/docs/PLUGIN_DOC.md @@ -24,7 +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 | - | - | - | [RegexSearchData](#RegexSearchData-Model) | - | [RegexSearchAnalyzer](#Data-Analyzer-Class-RegexSearchAnalyzer) | +| RegexSearchPlugin | No COLLECTOR: data is the text loaded from --data (file or directory) or equivalent input. | 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) | @@ -37,7 +37,7 @@ | 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 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). | 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 Members@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) | +| 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 diff --git a/docs/generate_plugin_doc_bundle.py b/docs/generate_plugin_doc_bundle.py index fb47a297..96042329 100644 --- a/docs/generate_plugin_doc_bundle.py +++ b/docs/generate_plugin_doc_bundle.py @@ -504,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: @@ -847,7 +848,11 @@ def main(): f"packages ({', '.join(DEFAULT_PACKAGES)}). Repeatable." ), ) - ap.add_argument("--output", default="PLUGIN_DOC.md", help="Output Markdown file") + ap.add_argument( + "--output", + default="docs/PLUGIN_DOC.md", + help="Output Markdown file (default: docs/PLUGIN_DOC.md under repo root)", + ) ap.add_argument( "--update-readme-help", action="store_true", diff --git a/nodescraper/plugins/inband/regex_search/regex_search_plugin.py b/nodescraper/plugins/inband/regex_search/regex_search_plugin.py index 3b923550..c33359f1 100644 --- a/nodescraper/plugins/inband/regex_search/regex_search_plugin.py +++ b/nodescraper/plugins/inband/regex_search/regex_search_plugin.py @@ -39,6 +39,15 @@ class RegexSearchPlugin(InBandDataPlugin[RegexSearchData, CollectorArgs, RegexSe DATA_MODEL = RegexSearchData ANALYZER = RegexSearchAnalyzer + ANALYZER_ARGS = RegexSearchAnalyzerArgs + + DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = ( + "No COLLECTOR: data is the text loaded from --data (file or directory) or equivalent input.", + ) + 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, 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_collector.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py index 2a6715c6..37bd839b 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py @@ -154,9 +154,9 @@ class RedfishEndpointCollector( DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = ( "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).", + "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( From 4ffe6b6c30e5764749006d35d15ae3a2794bf28a Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Fri, 12 Jun 2026 10:07:50 -0500 Subject: [PATCH 3/3] fixes for doc --- docs/PLUGIN_DOC.md | 2 +- nodescraper/plugins/inband/regex_search/regex_search_plugin.py | 3 --- 2 files changed, 1 insertion(+), 4 deletions(-) diff --git a/docs/PLUGIN_DOC.md b/docs/PLUGIN_DOC.md index 94dc5227..e15ce92a 100644 --- a/docs/PLUGIN_DOC.md +++ b/docs/PLUGIN_DOC.md @@ -24,7 +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 | No COLLECTOR: data is the text loaded from --data (file or directory) or equivalent input. | 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) | +| 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) | diff --git a/nodescraper/plugins/inband/regex_search/regex_search_plugin.py b/nodescraper/plugins/inband/regex_search/regex_search_plugin.py index c33359f1..2a101ff8 100644 --- a/nodescraper/plugins/inband/regex_search/regex_search_plugin.py +++ b/nodescraper/plugins/inband/regex_search/regex_search_plugin.py @@ -41,9 +41,6 @@ class RegexSearchPlugin(InBandDataPlugin[RegexSearchData, CollectorArgs, RegexSe ANALYZER = RegexSearchAnalyzer ANALYZER_ARGS = RegexSearchAnalyzerArgs - DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = ( - "No COLLECTOR: data is the text loaded from --data (file or directory) or equivalent input.", - ) 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.",