diff --git a/nodescraper/interfaces/dataplugin.py b/nodescraper/interfaces/dataplugin.py index da7a132..f4aa622 100644 --- a/nodescraper/interfaces/dataplugin.py +++ b/nodescraper/interfaces/dataplugin.py @@ -142,7 +142,7 @@ def collect( Union[SystemInteractionLevel, str] ] = SystemInteractionLevel.INTERACTIVE, preserve_connection: bool = False, - collection_args: Optional[Union[TCollectArg, dict]] = None, + collection_args: Optional[TCollectArg] = None, ) -> TaskResult: """Run data collector task @@ -150,7 +150,7 @@ def collect( max_event_priority_level (Union[EventPriority, str], optional): priority limit for events. Defaults to EventPriority.CRITICAL. system_interaction_level (Union[SystemInteractionLevel, str], optional): system interaction level. Defaults to SystemInteractionLevel.INTERACTIVE. preserve_connection (bool, optional): whether we should close the connection after data collection. Defaults to False. - collection_args (Optional[Union[TCollectArg , dict]], optional): args for data collection. Defaults to None. + collection_args (Optional[TCollectArg], optional): args for data collection (validated model). Defaults to None. Returns: TaskResult: task result for data collection @@ -195,6 +195,13 @@ def collect( message="Connection not available, data collection skipped", ) else: + if ( + collection_args is not None + and isinstance(collection_args, dict) + and hasattr(self, "COLLECTOR_ARGS") + and self.COLLECTOR_ARGS is not None + ): + collection_args = self.COLLECTOR_ARGS.model_validate(collection_args) collection_task = self.COLLECTOR( system_info=self.system_info, @@ -264,6 +271,14 @@ def analyze( ) return self.analysis_result + if ( + analysis_args is not None + and isinstance(analysis_args, dict) + and hasattr(self, "ANALYZER_ARGS") + and self.ANALYZER_ARGS is not None + ): + analysis_args = self.ANALYZER_ARGS.model_validate(analysis_args) + analyzer_task = self.ANALYZER( self.system_info, logger=self.logger, diff --git a/nodescraper/plugins/inband/sys_settings/__init__.py b/nodescraper/plugins/inband/sys_settings/__init__.py new file mode 100644 index 0000000..79a10bd --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/__init__.py @@ -0,0 +1,29 @@ +############################################################################### +# +# 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 .analyzer_args import SysfsCheck, SysSettingsAnalyzerArgs +from .sys_settings_plugin import SysSettingsPlugin + +__all__ = ["SysSettingsPlugin", "SysSettingsAnalyzerArgs", "SysfsCheck"] diff --git a/nodescraper/plugins/inband/sys_settings/analyzer_args.py b/nodescraper/plugins/inband/sys_settings/analyzer_args.py new file mode 100644 index 0000000..732398f --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/analyzer_args.py @@ -0,0 +1,83 @@ +############################################################################### +# +# 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 + +from pydantic import BaseModel, Field + +from nodescraper.models import AnalyzerArgs + + +class SysfsCheck(BaseModel): + """One sysfs check: path to read, acceptable values or pattern, and display name. + + For file paths: use expected (list of acceptable values); if empty, check passes. + For directory paths: use pattern (regex); at least one directory entry must match (e.g. ^hsn[0-9]+). + """ + + path: str + expected: list[str] = Field(default_factory=list) + name: str + pattern: Optional[str] = None + + +class SysSettingsAnalyzerArgs(AnalyzerArgs): + """Sysfs settings for analysis via a list of checks (path, expected values, name). + + The path in each check is the sysfs path to read; the collector uses these paths + when collection_args is derived from analysis_args (e.g. by the plugin). + """ + + checks: Optional[list[SysfsCheck]] = None + + def paths_to_collect(self) -> list[str]: + """Return unique sysfs file paths from checks (those without pattern), for use by the collector.""" + if not self.checks: + return [] + seen = set() + out = [] + for c in self.checks: + if c.pattern: + continue + p = c.path.rstrip("/") + if p not in seen: + seen.add(p) + out.append(c.path) + return out + + def paths_to_list(self) -> list[str]: + """Return unique sysfs directory paths from checks (those with pattern), for listing (ls).""" + if not self.checks: + return [] + seen = set() + out = [] + for c in self.checks: + if not c.pattern: + continue + p = c.path.rstrip("/") + if p not in seen: + seen.add(p) + out.append(c.path) + return out diff --git a/nodescraper/plugins/inband/sys_settings/collector_args.py b/nodescraper/plugins/inband/sys_settings/collector_args.py new file mode 100644 index 0000000..b1d2860 --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/collector_args.py @@ -0,0 +1,37 @@ +############################################################################### +# +# 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 pydantic import BaseModel + + +class SysSettingsCollectorArgs(BaseModel): + """Collection args for SysSettingsCollector. + + paths: sysfs paths to read (cat). + directory_paths: sysfs paths to list (ls -1); use for checks that match entry names by regex. + """ + + paths: list[str] = [] + directory_paths: list[str] = [] diff --git a/nodescraper/plugins/inband/sys_settings/sys_settings_analyzer.py b/nodescraper/plugins/inband/sys_settings/sys_settings_analyzer.py new file mode 100644 index 0000000..276b73c --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_analyzer.py @@ -0,0 +1,161 @@ +############################################################################### +# +# 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 re +from typing import List, Optional, Union, cast + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult + +from .analyzer_args import SysSettingsAnalyzerArgs +from .sys_settings_data import SysSettingsDataModel + + +def _get_actual_for_path(data: SysSettingsDataModel, path: str) -> Optional[str]: + """Return the actual value from the data model for the given sysfs path. + + Args: + data: Collected sysfs readings (path -> value). + path: Sysfs path (with or without trailing slash). + + Returns: + Normalized value for that path, or None if not present. + """ + value = data.readings.get(path) or data.readings.get(path.rstrip("/")) + return (value or "").strip().lower() if value is not None else None + + +class SysSettingsAnalyzer(DataAnalyzer[SysSettingsDataModel, SysSettingsAnalyzerArgs]): + """Check sysfs settings against expected values from the checks list.""" + + DATA_MODEL = SysSettingsDataModel + + def analyze_data( + self, data: SysSettingsDataModel, args: Optional[SysSettingsAnalyzerArgs] = None + ) -> TaskResult: + """Compare sysfs data to expected settings from args.checks. + + Args: + data: Collected sysfs readings to check. + args: Analyzer args with checks (path, expected, name). If None or no checks, returns OK. + + Returns: + TaskResult with status OK if all checks pass, ERROR if any mismatch or missing path. + """ + mismatches: dict[str, dict[str, Union[Optional[str], List[str]]]] = {} + + if not args or not args.checks: + self.result.status = ExecutionStatus.OK + self.result.message = "No checks configured." + return self.result + + for check in args.checks: + raw_reading = data.readings.get(check.path) or data.readings.get(check.path.rstrip("/")) + + if check.pattern: + # Directory-listing check: at least one line must match the regex (e.g. ^hsn[0-9]+) + if raw_reading is None: + mismatches[check.name] = { + "path": check.path, + "pattern": check.pattern, + "actual": None, + "reason": "path not collected by this plugin", + } + continue + try: + pat = re.compile(check.pattern) + except re.error: + mismatches[check.name] = { + "path": check.path, + "pattern": check.pattern, + "reason": "invalid regex", + } + continue + lines = [ln.strip() for ln in raw_reading.splitlines() if ln.strip()] + if not any(pat.search(ln) for ln in lines): + mismatches[check.name] = { + "path": check.path, + "pattern": check.pattern, + "actual": lines, + } + continue + + actual = _get_actual_for_path(data, check.path) + if actual is None: + mismatches[check.name] = { + "path": check.path, + "expected": check.expected, + "actual": None, + "reason": "path not collected by this plugin", + } + continue + + if not check.expected: + continue + expected_normalized = [e.strip().lower() for e in check.expected] + if actual not in expected_normalized: + raw = data.readings.get(check.path) or data.readings.get(check.path.rstrip("/")) + mismatches[check.name] = { + "path": check.path, + "expected": check.expected, + "actual": raw, + } + + if mismatches: + self.result.status = ExecutionStatus.ERROR + parts = [] + for name, info in mismatches.items(): + path = info.get("path", "") + reason = info.get("reason") + pattern = info.get("pattern") + if reason: + part = f"{name} ({path}): {reason}" + elif pattern is not None: + part = f"{name} ({path}): no entry matching pattern {pattern!r}" + else: + expected = info.get("expected") + actual = cast(Optional[str], info.get("actual")) + part = f"{name} ({path}): expected one of {expected}, actual {repr(actual)}" + parts.append(part) + self.result.message = "Sysfs mismatch: " + "; ".join(parts) + self._log_event( + category=EventCategory.OS, + description="Sysfs mismatch detected", + data=mismatches, + priority=EventPriority.ERROR, + console_log=True, + ) + else: + self._log_event( + category=EventCategory.OS, + description="Sysfs settings match expected", + priority=EventPriority.INFO, + console_log=True, + ) + self.result.status = ExecutionStatus.OK + self.result.message = "Sysfs settings as expected." + + return self.result diff --git a/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py b/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py new file mode 100644 index 0000000..753abbd --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py @@ -0,0 +1,206 @@ +############################################################################### +# +# 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 re +from typing import Optional + +from nodescraper.base import InBandDataCollector +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily +from nodescraper.models import TaskResult + +from .collector_args import SysSettingsCollectorArgs +from .sys_settings_data import SysSettingsDataModel + +# Sysfs format: "[always] madvise never" -> extract bracketed value +BRACKETED_RE = re.compile(r"\[(\w+)\]") + + +def _parse_bracketed_setting(content: str) -> Optional[str]: + """Extract the active setting from sysfs content. + + Args: + content: Raw sysfs file content (e.g. "[always] madvise never"). + + Returns: + The bracketed value if present, else None. + """ + if not content: + return None + match = BRACKETED_RE.search(content.strip()) + return match.group(1).strip() if match else None + + +def _paths_from_args(args: Optional[SysSettingsCollectorArgs]) -> list[str]: + """Extract list of sysfs paths from collection args. + + Args: + args: Collector args containing paths to read, or None. + + Returns: + List of sysfs paths; empty if args is None or args.paths is empty. + """ + if args is None: + return [] + return list(args.paths) if args.paths else [] + + +def _directory_paths_from_args(args: Optional[SysSettingsCollectorArgs]) -> list[str]: + """Extract list of sysfs directory paths to list from collection args.""" + if args is None: + return [] + return list(args.directory_paths) if args.directory_paths else [] + + +def _path_under_sys(path: str) -> Optional[str]: + """Normalize path to the suffix under /sys/ for use in 'cat /sys/{}'. + + Accepts paths like '/sys/kernel/...' or 'kernel/...'. Returns the relative + part (e.g. 'kernel/mm/transparent_hugepage/enabled'). Returns None if path + contains '..' (e.g. /sys/../etc/passwd, /sys/something/../../etc) or is not under /sys. + """ + if ".." in path: + return None + p = path.strip().lstrip("/") + if p.startswith("sys/"): + p = p[4:] + if p.startswith("/"): + return None + return p if p else None + + +def _sysfs_full_path(suffix: str) -> str: + """Return full path /sys/{suffix} for use as readings key.""" + return f"/sys/{suffix}" + + +class SysSettingsCollector(InBandDataCollector[SysSettingsDataModel, SysSettingsCollectorArgs]): + """Collect sysfs settings from user-specified paths.""" + + DATA_MODEL = SysSettingsDataModel + SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.LINUX} + + CMD = "cat /sys/{}" + CMD_LS = "ls -1 /sys/{}" + + def collect_data( + self, args: Optional[SysSettingsCollectorArgs] = None + ) -> tuple[TaskResult, Optional[SysSettingsDataModel]]: + """Collect sysfs values for each path in args.paths. + + Args: + args: Collector args with paths to read; if None or empty paths, returns NOT_RAN. + + Returns: + Tuple of (TaskResult, SysSettingsDataModel or None). Data is None on NOT_RAN or ERROR. + """ + if self.system_info.os_family != OSFamily.LINUX: + self._log_event( + category=EventCategory.OS, + description="Sysfs collection is only supported on Linux.", + priority=EventPriority.WARNING, + console_log=True, + ) + return self.result, None + + paths = _paths_from_args(args) + directory_paths = _directory_paths_from_args(args) + if directory_paths: + self._log_event( + category=EventCategory.OS, + description=f"Sysfs directory_paths to list: {directory_paths}", + data={"directory_paths": directory_paths}, + priority=EventPriority.INFO, + console_log=True, + ) + if not paths and not directory_paths: + self.result.message = "No paths configured for sysfs collection" + self.result.status = ExecutionStatus.NOT_RAN + return self.result, None + + readings: dict[str, str] = {} + for path in paths: + suffix = _path_under_sys(path) + if suffix is None: + self._log_event( + category=EventCategory.OS, + description=f"Skipping path not under /sys or invalid: {path!r}", + data={"path": path}, + priority=EventPriority.WARNING, + console_log=True, + ) + continue + full_path = _sysfs_full_path(suffix) + res = self._run_sut_cmd(self.CMD.format(suffix), sudo=False) + if res.exit_code == 0 and res.stdout: + value = _parse_bracketed_setting(res.stdout) or res.stdout.strip() + readings[full_path] = value + else: + self._log_event( + category=EventCategory.OS, + description=f"Failed to read sysfs path: {full_path}", + data={"exit_code": res.exit_code}, + priority=EventPriority.WARNING, + console_log=True, + ) + + for path in directory_paths: + suffix = _path_under_sys(path) + if suffix is None: + self._log_event( + category=EventCategory.OS, + description=f"Skipping directory path not under /sys or invalid: {path!r}", + data={"path": path}, + priority=EventPriority.WARNING, + console_log=True, + ) + continue + full_path = _sysfs_full_path(suffix) + res = self._run_sut_cmd(self.CMD_LS.format(suffix), sudo=False) + if res.exit_code == 0: + readings[full_path] = res.stdout.strip() if res.stdout else "" + else: + self._log_event( + category=EventCategory.OS, + description=f"Failed to list sysfs path: {full_path}", + data={"exit_code": res.exit_code}, + priority=EventPriority.WARNING, + console_log=True, + ) + + if not readings: + self.result.message = "Sysfs settings not read" + self.result.status = ExecutionStatus.ERROR + return self.result, None + + sys_settings_data = SysSettingsDataModel(readings=readings) + self._log_event( + category=EventCategory.OS, + description="Sysfs settings collected", + data=sys_settings_data.model_dump(), + priority=EventPriority.INFO, + ) + self.result.message = f"Sysfs collected {len(readings)} path(s)" + self.result.status = ExecutionStatus.OK + return self.result, sys_settings_data diff --git a/nodescraper/plugins/inband/sys_settings/sys_settings_data.py b/nodescraper/plugins/inband/sys_settings/sys_settings_data.py new file mode 100644 index 0000000..acec4b4 --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_data.py @@ -0,0 +1,38 @@ +############################################################################### +# +# 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 pydantic import Field + +from nodescraper.models import DataModel + + +class SysSettingsDataModel(DataModel): + """Data model for sysfs settings: path -> parsed value. + + Values are parsed from user-specified sysfs paths (bracketed value extracted + when present, e.g. '[always] madvise never' -> 'always'). + """ + + readings: dict[str, str] = Field(default_factory=dict) # sysfs path (as given) -> parsed value diff --git a/nodescraper/plugins/inband/sys_settings/sys_settings_plugin.py b/nodescraper/plugins/inband/sys_settings/sys_settings_plugin.py new file mode 100644 index 0000000..158ac6f --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_plugin.py @@ -0,0 +1,44 @@ +############################################################################### +# +# 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 nodescraper.base import InBandDataPlugin + +from .analyzer_args import SysSettingsAnalyzerArgs +from .collector_args import SysSettingsCollectorArgs +from .sys_settings_analyzer import SysSettingsAnalyzer +from .sys_settings_collector import SysSettingsCollector +from .sys_settings_data import SysSettingsDataModel + + +class SysSettingsPlugin( + InBandDataPlugin[SysSettingsDataModel, SysSettingsCollectorArgs, SysSettingsAnalyzerArgs] +): + """Plugin to collect and analyze sysfs settings from user-specified paths.""" + + DATA_MODEL = SysSettingsDataModel + COLLECTOR = SysSettingsCollector + ANALYZER = SysSettingsAnalyzer + COLLECTOR_ARGS = SysSettingsCollectorArgs + ANALYZER_ARGS = SysSettingsAnalyzerArgs diff --git a/test/functional/fixtures/plugin_config.json b/test/functional/fixtures/plugin_config.json new file mode 100644 index 0000000..000b7b2 --- /dev/null +++ b/test/functional/fixtures/plugin_config.json @@ -0,0 +1 @@ +{"name":"plugin_config","desc":"Full config: all collector_args + analyzer_args, values that pass on Ubuntu","global_args":{},"plugins":{"PackagePlugin":{"collection_args":{},"analysis_args":{"exp_package_ver":{"libc|python|apt":null},"regex_match":true,"rocm_regex":null,"enable_rocm_regex":false}},"SysSettingsPlugin":{"collection_args":{"paths":["/sys/kernel/mm/transparent_hugepage/enabled","/sys/kernel/mm/transparent_hugepage/defrag"],"directory_paths":["/sys/class/net"]},"analysis_args":{"checks":[{"path":"/sys/kernel/mm/transparent_hugepage/enabled","expected":["always","madvise","never"],"name":"thp_enabled","pattern":null},{"path":"/sys/kernel/mm/transparent_hugepage/defrag","expected":["always","madvise","never","defer"],"name":"thp_defrag","pattern":null},{"path":"/sys/class/net","expected":[],"name":"net_interfaces","pattern":"^(lo|eth|enp|wl|br-|docker|ens)"}]}}},"result_collators":{}} diff --git a/test/functional/fixtures/sys_settings_plugin_config.json b/test/functional/fixtures/sys_settings_plugin_config.json new file mode 100644 index 0000000..2a2013c --- /dev/null +++ b/test/functional/fixtures/sys_settings_plugin_config.json @@ -0,0 +1,36 @@ +{ + "name": "SysSettingsPlugin config", + "desc": "Config for testing SysSettingsPlugin (sysfs settings)", + "global_args": {}, + "plugins": { + "SysSettingsPlugin": { + "collection_args": { + "paths": [ + "/sys/kernel/mm/transparent_hugepage/enabled", + "/sys/kernel/mm/transparent_hugepage/defrag", + "/sys/kernel/mm/transparent_hugepage/shmem_enabled" + ] + }, + "analysis_args": { + "checks": [ + { + "path": "/sys/kernel/mm/transparent_hugepage/enabled", + "expected": ["always", "madvise", "never"], + "name": "thp_enabled" + }, + { + "path": "/sys/kernel/mm/transparent_hugepage/defrag", + "expected": ["always", "madvise", "never", "defer"], + "name": "thp_defrag" + }, + { + "path": "/sys/kernel/mm/transparent_hugepage/shmem_enabled", + "expected": [], + "name": "thp_shmem" + } + ] + } + } + }, + "result_collators": {} +} diff --git a/test/functional/test_plugin_configs.py b/test/functional/test_plugin_configs.py index 7f4ea6c..ec83223 100644 --- a/test/functional/test_plugin_configs.py +++ b/test/functional/test_plugin_configs.py @@ -57,6 +57,7 @@ def plugin_config_files(fixtures_dir): "ProcessPlugin": fixtures_dir / "process_plugin_config.json", "RocmPlugin": fixtures_dir / "rocm_plugin_config.json", "StoragePlugin": fixtures_dir / "storage_plugin_config.json", + "SysSettingsPlugin": fixtures_dir / "sys_settings_plugin_config.json", "SysctlPlugin": fixtures_dir / "sysctl_plugin_config.json", "SyslogPlugin": fixtures_dir / "syslog_plugin_config.json", "UptimePlugin": fixtures_dir / "uptime_plugin_config.json", @@ -119,6 +120,7 @@ def test_plugin_config_with_builtin_config(run_cli_command, tmp_path): "ProcessPlugin", "RocmPlugin", "StoragePlugin", + "SysSettingsPlugin", "SysctlPlugin", "SyslogPlugin", "UptimePlugin", diff --git a/test/functional/test_sys_settings_plugin.py b/test/functional/test_sys_settings_plugin.py new file mode 100644 index 0000000..85b1bc0 --- /dev/null +++ b/test/functional/test_sys_settings_plugin.py @@ -0,0 +1,110 @@ +############################################################################### +# +# 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. +# +############################################################################### +"""Functional tests for SysSettingsPlugin with --plugin-configs.""" + +from pathlib import Path + +import pytest + + +@pytest.fixture +def fixtures_dir(): + """Return path to fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def sys_settings_config_file(fixtures_dir): + """Return path to SysSettingsPlugin config file.""" + return fixtures_dir / "sys_settings_plugin_config.json" + + +@pytest.fixture +def plugin_config_json(fixtures_dir): + """Return path to fixture plugin_config.json.""" + return fixtures_dir / "plugin_config.json" + + +def test_sys_settings_plugin_with_config_file(run_cli_command, sys_settings_config_file, tmp_path): + """Test SysSettingsPlugin using config file with collection_args and analysis_args.""" + assert sys_settings_config_file.exists(), f"Config file not found: {sys_settings_config_file}" + + log_path = str(tmp_path / "logs_sys_settings") + result = run_cli_command( + ["--log-path", log_path, "--plugin-configs", str(sys_settings_config_file)], check=False + ) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0 + assert "SysSettingsPlugin" in output or "syssettings" in output.lower() + + +def test_sys_settings_plugin_with_run_plugins_subcommand(run_cli_command, tmp_path): + """Test SysSettingsPlugin via run-plugins subcommand (no config; collector gets no paths).""" + log_path = str(tmp_path / "logs_sys_settings_subcommand") + result = run_cli_command( + ["--log-path", log_path, "run-plugins", "SysSettingsPlugin"], check=False + ) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0 + # Without config, plugin runs with no paths -> NOT_RAN or similar + assert "SysSettings" in output or "sys" in output.lower() + + +def test_sys_settings_plugin_output_contains_plugin_result( + run_cli_command, sys_settings_config_file, tmp_path +): + """On Linux, plugin runs and table shows SysSettingsPlugin with a status.""" + assert sys_settings_config_file.exists() + + log_path = str(tmp_path / "logs_sys_settings_result") + result = run_cli_command( + ["--log-path", log_path, "--plugin-configs", str(sys_settings_config_file)], check=False + ) + + output = result.stdout + result.stderr + # Table or status line should mention the plugin + assert "SysSettingsPlugin" in output + + +def test_sys_settings_plugin_with_plugin_config_json(run_cli_command, plugin_config_json, tmp_path): + """Functional test: run node-scraper with project plugin_config.json (paths + directory_paths + checks).""" + assert plugin_config_json.exists(), f"Config not found: {plugin_config_json}" + + log_path = str(tmp_path / "logs_plugin_config") + result = run_cli_command( + ["--log-path", log_path, "--plugin-configs", str(plugin_config_json)], check=False + ) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0 + assert "SysSettingsPlugin" in output + # Config exercises paths + directory_paths (/sys/class/net) + pattern check; collector/analyzer run + assert "Sysfs" in output or "sys" in output.lower() diff --git a/test/unit/plugin/test_sys_settings_analyzer.py b/test/unit/plugin/test_sys_settings_analyzer.py new file mode 100644 index 0000000..318093c --- /dev/null +++ b/test/unit/plugin/test_sys_settings_analyzer.py @@ -0,0 +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 pytest + +from nodescraper.enums import ExecutionStatus +from nodescraper.plugins.inband.sys_settings.analyzer_args import ( + SysfsCheck, + SysSettingsAnalyzerArgs, +) +from nodescraper.plugins.inband.sys_settings.sys_settings_analyzer import ( + SysSettingsAnalyzer, +) +from nodescraper.plugins.inband.sys_settings.sys_settings_data import ( + SysSettingsDataModel, +) + +SYSFS_BASE = "/sys/kernel/mm/transparent_hugepage" + + +@pytest.fixture +def analyzer(system_info): + return SysSettingsAnalyzer(system_info=system_info) + + +@pytest.fixture +def sample_data(): + return SysSettingsDataModel( + readings={ + f"{SYSFS_BASE}/enabled": "always", + f"{SYSFS_BASE}/defrag": "madvise", + } + ) + + +def test_analyzer_no_checks_ok(analyzer, sample_data): + """No checks configured -> OK.""" + result = analyzer.analyze_data(sample_data) + assert result.status == ExecutionStatus.OK + assert "No checks" in result.message + + +def test_analyzer_checks_match(analyzer, sample_data): + """Checks match collected values -> OK.""" + args = SysSettingsAnalyzerArgs( + checks=[ + SysfsCheck( + path=f"{SYSFS_BASE}/enabled", expected=["always", "[always]"], name="enabled" + ), + SysfsCheck( + path=f"{SYSFS_BASE}/defrag", expected=["madvise", "[madvise]"], name="defrag" + ), + ] + ) + result = analyzer.analyze_data(sample_data, args) + assert result.status == ExecutionStatus.OK + assert "as expected" in result.message + + +def test_analyzer_check_mismatch(analyzer, sample_data): + """One check expects wrong value -> ERROR; message enumerates path and expected/actual.""" + args = SysSettingsAnalyzerArgs( + checks=[ + SysfsCheck(path=f"{SYSFS_BASE}/enabled", expected=["never"], name="enabled"), + ] + ) + result = analyzer.analyze_data(sample_data, args) + assert result.status == ExecutionStatus.ERROR + assert "mismatch" in result.message.lower() + assert "enabled" in result.message + assert "never" in result.message + assert "always" in result.message + + +def test_analyzer_unknown_path(analyzer, sample_data): + """Check for path not collected by plugin -> ERROR.""" + args = SysSettingsAnalyzerArgs( + checks=[ + SysfsCheck(path="/sys/unknown/path", expected=["x"], name="unknown"), + ] + ) + result = analyzer.analyze_data(sample_data, args) + assert result.status == ExecutionStatus.ERROR + assert "mismatch" in result.message.lower() + assert "unknown" in result.message diff --git a/test/unit/plugin/test_sys_settings_collector.py b/test/unit/plugin/test_sys_settings_collector.py new file mode 100644 index 0000000..88097aa --- /dev/null +++ b/test/unit/plugin/test_sys_settings_collector.py @@ -0,0 +1,165 @@ +############################################################################### +# +# 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 types import SimpleNamespace + +import pytest + +from nodescraper.enums import ExecutionStatus, OSFamily +from nodescraper.plugins.inband.sys_settings.sys_settings_collector import ( + SysSettingsCollector, +) +from nodescraper.plugins.inband.sys_settings.sys_settings_data import ( + SysSettingsDataModel, +) + +SYSFS_BASE = "/sys/kernel/mm/transparent_hugepage" +PATH_ENABLED = f"{SYSFS_BASE}/enabled" +PATH_DEFRAG = f"{SYSFS_BASE}/defrag" + + +@pytest.fixture +def linux_sys_settings_collector(system_info, conn_mock): + system_info.os_family = OSFamily.LINUX + return SysSettingsCollector(system_info=system_info, connection=conn_mock) + + +@pytest.fixture +def collection_args(): + return {"paths": [PATH_ENABLED, PATH_DEFRAG]} + + +def make_artifact(exit_code, stdout): + return SimpleNamespace(command="", exit_code=exit_code, stdout=stdout, stderr="") + + +def test_collect_data_success(linux_sys_settings_collector, collection_args): + """Both enabled and defrag read successfully.""" + + def run_cmd(cmd, **kwargs): + if "enabled" in cmd: + return make_artifact(0, "[always] madvise never") + return make_artifact(0, "[madvise] always never defer") + + linux_sys_settings_collector._run_sut_cmd = run_cmd + result, data = linux_sys_settings_collector.collect_data(collection_args) + + assert result.status == ExecutionStatus.OK + assert data is not None + assert isinstance(data, SysSettingsDataModel) + assert data.readings.get(PATH_ENABLED) == "always" + assert data.readings.get(PATH_DEFRAG) == "madvise" + assert "Sysfs collected 2 path(s)" in result.message + + +def test_collect_data_no_paths_not_ran(linux_sys_settings_collector): + """No paths in args -> NOT_RAN.""" + result, data = linux_sys_settings_collector.collect_data({}) + assert result.status == ExecutionStatus.NOT_RAN + assert "No paths configured" in result.message + assert data is None + + +def test_collect_data_enabled_fails(linux_sys_settings_collector, collection_args): + """Enabled read fails; defrag succeeds -> still get partial data.""" + + def run_cmd(cmd, **kwargs): + if "enabled" in cmd: + return make_artifact(1, "") + return make_artifact(0, "[never] always madvise") + + linux_sys_settings_collector._run_sut_cmd = run_cmd + result, data = linux_sys_settings_collector.collect_data(collection_args) + + assert result.status == ExecutionStatus.OK + assert data is not None + assert PATH_ENABLED not in data.readings + assert data.readings.get(PATH_DEFRAG) == "never" + + +def test_collect_data_both_fail(linux_sys_settings_collector, collection_args): + """Both reads fail -> error.""" + + def run_cmd(cmd, **kwargs): + return make_artifact(1, "") + + linux_sys_settings_collector._run_sut_cmd = run_cmd + result, data = linux_sys_settings_collector.collect_data(collection_args) + + assert result.status == ExecutionStatus.ERROR + assert data is None + assert "Sysfs settings not read" in result.message + + +def test_collector_raises_on_non_linux(system_info, conn_mock): + """SysSettingsCollector does not support non-Linux; constructor raises.""" + from nodescraper.interfaces.task import SystemCompatibilityError + + system_info.os_family = OSFamily.WINDOWS + with pytest.raises(SystemCompatibilityError, match="not supported"): + SysSettingsCollector(system_info=system_info, connection=conn_mock) + + +def test_collect_data_uses_sys_only_command(linux_sys_settings_collector): + seen_commands = [] + + def run_cmd(cmd, **kwargs): + seen_commands.append(cmd) + return make_artifact(0, "value") + + linux_sys_settings_collector._run_sut_cmd = run_cmd + args = {"paths": [PATH_ENABLED]} + result, data = linux_sys_settings_collector.collect_data(args) + + assert result.status == ExecutionStatus.OK + assert len(seen_commands) == 1 + assert seen_commands[0].startswith("cat /sys/") + assert data.readings.get(PATH_ENABLED) == "value" + + +def test_collect_data_skips_path_with_dotdot(linux_sys_settings_collector): + seen_commands = [] + + def run_cmd(cmd, **kwargs): + seen_commands.append(cmd) + return make_artifact(0, "safe") + + linux_sys_settings_collector._run_sut_cmd = run_cmd + args = { + "paths": [ + "/sys/kernel/mm/transparent_hugepage/enabled", + "/sys/../etc/passwd", + "/sys/something/../../etc", + "sys/kernel/mm/transparent_hugepage/defrag", + ] + } + result, data = linux_sys_settings_collector.collect_data(args) + + assert result.status == ExecutionStatus.OK + assert len(seen_commands) == 2 + assert all(c.startswith("cat /sys/") for c in seen_commands) + assert data.readings.get("/sys/kernel/mm/transparent_hugepage/enabled") == "safe" + assert data.readings.get("/sys/kernel/mm/transparent_hugepage/defrag") == "safe" + assert "/etc" not in str(seen_commands)