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..e3d4e06 --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/analyzer_args.py @@ -0,0 +1,68 @@ +############################################################################### +# +# 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 + +from nodescraper.models import AnalyzerArgs + + +class SysfsCheck(BaseModel): + """One sysfs check: path to read, acceptable values, and display name. + + If expected is an empty list, the check is treated as passing (no constraint). + """ + + path: str + expected: list[str] + name: str + + +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 the unique sysfs paths from checks, for use by the collector. + + Returns: + List of unique path strings from self.checks, preserving order of first occurrence. + """ + if not self.checks: + return [] + seen = set() + out = [] + for c in self.checks: + 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..4f321d3 --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/collector_args.py @@ -0,0 +1,32 @@ +############################################################################### +# +# 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: list of sysfs paths to read.""" + + 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..06c3811 --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_analyzer.py @@ -0,0 +1,127 @@ +############################################################################### +# +# 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, 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 = {} + + 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: + 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", "") + expected = info.get("expected") + actual = cast(Optional[str], info.get("actual")) + reason = info.get("reason") + if reason: + part = f"{name} ({path})" + else: + 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..da08934 --- /dev/null +++ b/nodescraper/plugins/inband/sys_settings/sys_settings_collector.py @@ -0,0 +1,132 @@ +############################################################################### +# +# 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 (value in square brackets). + + 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 [] + + +class SysSettingsCollector(InBandDataCollector[SysSettingsDataModel, SysSettingsCollectorArgs]): + """Collect sysfs settings from user-specified paths (paths come from config/args).""" + + DATA_MODEL = SysSettingsDataModel + SUPPORTED_OS_FAMILY: set[OSFamily] = {OSFamily.LINUX} + + CMD = "cat {}" + + 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) + if not 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: + res = self._run_sut_cmd(self.CMD.format(path), sudo=False) + if res.exit_code == 0 and res.stdout: + value = _parse_bracketed_setting(res.stdout) or res.stdout.strip() + readings[path] = value + else: + self._log_event( + category=EventCategory.OS, + description=f"Failed to read sysfs path: {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/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..4a482aa --- /dev/null +++ b/test/functional/test_sys_settings_plugin.py @@ -0,0 +1,87 @@ +############################################################################### +# +# 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" + + +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 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..275cfde --- /dev/null +++ b/test/unit/plugin/test_sys_settings_collector.py @@ -0,0 +1,122 @@ +############################################################################### +# +# 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)