diff --git a/docs/PLUGIN_DOC.md b/docs/PLUGIN_DOC.md
index eb7a3601..5fcad7d0 100644
--- a/docs/PLUGIN_DOC.md
+++ b/docs/PLUGIN_DOC.md
@@ -30,7 +30,7 @@
| StoragePlugin | sh -c 'df -lH -B1 | grep -v 'boot''
wmic LogicalDisk Where DriveType="3" Get DeviceId,Size,FreeSpace | **Analyzer Args:**
- `min_required_free_space_abs`: Optional[str] — Minimum required free space per mount (e.g. '10G', '1T').
- `min_required_free_space_prct`: Optional[int] — Minimum required free space as percentage of total (0–100).
- `ignore_devices`: Optional[list[str]] — Mount points or devices to exclude from free-space checks.
- `check_devices`: Optional[list[str]] — If non-empty, only these mount points or devices are checked.
- `regex_match`: bool — If True, match device/mount names with regex; otherwise exact match. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `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) |
| ScaleOutAristaPlugin | show interfaces counters bins | json | no-more
show interfaces counters queue | no-more
show interfaces counters queue drop-precedence | no-more
show qos interfaces ecn counters queue | json | no-more
show interfaces counters errors | json | no-more
show interfaces phy | no-more
show interfaces phy detail | no-more
show interfaces counters ip | json | no-more
show ip interface | no-more
show lldp | no-more
show lldp neighbors | json | no-more
show interfaces counters | json | no-more
show interfaces flow-control | json | no-more
show interfaces counters queue detail | no-more
show priority-flow-control counters | json | no-more
show priority-flow-control status | no-more
show interfaces status | json | no-more
show qos interfaces | no-more
show qos interfaces ecn | no-more
show qos interfaces trust | no-more
show qos maps | no-more
show qos profile | no-more
show qos profile summary | no-more
show interfaces counters rates | json | no-more
show running-config | no-more
show startup-config | no-more
show system environment cooling | json | no-more
show platform trident mmu queue status | no-more
show version | json | no-more | **Analyzer Args:**
- `analysis_ports`: Optional[List[str]] — Restrict per-port analysis to the given ports. Ports are S/P/[SP] where subport is optional (e.g. ['1/1', '1/31', '1/...
- `expected_port_bandwidth`: int — Expected interface bandwidth (bps) from show interfaces status (AristaPortStatus.bandwidth). Ports with a different b... | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output. Re-runs successful... | [ScaleOutAristaDataModel](#ScaleOutAristaDataModel-Model) | [ScaleOutAristaCollector](#Collector-Class-ScaleOutAristaCollector) | [ScaleOutAristaAnalyzer](#Data-Analyzer-Class-ScaleOutAristaAnalyzer) |
| ScaleOutDellPlugin | show alarm | no-more
show buffer pool | no-more
show buffer profile | no-more
show clock | no-more
show interface counters {port} | no-more
show event details | no-more
show interface fec status | no-more
show interface counters | no-more
show interface counters rate | no-more
show interface Eth | no-more
show interface phy counters | no-more
show interface status | no-more
show interface transceiver | no-more
show interface transceiver dom | no-more
show interface transceiver summary | no-more
show ip arp | no-more
show ip interfaces | no-more
show ip route | no-more
show lldp neighbor | no-more
show lldp table | no-more
show qos interface Ethall priority-flow-control statistics | no-more
show priority-flow-control watchdog | no-more
show qos interface Ethall queue all priority-flow-control watchdog-statistics | no-more
show platform environment | no-more
show platform firmware detail | no-more
show platform syseeprom | no-more
show qos interface Eth all | no-more
show qos interface Eth all queue all | no-more
show qos map dot1p-tc | no-more
show qos map dscp-tc | no-more
show qos map pfc-priority-pg | no-more
show qos map pfc-priority-queue | no-more
show qos map tc-dot1p | no-more
show qos map tc-dscp | no-more
show qos map tc-pg | no-more
show qos map tc-queue | no-more
show qos scheduler-policy | no-more
show qos wred-policy | no-more
show queue counters | no-more
show queue persistent-watermark multicast | no-more
show queue persistent-watermark unicast | no-more
show queue watermark multicast | no-more
show queue watermark unicast | no-more
show running-configuration | no-more
show version | no-more | **Analyzer Args:**
- `analysis_ports`: Optional[List[str]] — Restrict per-port analysis to the given ports. Accepts optional Eth prefix (e.g. ['1/1', '1/31', '1/1/1'] or ['Eth1/1...
- `expected_port_speed`: int — Expected interface speed (Mbps) from show interface status (DellInterfaceStatus.speed). Ports with a different speed... | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `collection_ports`: Optional[List[str]] — Restrict detail counter collection to these ports. Accepts the same tokens as analysis_ports (e.g. ['1/1', '1/1/2'] o... | [ScaleOutDellDataModel](#ScaleOutDellDataModel-Model) | [ScaleOutDellCollector](#Collector-Class-ScaleOutDellCollector) | [ScaleOutDellAnalyzer](#Data-Analyzer-Class-ScaleOutDellAnalyzer) |
-| 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:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `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) |
+| 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:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `paths`: list[str] — Sysfs paths to read (cat). Paths with '\*' are cat'd via bash (e.g. class/drm/card\*/device/vbios_version); if cat fails, ls -l is used.
- `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) |
| SysctlPlugin | sysctl -n | **Analyzer Args:**
- `exp_vm_swappiness`: Optional[int] — Expected vm.swappiness value.
- `exp_vm_numa_balancing`: Optional[int] — Expected vm.numa_balancing value.
- `exp_vm_oom_kill_allocating_task`: Optional[int] — Expected vm.oom_kill_allocating_task value.
- `exp_vm_compaction_proactiveness`: Optional[int] — Expected vm.compaction_proactiveness value.
- `exp_vm_compact_unevictable_allowed`: Optional[int] — Expected vm.compact_unevictable_allowed value.
- `exp_vm_extfrag_threshold`: Optional[int] — Expected vm.extfrag_threshold value.
- `exp_vm_zone_reclaim_mode`: Optional[int] — Expected vm.zone_reclaim_mode value.
- `exp_vm_dirty_background_ratio`: Optional[int] — Expected vm.dirty_background_ratio value.
- `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. | - | [SysctlDataModel](#SysctlDataModel-Model) | [SysctlCollector](#Collector-Class-SysctlCollector) | [SysctlAnalyzer](#Data-Analyzer-Class-SysctlAnalyzer) |
| 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) | - |
diff --git a/nodescraper/plugins/inband/sys_settings/collector_args.py b/nodescraper/plugins/inband/sys_settings/collector_args.py
index 7e49f228..7fe0bdad 100644
--- a/nodescraper/plugins/inband/sys_settings/collector_args.py
+++ b/nodescraper/plugins/inband/sys_settings/collector_args.py
@@ -31,13 +31,18 @@
class SysSettingsCollectorArgs(CollectorArgs):
"""Collection args for SysSettingsCollector.
- paths: sysfs paths to read (cat). If a path contains '*', collect with ls -l instead (e.g. class/net/*/device).
+ paths: sysfs paths to read (cat). If a path contains '*', collect with
+ ``cat`` first (e.g. class/drm/card*/device/vbios_version). If cat fails,
+ fall back to ls -l (e.g. class/net/*/device).
directory_paths: sysfs paths to list (ls -1); use for checks that match entry names by regex.
"""
paths: list[str] = Field(
default_factory=list,
- description="Sysfs paths to read (cat). Paths with '*' are collected with ls -l (e.g. class/net/*/device).",
+ description=(
+ "Sysfs paths to read (cat). Paths with '*' are cat'd via bash "
+ "(e.g. class/drm/card*/device/vbios_version); if cat fails, ls -l is used."
+ ),
)
directory_paths: list[str] = Field(
default_factory=list,
diff --git a/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py b/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py
index af51a021..612082a0 100644
--- a/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py
+++ b/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py
@@ -94,6 +94,17 @@ def _sysfs_full_path(suffix: str) -> str:
return f"/sys/{suffix}"
+def _collapse_glob_cat(stdout: str) -> str:
+ """Keep unique non-empty lines; store a single value when every match is identical."""
+ lines = [line.strip() for line in (stdout or "").splitlines() if line.strip()]
+ unique = list(dict.fromkeys(lines))
+ if not unique:
+ return ""
+ if len(unique) == 1:
+ return unique[0]
+ return "\n".join(unique)
+
+
class SysSettingsCollector(InBandDataCollector[SysSettingsDataModel, SysSettingsCollectorArgs]):
"""Collect sysfs settings from user-specified paths."""
@@ -109,6 +120,9 @@ def collect_data(
) -> tuple[TaskResult, Optional[SysSettingsDataModel]]:
"""Collect sysfs values for each path in args.paths.
+ Glob paths (containing '*') are read with ``bash -c 'cat /sys/...'`` first.
+ Identical cat lines are collapsed to one value. If cat fails, ``ls -l`` is used.
+
Args:
args: Collector args with paths to read; if None or empty paths, returns NOT_RAN.
@@ -153,6 +167,14 @@ def collect_data(
continue
full_path = _sysfs_full_path(suffix)
if "*" in suffix:
+ cat_cmd = self.CMD.format(suffix)
+ res = self._run_sut_cmd(f"bash -c {cat_cmd!r}", sudo=False)
+ if res.exit_code == 0 and res.stdout and res.stdout.strip():
+ value = _collapse_glob_cat(res.stdout)
+ if "\n" not in value:
+ value = _parse_bracketed_setting(value) or value
+ readings[full_path] = value
+ continue
cmd = self.CMD_LS_LONG.format(suffix)
res = self._run_sut_cmd(f"bash -c {cmd!r}", sudo=False)
if res.exit_code == 0:
@@ -160,7 +182,7 @@ def collect_data(
else:
self._log_event(
category=EventCategory.OS,
- description=f"Failed to run ls -l for sysfs path: {full_path}",
+ description=f"Failed to read sysfs path: {full_path}",
data={"exit_code": res.exit_code},
priority=EventPriority.WARNING,
console_log=True,
diff --git a/test/unit/plugin/test_sys_settings_analyzer.py b/test/unit/plugin/test_sys_settings_analyzer.py
index 318093c4..17e4b256 100644
--- a/test/unit/plugin/test_sys_settings_analyzer.py
+++ b/test/unit/plugin/test_sys_settings_analyzer.py
@@ -105,3 +105,23 @@ def test_analyzer_unknown_path(analyzer, sample_data):
assert result.status == ExecutionStatus.ERROR
assert "mismatch" in result.message.lower()
assert "unknown" in result.message
+
+
+def test_analyzer_gpu_vbios_match(analyzer):
+ path = "/sys/class/drm/card*/device/vbios_version"
+ data = SysSettingsDataModel(readings={path: "113-M355-01-1K1-040C"})
+ args = SysSettingsAnalyzerArgs(
+ checks=[SysfsCheck(path=path, expected=["113-M355-01-1K1-040C"], name="gpu_vbios")]
+ )
+ result = analyzer.analyze_data(data, args)
+ assert result.status == ExecutionStatus.OK
+
+
+def test_analyzer_gpu_vbios_mismatch(analyzer):
+ path = "/sys/class/drm/card*/device/vbios_version"
+ data = SysSettingsDataModel(readings={path: "wrong"})
+ args = SysSettingsAnalyzerArgs(
+ checks=[SysfsCheck(path=path, expected=["113-M355-01-1K1-040C"], name="gpu_vbios")]
+ )
+ result = analyzer.analyze_data(data, args)
+ assert result.status == ExecutionStatus.ERROR
diff --git a/test/unit/plugin/test_sys_settings_collector.py b/test/unit/plugin/test_sys_settings_collector.py
index b2da8b2e..555b79ee 100644
--- a/test/unit/plugin/test_sys_settings_collector.py
+++ b/test/unit/plugin/test_sys_settings_collector.py
@@ -165,7 +165,7 @@ def run_cmd(cmd, **kwargs):
assert "/etc" not in str(seen_commands)
-def test_collect_data_glob_path_uses_ls_long(linux_sys_settings_collector):
+def test_collect_data_glob_path_falls_back_to_ls_long(linux_sys_settings_collector):
seen_commands = []
def run_cmd(cmd, **kwargs):
@@ -174,7 +174,7 @@ def run_cmd(cmd, **kwargs):
return make_artifact(
0, "lrwxrwxrwx 1 root root 0 Jan 1 00:00 device -> ../../pci0000:00/0000:00:01.0"
)
- return make_artifact(0, "[always] madvise never")
+ return make_artifact(1, "")
linux_sys_settings_collector._run_sut_cmd = run_cmd
args = {"paths": ["class/net/*/device"]}
@@ -182,9 +182,8 @@ def run_cmd(cmd, **kwargs):
assert result.status == ExecutionStatus.OK
assert data is not None
- assert len(seen_commands) == 1
- assert "ls -l /sys/class/net/*/device" in seen_commands[0]
- assert "bash -c" in seen_commands[0]
+ assert any("cat /sys/class/net/*/device" in cmd for cmd in seen_commands)
+ assert any("ls -l /sys/class/net/*/device" in cmd for cmd in seen_commands)
assert data.readings.get("/sys/class/net/*/device") == (
"lrwxrwxrwx 1 root root 0 Jan 1 00:00 device -> ../../pci0000:00/0000:00:01.0"
)
@@ -195,6 +194,8 @@ def test_collect_data_mixed_paths_cat_and_glob(linux_sys_settings_collector):
def run_cmd(cmd, **kwargs):
if "ls -l" in cmd:
return make_artifact(0, "lrwx 1 root root 0 device -> ../../device")
+ if "net/*" in cmd:
+ return make_artifact(1, "")
if "enabled" in cmd:
return make_artifact(0, "[always] madvise never")
return make_artifact(0, "[madvise] always never defer")
@@ -213,3 +214,36 @@ def run_cmd(cmd, **kwargs):
data.readings.get("/sys/class/net/*/device") == "lrwx 1 root root 0 device -> ../../device"
)
assert "Sysfs collected 3 path(s)" in result.message
+
+
+VBIOS_PATH = "/sys/class/drm/card*/device/vbios_version"
+VBIOS_VALUE = "113-M355-01-1K1-040C"
+
+
+def test_collect_data_glob_cat_vbios_collapses_identical_lines(linux_sys_settings_collector):
+ seen_commands = []
+
+ def run_cmd(cmd, **kwargs):
+ seen_commands.append(cmd)
+ if "cat /sys/class/drm/card*/device/vbios_version" in cmd:
+ return make_artifact(0, f"{VBIOS_VALUE}\n{VBIOS_VALUE}\n{VBIOS_VALUE}\n")
+ return make_artifact(1, "")
+
+ linux_sys_settings_collector._run_sut_cmd = run_cmd
+ result, data = linux_sys_settings_collector.collect_data({"paths": [VBIOS_PATH]})
+
+ assert result.status == ExecutionStatus.OK
+ assert data.readings.get(VBIOS_PATH) == VBIOS_VALUE
+ assert any("cat /sys/class/drm/card*/device/vbios_version" in cmd for cmd in seen_commands)
+ assert not any("ls -l" in cmd for cmd in seen_commands)
+
+
+def test_collect_data_glob_cat_vbios_keeps_mixed_versions(linux_sys_settings_collector):
+ def run_cmd(cmd, **kwargs):
+ return make_artifact(0, f"{VBIOS_VALUE}\n113-OTHER-0000\n")
+
+ linux_sys_settings_collector._run_sut_cmd = run_cmd
+ result, data = linux_sys_settings_collector.collect_data({"paths": [VBIOS_PATH]})
+
+ assert result.status == ExecutionStatus.OK
+ assert data.readings.get(VBIOS_PATH) == f"{VBIOS_VALUE}\n113-OTHER-0000"