diff --git a/nodescraper/plugins/inband/sysctl/__init__.py b/nodescraper/plugins/inband/sysctl/__init__.py new file mode 100644 index 0000000..b4ba1e4 --- /dev/null +++ b/nodescraper/plugins/inband/sysctl/__init__.py @@ -0,0 +1,29 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 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 SysctlAnalyzerArgs +from .sysctl_plugin import SysctlPlugin + +__all__ = ["SysctlPlugin", "SysctlAnalyzerArgs"] diff --git a/nodescraper/plugins/inband/sysctl/analyzer_args.py b/nodescraper/plugins/inband/sysctl/analyzer_args.py new file mode 100644 index 0000000..7ecae81 --- /dev/null +++ b/nodescraper/plugins/inband/sysctl/analyzer_args.py @@ -0,0 +1,67 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 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 nodescraper.models import AnalyzerArgs +from nodescraper.plugins.inband.sysctl.sysctldata import SysctlDataModel + + +class SysctlAnalyzerArgs(AnalyzerArgs): + exp_vm_swappiness: Optional[int] = None + exp_vm_numa_balancing: Optional[int] = None + exp_vm_oom_kill_allocating_task: Optional[int] = None + exp_vm_compaction_proactiveness: Optional[int] = None + exp_vm_compact_unevictable_allowed: Optional[int] = None + exp_vm_extfrag_threshold: Optional[int] = None + exp_vm_zone_reclaim_mode: Optional[int] = None + exp_vm_dirty_background_ratio: Optional[int] = None + exp_vm_dirty_ratio: Optional[int] = None + exp_vm_dirty_writeback_centisecs: Optional[int] = None + exp_kernel_numa_balancing: Optional[int] = None + + @classmethod + def build_from_model(cls, datamodel: SysctlDataModel) -> "SysctlAnalyzerArgs": + """build analyzer args from data model + + Args: + datamodel (SysctlDataModel): data model for plugin + + Returns: + SysctlAnalyzerArgs: instance of analyzer args class + """ + return cls( + exp_vm_swappiness=datamodel.vm_swappiness, + exp_vm_numa_balancing=datamodel.vm_numa_balancing, + exp_vm_oom_kill_allocating_task=datamodel.vm_oom_kill_allocating_task, + exp_vm_compaction_proactiveness=datamodel.vm_compaction_proactiveness, + exp_vm_compact_unevictable_allowed=datamodel.vm_compact_unevictable_allowed, + exp_vm_extfrag_threshold=datamodel.vm_extfrag_threshold, + exp_vm_zone_reclaim_mode=datamodel.vm_zone_reclaim_mode, + exp_vm_dirty_background_ratio=datamodel.vm_dirty_background_ratio, + exp_vm_dirty_ratio=datamodel.vm_dirty_ratio, + exp_vm_dirty_writeback_centisecs=datamodel.vm_dirty_writeback_centisecs, + exp_kernel_numa_balancing=datamodel.kernel_numa_balancing, + ) diff --git a/nodescraper/plugins/inband/sysctl/sysctl_analyzer.py b/nodescraper/plugins/inband/sysctl/sysctl_analyzer.py new file mode 100644 index 0000000..301543b --- /dev/null +++ b/nodescraper/plugins/inband/sysctl/sysctl_analyzer.py @@ -0,0 +1,82 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 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 nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult + +from .analyzer_args import SysctlAnalyzerArgs +from .sysctldata import SysctlDataModel + + +class SysctlAnalyzer(DataAnalyzer[SysctlDataModel, SysctlAnalyzerArgs]): + """Check sysctl matches expected sysctl details""" + + DATA_MODEL = SysctlDataModel + + def analyze_data( + self, data: SysctlDataModel, args: Optional[SysctlAnalyzerArgs] = None + ) -> TaskResult: + """Analyze the Sysctl data against expected Sysctl values.""" + mismatches = {} + + if not args: + args = SysctlAnalyzerArgs() + + for exp_field_name, expected_value in args.model_dump(exclude_unset=True).items(): + + data_field_name = exp_field_name.removeprefix("exp_") + actual_value = getattr(data, data_field_name, None) + + if actual_value is None: + mismatches[data_field_name] = {"expected": expected_value, "actual": "missing"} + elif actual_value != expected_value: + mismatches[data_field_name] = {"expected": expected_value, "actual": actual_value} + + if mismatches: + self.result.status = ExecutionStatus.ERROR + self.result.message = f"{len(mismatches)} sysctl parameter(s) mismatched." + self.result.message = "Sysctl parameters mismatch detected." + self._log_event( + category=EventCategory.OS, + description="Sysctl mismatch detected", + data=mismatches, + priority=EventPriority.ERROR, + console_log=True, + ) + else: + self._log_event( + category=EventCategory.OS, + description="All expected sysctl parameters matched", + priority=EventPriority.INFO, + console_log=True, + ) + self.result.status = ExecutionStatus.OK + self.result.message = "All expected sysctl parameters match." + + return self.result diff --git a/nodescraper/plugins/inband/sysctl/sysctl_collector.py b/nodescraper/plugins/inband/sysctl/sysctl_collector.py new file mode 100644 index 0000000..0546dad --- /dev/null +++ b/nodescraper/plugins/inband/sysctl/sysctl_collector.py @@ -0,0 +1,99 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 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 InBandDataCollector +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily +from nodescraper.models import TaskResult + +from .sysctldata import SysctlDataModel + + +class SysctlCollector(InBandDataCollector[SysctlDataModel, None]): + """Collect sysctl kernel VM settings.""" + + DATA_MODEL = SysctlDataModel + + def collect_data( + self, + args=None, + ) -> tuple[TaskResult, SysctlDataModel | None]: + """Collect sysctl VM tuning values from the system.""" + values = {} + + if self.system_info.os_family == OSFamily.WINDOWS: + self._log_event( + category=EventCategory.OS, + description="Windows is not supported for sysctl collection.", + priority=EventPriority.WARNING, + console_log=True, + ) + return self.result, None + + for field_name in SysctlDataModel.model_fields: + sysctl_key = field_name.replace("_", ".", 1) + res = self._run_sut_cmd(f"sysctl -n {sysctl_key}") + + if res.exit_code == 0: + try: + values[field_name] = int(res.stdout.strip()) + except ValueError: + self._log_event( + category=EventCategory.OS, + description=f"Invalid integer value for {sysctl_key}", + data={"stdout": res.stdout}, + priority=EventPriority.ERROR, + console_log=True, + ) + else: + self._log_event( + category=EventCategory.OS, + description=f"Error checking Linux system setting : {sysctl_key}", + data={"system_setting": sysctl_key, "exit_code": res.exit_code}, + priority=EventPriority.WARNING, + console_log=True, + ) + + if values: + sysctl_data = SysctlDataModel(**values) + self._log_event( + category="OS", + description="Sysctl settings read", + data=sysctl_data.model_dump(), + priority=EventPriority.INFO, + ) + self.result.message = "SYSCTL data collected" + self.result.status = ExecutionStatus.OK + else: + sysctl_data = None + self._log_event( + category=EventCategory.OS, + description="Sysctl settings not read", + priority=EventPriority.CRITICAL, + ) + self.result.message = "Sysctl settings not read" + self.result.status = ExecutionStatus.ERROR + + return self.result, sysctl_data diff --git a/nodescraper/plugins/inband/sysctl/sysctl_plugin.py b/nodescraper/plugins/inband/sysctl/sysctl_plugin.py new file mode 100644 index 0000000..2c36a10 --- /dev/null +++ b/nodescraper/plugins/inband/sysctl/sysctl_plugin.py @@ -0,0 +1,43 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 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 SysctlAnalyzerArgs +from .sysctl_analyzer import SysctlAnalyzer +from .sysctl_collector import SysctlCollector +from .sysctldata import SysctlDataModel + + +class SysctlPlugin(InBandDataPlugin[SysctlDataModel, None, SysctlAnalyzerArgs]): + """Plugin for collection and analysis of BIOS data""" + + DATA_MODEL = SysctlDataModel + + COLLECTOR = SysctlCollector + + ANALYZER = SysctlAnalyzer + + ANALYZER_ARGS = SysctlAnalyzerArgs diff --git a/nodescraper/plugins/inband/sysctl/sysctldata.py b/nodescraper/plugins/inband/sysctl/sysctldata.py new file mode 100644 index 0000000..258b3b5 --- /dev/null +++ b/nodescraper/plugins/inband/sysctl/sysctldata.py @@ -0,0 +1,42 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2025 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 nodescraper.models import DataModel + + +class SysctlDataModel(DataModel): + vm_swappiness: Optional[int] = None + vm_numa_balancing: Optional[int] = None + vm_oom_kill_allocating_task: Optional[int] = None + vm_compaction_proactiveness: Optional[int] = None + vm_compact_unevictable_allowed: Optional[int] = None + vm_extfrag_threshold: Optional[int] = None + vm_zone_reclaim_mode: Optional[int] = None + vm_dirty_background_ratio: Optional[int] = None + vm_dirty_ratio: Optional[int] = None + vm_dirty_writeback_centisecs: Optional[int] = None + kernel_numa_balancing: Optional[int] = None diff --git a/test/unit/plugin/test_sysctl_analyzer.py b/test/unit/plugin/test_sysctl_analyzer.py new file mode 100644 index 0000000..51fbc4d --- /dev/null +++ b/test/unit/plugin/test_sysctl_analyzer.py @@ -0,0 +1,41 @@ +import pytest + +from nodescraper.enums import ExecutionStatus +from nodescraper.plugins.inband.sysctl.analyzer_args import SysctlAnalyzerArgs +from nodescraper.plugins.inband.sysctl.sysctl_analyzer import SysctlAnalyzer +from nodescraper.plugins.inband.sysctl.sysctldata import SysctlDataModel + + +@pytest.fixture +def analyzer(system_info): + return SysctlAnalyzer(system_info=system_info) + + +@pytest.fixture +def correct_data(): + return SysctlDataModel( + vm_swappiness=1, + vm_numa_balancing=2, + vm_oom_kill_allocating_task=3, + vm_compaction_proactiveness=4, + vm_compact_unevictable_allowed=5, + vm_extfrag_threshold=6, + vm_zone_reclaim_mode=7, + vm_dirty_background_ratio=8, + vm_dirty_ratio=9, + vm_dirty_writeback_centisecs=10, + kernel_numa_balancing=11, + ) + + +def test_analyzer_all_match(analyzer, correct_data): + args = SysctlAnalyzerArgs.build_from_model(correct_data) + result = analyzer.analyze_data(correct_data, args) + assert result.status == ExecutionStatus.OK + + +def test_analyzer_mismatch(analyzer, correct_data): + args = SysctlAnalyzerArgs(exp_vm_swappiness=3, exp_vm_numa_balancing=4) + result = analyzer.analyze_data(correct_data, args) + assert result.status == ExecutionStatus.ERROR + assert "Sysctl parameters mismatch detected" in result.message diff --git a/test/unit/plugin/test_sysctl_collector.py b/test/unit/plugin/test_sysctl_collector.py new file mode 100644 index 0000000..553a6d4 --- /dev/null +++ b/test/unit/plugin/test_sysctl_collector.py @@ -0,0 +1,53 @@ +from types import SimpleNamespace + +import pytest + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily +from nodescraper.plugins.inband.sysctl.sysctl_collector import SysctlCollector +from nodescraper.plugins.inband.sysctl.sysctldata import SysctlDataModel + + +@pytest.fixture +def linux_sysctl_collector(system_info, conn_mock): + system_info.os_family = OSFamily.LINUX + return SysctlCollector(system_info, conn_mock) + + +def make_artifact(cmd, exit_code, stdout): + return SimpleNamespace(command=cmd, exit_code=exit_code, stdout=stdout, stderr="") + + +def test_collect_data_all_fields_success(linux_sysctl_collector): + sysctl_fields = SysctlDataModel.model_fields.keys() + responses = [ + make_artifact(f"sysctl -n {f.replace('_', '.', 1)}", 0, "111") for f in sysctl_fields + ] + + linux_sysctl_collector._run_sut_cmd = lambda cmd, seq=responses: seq.pop(0) + + result, data = linux_sysctl_collector.collect_data() + + assert result.status == ExecutionStatus.OK + assert isinstance(data, SysctlDataModel) + for field in SysctlDataModel.model_fields: + assert getattr(data, field) == 111 + + event = result.events[-1] + assert event.category == "OS" + assert event.priority == EventPriority.INFO.value + assert result.message == "SYSCTL data collected" + + +def test_collect_data_all_fail(linux_sysctl_collector): + def always_fail(cmd): + return make_artifact(cmd, 1, "") + + linux_sysctl_collector._run_sut_cmd = always_fail + result, data = linux_sysctl_collector.collect_data() + + assert result.status == ExecutionStatus.ERROR + assert data is None + + evt = result.events[0] + assert evt.category == EventCategory.OS.value + assert "Sysctl settings not read" in result.message