Skip to content

Commit aeacaf3

Browse files
committed
adding ls capability with regex pattern expected values
1 parent a843e21 commit aeacaf3

6 files changed

Lines changed: 166 additions & 24 deletions

File tree

nodescraper/interfaces/dataplugin.py

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -142,15 +142,15 @@ def collect(
142142
Union[SystemInteractionLevel, str]
143143
] = SystemInteractionLevel.INTERACTIVE,
144144
preserve_connection: bool = False,
145-
collection_args: Optional[Union[TCollectArg, dict]] = None,
145+
collection_args: Optional[TCollectArg] = None,
146146
) -> TaskResult:
147147
"""Run data collector task
148148
149149
Args:
150150
max_event_priority_level (Union[EventPriority, str], optional): priority limit for events. Defaults to EventPriority.CRITICAL.
151151
system_interaction_level (Union[SystemInteractionLevel, str], optional): system interaction level. Defaults to SystemInteractionLevel.INTERACTIVE.
152152
preserve_connection (bool, optional): whether we should close the connection after data collection. Defaults to False.
153-
collection_args (Optional[Union[TCollectArg , dict]], optional): args for data collection. Defaults to None.
153+
collection_args (Optional[TCollectArg], optional): args for data collection (validated model). Defaults to None.
154154
155155
Returns:
156156
TaskResult: task result for data collection
@@ -195,6 +195,13 @@ def collect(
195195
message="Connection not available, data collection skipped",
196196
)
197197
else:
198+
if (
199+
collection_args is not None
200+
and isinstance(collection_args, dict)
201+
and hasattr(self, "COLLECTOR_ARGS")
202+
and self.COLLECTOR_ARGS is not None
203+
):
204+
collection_args = self.COLLECTOR_ARGS.model_validate(collection_args)
198205

199206
collection_task = self.COLLECTOR(
200207
system_info=self.system_info,
@@ -264,6 +271,14 @@ def analyze(
264271
)
265272
return self.analysis_result
266273

274+
if (
275+
analysis_args is not None
276+
and isinstance(analysis_args, dict)
277+
and hasattr(self, "ANALYZER_ARGS")
278+
and self.ANALYZER_ARGS is not None
279+
):
280+
analysis_args = self.ANALYZER_ARGS.model_validate(analysis_args)
281+
267282
analyzer_task = self.ANALYZER(
268283
self.system_info,
269284
logger=self.logger,

nodescraper/plugins/inband/sys_settings/analyzer_args.py

Lines changed: 23 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -25,20 +25,22 @@
2525
###############################################################################
2626
from typing import Optional
2727

28-
from pydantic import BaseModel
28+
from pydantic import BaseModel, Field
2929

3030
from nodescraper.models import AnalyzerArgs
3131

3232

3333
class SysfsCheck(BaseModel):
34-
"""One sysfs check: path to read, acceptable values, and display name.
34+
"""One sysfs check: path to read, acceptable values or pattern, and display name.
3535
36-
If expected is an empty list, the check is treated as passing (no constraint).
36+
For file paths: use expected (list of acceptable values); if empty, check passes.
37+
For directory paths: use pattern (regex); at least one directory entry must match (e.g. ^hsn[0-9]+).
3738
"""
3839

3940
path: str
40-
expected: list[str]
41+
expected: list[str] = Field(default_factory=list)
4142
name: str
43+
pattern: Optional[str] = None
4244

4345

4446
class SysSettingsAnalyzerArgs(AnalyzerArgs):
@@ -51,16 +53,29 @@ class SysSettingsAnalyzerArgs(AnalyzerArgs):
5153
checks: Optional[list[SysfsCheck]] = None
5254

5355
def paths_to_collect(self) -> list[str]:
54-
"""Return the unique sysfs paths from checks, for use by the collector.
56+
"""Return unique sysfs file paths from checks (those without pattern), for use by the collector."""
57+
if not self.checks:
58+
return []
59+
seen = set()
60+
out = []
61+
for c in self.checks:
62+
if c.pattern:
63+
continue
64+
p = c.path.rstrip("/")
65+
if p not in seen:
66+
seen.add(p)
67+
out.append(c.path)
68+
return out
5569

56-
Returns:
57-
List of unique path strings from self.checks, preserving order of first occurrence.
58-
"""
70+
def paths_to_list(self) -> list[str]:
71+
"""Return unique sysfs directory paths from checks (those with pattern), for listing (ls)."""
5972
if not self.checks:
6073
return []
6174
seen = set()
6275
out = []
6376
for c in self.checks:
77+
if not c.pattern:
78+
continue
6479
p = c.path.rstrip("/")
6580
if p not in seen:
6681
seen.add(p)

nodescraper/plugins/inband/sys_settings/collector_args.py

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,11 @@
2727

2828

2929
class SysSettingsCollectorArgs(BaseModel):
30-
"""Collection args for SysSettingsCollector: list of sysfs paths to read."""
30+
"""Collection args for SysSettingsCollector.
31+
32+
paths: sysfs paths to read (cat).
33+
directory_paths: sysfs paths to list (ls -1); use for checks that match entry names by regex.
34+
"""
3135

3236
paths: list[str] = []
37+
directory_paths: list[str] = []

nodescraper/plugins/inband/sys_settings/sys_settings_analyzer.py

Lines changed: 39 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,8 @@
2323
# SOFTWARE.
2424
#
2525
###############################################################################
26-
from typing import Optional, cast
26+
import re
27+
from typing import List, Optional, Union, cast
2728

2829
from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus
2930
from nodescraper.interfaces import DataAnalyzer
@@ -64,14 +65,44 @@ def analyze_data(
6465
Returns:
6566
TaskResult with status OK if all checks pass, ERROR if any mismatch or missing path.
6667
"""
67-
mismatches = {}
68+
mismatches: dict[str, dict[str, Union[Optional[str], List[str]]]] = {}
6869

6970
if not args or not args.checks:
7071
self.result.status = ExecutionStatus.OK
7172
self.result.message = "No checks configured."
7273
return self.result
7374

7475
for check in args.checks:
76+
raw_reading = data.readings.get(check.path) or data.readings.get(check.path.rstrip("/"))
77+
78+
if check.pattern:
79+
# Directory-listing check: at least one line must match the regex (e.g. ^hsn[0-9]+)
80+
if raw_reading is None:
81+
mismatches[check.name] = {
82+
"path": check.path,
83+
"pattern": check.pattern,
84+
"actual": None,
85+
"reason": "path not collected by this plugin",
86+
}
87+
continue
88+
try:
89+
pat = re.compile(check.pattern)
90+
except re.error:
91+
mismatches[check.name] = {
92+
"path": check.path,
93+
"pattern": check.pattern,
94+
"reason": "invalid regex",
95+
}
96+
continue
97+
lines = [ln.strip() for ln in raw_reading.splitlines() if ln.strip()]
98+
if not any(pat.search(ln) for ln in lines):
99+
mismatches[check.name] = {
100+
"path": check.path,
101+
"pattern": check.pattern,
102+
"actual": lines,
103+
}
104+
continue
105+
75106
actual = _get_actual_for_path(data, check.path)
76107
if actual is None:
77108
mismatches[check.name] = {
@@ -98,12 +129,15 @@ def analyze_data(
98129
parts = []
99130
for name, info in mismatches.items():
100131
path = info.get("path", "")
101-
expected = info.get("expected")
102-
actual = cast(Optional[str], info.get("actual"))
103132
reason = info.get("reason")
133+
pattern = info.get("pattern")
104134
if reason:
105-
part = f"{name} ({path})"
135+
part = f"{name} ({path}): {reason}"
136+
elif pattern is not None:
137+
part = f"{name} ({path}): no entry matching pattern {pattern!r}"
106138
else:
139+
expected = info.get("expected")
140+
actual = cast(Optional[str], info.get("actual"))
107141
part = f"{name} ({path}): expected one of {expected}, actual {repr(actual)}"
108142
parts.append(part)
109143
self.result.message = "Sysfs mismatch: " + "; ".join(parts)

nodescraper/plugins/inband/sys_settings/sys_settings_collector.py

Lines changed: 53 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -38,7 +38,7 @@
3838

3939

4040
def _parse_bracketed_setting(content: str) -> Optional[str]:
41-
"""Extract the active setting from sysfs content (value in square brackets).
41+
"""Extract the active setting from sysfs content.
4242
4343
Args:
4444
content: Raw sysfs file content (e.g. "[always] madvise never").
@@ -56,19 +56,30 @@ def _paths_from_args(args: Optional[SysSettingsCollectorArgs]) -> list[str]:
5656
"""Extract list of sysfs paths from collection args.
5757
5858
Args:
59-
args: Collector args containing paths to read, or None. May be a dict.
59+
args: Collector args containing paths to read, or None.
6060
6161
Returns:
6262
List of sysfs paths; empty if args is None or args.paths is empty.
6363
"""
6464
if args is None:
6565
return []
66-
paths = args.get("paths") if isinstance(args, dict) else getattr(args, "paths", None)
67-
return list(paths) if paths else []
66+
return list(args.paths) if args.paths else []
67+
68+
69+
def _directory_paths_from_args(args: Optional[SysSettingsCollectorArgs]) -> list[str]:
70+
"""Extract list of sysfs directory paths to list from collection args."""
71+
if args is None:
72+
return []
73+
return list(args.directory_paths) if args.directory_paths else []
6874

6975

7076
def _path_under_sys(path: str) -> Optional[str]:
71-
"""Normalize path to the suffix under /sys/ for use in 'cat /sys/{}'."""
77+
"""Normalize path to the suffix under /sys/ for use in 'cat /sys/{}'.
78+
79+
Accepts paths like '/sys/kernel/...' or 'kernel/...'. Returns the relative
80+
part (e.g. 'kernel/mm/transparent_hugepage/enabled'). Returns None if path
81+
contains '..' (e.g. /sys/../etc/passwd, /sys/something/../../etc) or is not under /sys.
82+
"""
7283
if ".." in path:
7384
return None
7485
p = path.strip().lstrip("/")
@@ -80,17 +91,18 @@ def _path_under_sys(path: str) -> Optional[str]:
8091

8192

8293
def _sysfs_full_path(suffix: str) -> str:
83-
"""Return full path /sys/{suffix}."""
94+
"""Return full path /sys/{suffix} for use as readings key."""
8495
return f"/sys/{suffix}"
8596

8697

8798
class SysSettingsCollector(InBandDataCollector[SysSettingsDataModel, SysSettingsCollectorArgs]):
88-
"""Collect sysfs settings from user-specified paths (paths come from config/args)."""
99+
"""Collect sysfs settings from user-specified paths."""
89100

90101
DATA_MODEL = SysSettingsDataModel
91102
SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.LINUX}
92103

93104
CMD = "cat /sys/{}"
105+
CMD_LS = "ls -1 /sys/{}"
94106

95107
def collect_data(
96108
self, args: Optional[SysSettingsCollectorArgs] = None
@@ -113,7 +125,16 @@ def collect_data(
113125
return self.result, None
114126

115127
paths = _paths_from_args(args)
116-
if not paths:
128+
directory_paths = _directory_paths_from_args(args)
129+
if directory_paths:
130+
self._log_event(
131+
category=EventCategory.OS,
132+
description=f"Sysfs directory_paths to list: {directory_paths}",
133+
data={"directory_paths": directory_paths},
134+
priority=EventPriority.INFO,
135+
console_log=True,
136+
)
137+
if not paths and not directory_paths:
117138
self.result.message = "No paths configured for sysfs collection"
118139
self.result.status = ExecutionStatus.NOT_RAN
119140
return self.result, None
@@ -144,6 +165,30 @@ def collect_data(
144165
console_log=True,
145166
)
146167

168+
for path in directory_paths:
169+
suffix = _path_under_sys(path)
170+
if suffix is None:
171+
self._log_event(
172+
category=EventCategory.OS,
173+
description=f"Skipping directory path not under /sys or invalid: {path!r}",
174+
data={"path": path},
175+
priority=EventPriority.WARNING,
176+
console_log=True,
177+
)
178+
continue
179+
full_path = _sysfs_full_path(suffix)
180+
res = self._run_sut_cmd(self.CMD_LS.format(suffix), sudo=False)
181+
if res.exit_code == 0:
182+
readings[full_path] = res.stdout.strip() if res.stdout else ""
183+
else:
184+
self._log_event(
185+
category=EventCategory.OS,
186+
description=f"Failed to list sysfs path: {full_path}",
187+
data={"exit_code": res.exit_code},
188+
priority=EventPriority.WARNING,
189+
console_log=True,
190+
)
191+
147192
if not readings:
148193
self.result.message = "Sysfs settings not read"
149194
self.result.status = ExecutionStatus.ERROR

test/functional/test_sys_settings_plugin.py

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,11 @@
3030
import pytest
3131

3232

33+
def _project_root() -> Path:
34+
"""Return project root (directory containing plugin_config.json)."""
35+
return Path(__file__).resolve().parent.parent.parent
36+
37+
3338
@pytest.fixture
3439
def fixtures_dir():
3540
"""Return path to fixtures directory."""
@@ -42,6 +47,12 @@ def sys_settings_config_file(fixtures_dir):
4247
return fixtures_dir / "sys_settings_plugin_config.json"
4348

4449

50+
@pytest.fixture
51+
def plugin_config_json():
52+
"""Return path to project root plugin_config.json (full config: paths + directory_paths + checks)."""
53+
return _project_root() / "plugin_config.json"
54+
55+
4556
def test_sys_settings_plugin_with_config_file(run_cli_command, sys_settings_config_file, tmp_path):
4657
"""Test SysSettingsPlugin using config file with collection_args and analysis_args."""
4758
assert sys_settings_config_file.exists(), f"Config file not found: {sys_settings_config_file}"
@@ -85,3 +96,20 @@ def test_sys_settings_plugin_output_contains_plugin_result(
8596
output = result.stdout + result.stderr
8697
# Table or status line should mention the plugin
8798
assert "SysSettingsPlugin" in output
99+
100+
101+
def test_sys_settings_plugin_with_plugin_config_json(run_cli_command, plugin_config_json, tmp_path):
102+
"""Functional test: run node-scraper with project plugin_config.json (paths + directory_paths + checks)."""
103+
assert plugin_config_json.exists(), f"Config not found: {plugin_config_json}"
104+
105+
log_path = str(tmp_path / "logs_plugin_config")
106+
result = run_cli_command(
107+
["--log-path", log_path, "--plugin-configs", str(plugin_config_json)], check=False
108+
)
109+
110+
assert result.returncode in [0, 1, 2]
111+
output = result.stdout + result.stderr
112+
assert len(output) > 0
113+
assert "SysSettingsPlugin" in output
114+
# Config exercises paths + directory_paths (/sys/class/net) + pattern check; collector/analyzer run
115+
assert "Sysfs" in output or "sys" in output.lower()

0 commit comments

Comments
 (0)