From 1b7b248163f02ce5c415b697354d3aaf65ef0e70 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Wed, 10 Jun 2026 10:29:44 -0500 Subject: [PATCH 1/3] OobBmcArchivePlugin --- .../plugins/ooband/bmc_archive/__init__.py | 40 +++ .../bmc_archive/bmc_archive_collector.py | 312 ++++++++++++++++++ .../ooband/bmc_archive/bmc_archive_data.py | 65 ++++ .../ooband/bmc_archive/bmc_archive_plugin.py | 44 +++ .../ooband/bmc_archive/collector_args.py | 113 +++++++ .../plugin/test_oob_bmc_archive_plugin.py | 196 +++++++++++ 6 files changed, 770 insertions(+) create mode 100644 nodescraper/plugins/ooband/bmc_archive/__init__.py create mode 100644 nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py create mode 100644 nodescraper/plugins/ooband/bmc_archive/bmc_archive_data.py create mode 100644 nodescraper/plugins/ooband/bmc_archive/bmc_archive_plugin.py create mode 100644 nodescraper/plugins/ooband/bmc_archive/collector_args.py create mode 100644 test/unit/plugin/test_oob_bmc_archive_plugin.py diff --git a/nodescraper/plugins/ooband/bmc_archive/__init__.py b/nodescraper/plugins/ooband/bmc_archive/__init__.py new file mode 100644 index 00000000..2861fb9b --- /dev/null +++ b/nodescraper/plugins/ooband/bmc_archive/__init__.py @@ -0,0 +1,40 @@ +############################################################################### +# +# 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. +# +############################################################################### +"""OOB BMC archive collection over SSH.""" + +from .bmc_archive_collector import BmcArchiveCollector +from .bmc_archive_data import ArchiveCollectionResult, BmcArchiveDataModel +from .bmc_archive_plugin import OobBmcArchivePlugin +from .collector_args import BmcArchiveCollectorArgs, PathSpec + +__all__ = [ + "ArchiveCollectionResult", + "BmcArchiveCollector", + "BmcArchiveCollectorArgs", + "BmcArchiveDataModel", + "OobBmcArchivePlugin", + "PathSpec", +] diff --git a/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py new file mode 100644 index 00000000..f2c62378 --- /dev/null +++ b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py @@ -0,0 +1,312 @@ +############################################################################### +# +# 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 nodescraper.base import InBandDataCollector +from nodescraper.connection.inband.inband import BaseFileArtifact, BinaryFileArtifact +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily +from nodescraper.models import TaskResult +from nodescraper.utils import shell_quote + +from .bmc_archive_data import ArchiveCollectionResult, BmcArchiveDataModel +from .collector_args import BmcArchiveCollectorArgs, PathSpec + + +class BmcArchiveCollector(InBandDataCollector[BmcArchiveDataModel, BmcArchiveCollectorArgs]): + """Archive BMC directories over SSH using tar czf - .""" + + DATA_MODEL = BmcArchiveDataModel + SUPPORTED_OS_FAMILY = {OSFamily.LINUX} + + REMOTE_ARCHIVE_TEMPLATE = "/tmp/node_scraper_{name}.tar.gz" + + def _remote_archive_path(self, name: str) -> str: + safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name) + return self.REMOTE_ARCHIVE_TEMPLATE.format(name=safe_name) + + def _tar_command( + self, + path: str, + remote_archive: str, + *, + ignore_failed_read: bool, + ) -> str: + tar_flags = "czf -" + if ignore_failed_read: + tar_flags = "czf - --ignore-failed-read" + return f"tar {tar_flags} {shell_quote(path)} > {shell_quote(remote_archive)}" + + def _path_exists(self, path: str, *, sudo: bool, timeout: int) -> bool: + res = self._run_sut_cmd( + f"test -e {shell_quote(path)}", + sudo=sudo, + timeout=timeout, + log_artifact=False, + ) + return res.exit_code == 0 + + def _remote_archive_has_content(self, remote_archive: str, *, sudo: bool, timeout: int) -> bool: + res = self._run_sut_cmd( + f"test -s {shell_quote(remote_archive)}", + sudo=sudo, + timeout=timeout, + log_artifact=False, + ) + return res.exit_code == 0 + + def _read_remote_archive( + self, + path_spec: PathSpec, + *, + remote_archive: str, + archive_filename: str, + sudo: bool, + timeout: int, + result: ArchiveCollectionResult, + ) -> tuple[ArchiveCollectionResult, Optional[BinaryFileArtifact]]: + read_artifact: Optional[BaseFileArtifact] = None + try: + read_artifact = self._read_sut_file( + remote_archive, + encoding=None, + strip=False, + log_artifact=True, + ) + except Exception as exc: + result.stderr = str(exc) + self._log_event( + category=EventCategory.RUNTIME, + description=f"BMC archive read failed: {path_spec.name}", + data={"name": path_spec.name, "path": path_spec.path, "error": str(exc)}, + priority=EventPriority.ERROR, + console_log=True, + ) + return result, None + finally: + self._run_sut_cmd( + f"rm -f {shell_quote(remote_archive)}", + sudo=sudo, + timeout=timeout, + log_artifact=False, + ) + + if not isinstance(read_artifact, BinaryFileArtifact) or not read_artifact.contents: + result.stderr = "Archive file was empty or unreadable" + self._log_event( + category=EventCategory.RUNTIME, + description=f"BMC archive empty: {path_spec.name}", + data={"name": path_spec.name, "path": path_spec.path}, + priority=EventPriority.ERROR, + console_log=True, + ) + return result, None + + read_artifact.filename = archive_filename + result.success = True + result.size_bytes = len(read_artifact.contents) + return result, read_artifact + + def _collect_path( + self, + path_spec: PathSpec, + *, + default_sudo: bool, + default_timeout: int, + default_skip_if_missing: bool, + default_ignore_failed_read: bool, + ) -> tuple[ArchiveCollectionResult, Optional[BinaryFileArtifact]]: + sudo = default_sudo if path_spec.sudo is None else path_spec.sudo + timeout = default_timeout if path_spec.timeout is None else path_spec.timeout + skip_if_missing = ( + default_skip_if_missing + if path_spec.skip_if_missing is None + else path_spec.skip_if_missing + ) + ignore_failed_read = ( + default_ignore_failed_read + if path_spec.ignore_failed_read is None + else path_spec.ignore_failed_read + ) + remote_archive = self._remote_archive_path(path_spec.name) + archive_filename = f"{path_spec.name}.tar.gz" + + result = ArchiveCollectionResult( + name=path_spec.name, + path=path_spec.path, + archive_filename=archive_filename, + ) + + if not self._path_exists(path_spec.path, sudo=sudo, timeout=timeout): + result.stderr = f"Path does not exist: {path_spec.path}" + if skip_if_missing: + result.skipped = True + self._log_event( + category=EventCategory.RUNTIME, + description=f"BMC archive skipped: {path_spec.name}", + data={"name": path_spec.name, "path": path_spec.path, "reason": "missing"}, + priority=EventPriority.WARNING, + ) + return result, None + + self._log_event( + category=EventCategory.RUNTIME, + description=f"BMC archive failed: {path_spec.name}", + data={ + "name": path_spec.name, + "path": path_spec.path, + "exit_code": 2, + "stderr": result.stderr, + }, + priority=EventPriority.ERROR, + console_log=True, + ) + result.exit_code = 2 + return result, None + + tar_res = self._run_sut_cmd( + self._tar_command( + path_spec.path, + remote_archive, + ignore_failed_read=ignore_failed_read, + ), + sudo=sudo, + timeout=timeout, + log_artifact=True, + ) + result.exit_code = tar_res.exit_code + result.stderr = tar_res.stderr or "" + + if tar_res.exit_code != 0: + if not self._remote_archive_has_content( + remote_archive, + sudo=sudo, + timeout=timeout, + ): + self._log_event( + category=EventCategory.RUNTIME, + description=f"BMC archive failed: {path_spec.name}", + data={ + "name": path_spec.name, + "path": path_spec.path, + "exit_code": tar_res.exit_code, + "stderr": tar_res.stderr, + }, + priority=EventPriority.ERROR, + console_log=True, + ) + self._run_sut_cmd( + f"rm -f {shell_quote(remote_archive)}", + sudo=sudo, + timeout=timeout, + log_artifact=False, + ) + return result, None + + self._log_event( + category=EventCategory.RUNTIME, + description=f"BMC archive partial: {path_spec.name}", + data={ + "name": path_spec.name, + "path": path_spec.path, + "exit_code": tar_res.exit_code, + "stderr": tar_res.stderr, + }, + priority=EventPriority.WARNING, + ) + + result, archive_artifact = self._read_remote_archive( + path_spec, + remote_archive=remote_archive, + archive_filename=archive_filename, + sudo=sudo, + timeout=timeout, + result=result, + ) + if result.success: + priority = EventPriority.WARNING if tar_res.exit_code != 0 else EventPriority.INFO + self._log_event( + category=EventCategory.RUNTIME, + description=f"BMC archive collected: {path_spec.name}", + data={ + "name": path_spec.name, + "path": path_spec.path, + "size_bytes": result.size_bytes, + "archive_filename": archive_filename, + "partial": tar_res.exit_code != 0, + }, + priority=priority, + ) + return result, archive_artifact + + def collect_data( + self, + args: Optional[BmcArchiveCollectorArgs] = None, + ) -> tuple[TaskResult, Optional[BmcArchiveDataModel]]: + if args is None: + args = BmcArchiveCollectorArgs() + + if not args.paths: + self.result.message = "No paths configured in collection_args.paths" + self.result.status = ExecutionStatus.NOT_RAN + return self.result, None + + results: list[ArchiveCollectionResult] = [] + archives: list[BinaryFileArtifact] = [] + failures: list[str] = [] + + for path_spec in args.paths: + result, archive_artifact = self._collect_path( + path_spec, + default_sudo=args.sudo, + default_timeout=args.timeout, + default_skip_if_missing=args.skip_if_missing, + default_ignore_failed_read=args.ignore_failed_read, + ) + results.append(result) + if archive_artifact is not None: + archives.append(archive_artifact) + if not result.success and not result.skipped: + failures.append(path_spec.name) + + success_count = sum(1 for result in results if result.success) + skipped_count = sum(1 for result in results if result.skipped) + total = len(results) + + if failures: + self.result.message = ( + f"BMC archive collection: {success_count}/{total} paths archived " + f"({len(failures)} errors: {', '.join(failures)}" + f"{f'; {skipped_count} skipped' if skipped_count else ''})" + ) + self.result.status = ExecutionStatus.ERROR + else: + suffix = f", {skipped_count} skipped" if skipped_count else "" + self.result.message = ( + f"BMC archive collection: {success_count}/{total} paths archived{suffix}" + ) + self.result.status = ExecutionStatus.OK + + return self.result, BmcArchiveDataModel(results=results, archives=archives) diff --git a/nodescraper/plugins/ooband/bmc_archive/bmc_archive_data.py b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_data.py new file mode 100644 index 00000000..9393acf2 --- /dev/null +++ b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_data.py @@ -0,0 +1,65 @@ +############################################################################### +# +# 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 os +from typing import Optional + +from pydantic import Field + +from nodescraper.connection.inband.inband import BinaryFileArtifact +from nodescraper.models import DataModel + + +class ArchiveCollectionResult(DataModel): + """Result of archiving one BMC path.""" + + name: str + path: str + success: bool = False + skipped: bool = False + exit_code: int = 0 + stderr: str = "" + size_bytes: int = 0 + archive_filename: Optional[str] = None + + +class BmcArchiveDataModel(DataModel): + """Collected BMC directory archives.""" + + results: list[ArchiveCollectionResult] = Field(default_factory=list) + archives: list[BinaryFileArtifact] = Field(default_factory=list) + + def log_model(self, log_path: str) -> None: + for archive in self.archives: + archive.log_model(log_path) + + log_name = os.path.join(log_path, "oob_bmc_archive_results.json") + with open(log_name, "w", encoding="utf-8") as log_file: + log_file.write( + self.model_dump_json( + indent=2, + exclude={"archives"}, + ) + ) diff --git a/nodescraper/plugins/ooband/bmc_archive/bmc_archive_plugin.py b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_plugin.py new file mode 100644 index 00000000..dca6fc5e --- /dev/null +++ b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_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 OOBSSHDataPlugin + +from .bmc_archive_collector import BmcArchiveCollector +from .bmc_archive_data import BmcArchiveDataModel +from .collector_args import BmcArchiveCollectorArgs + + +class OobBmcArchivePlugin( + OOBSSHDataPlugin[ + BmcArchiveDataModel, + BmcArchiveCollectorArgs, + None, + ] +): + """Archive remote directories over BMC SSH using tar czf - .""" + + DATA_MODEL = BmcArchiveDataModel + COLLECTOR = BmcArchiveCollector + COLLECTOR_ARGS = BmcArchiveCollectorArgs diff --git a/nodescraper/plugins/ooband/bmc_archive/collector_args.py b/nodescraper/plugins/ooband/bmc_archive/collector_args.py new file mode 100644 index 00000000..c8d7d3cd --- /dev/null +++ b/nodescraper/plugins/ooband/bmc_archive/collector_args.py @@ -0,0 +1,113 @@ +############################################################################### +# +# 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 Field, field_validator, model_validator + +from nodescraper.models import CollectorArgs + + +class PathSpec(CollectorArgs): + """One named BMC directory path to archive.""" + + name: str = Field(description="Stable name for this archive, used in output filenames.") + path: str = Field(description="Absolute BMC path to tar.") + sudo: Optional[bool] = Field( + default=None, + description="Run tar with sudo. When omitted, uses collection_args.sudo.", + ) + timeout: Optional[int] = Field( + default=None, + ge=1, + description="Tar command timeout in seconds. When omitted, uses collection_args.timeout.", + ) + skip_if_missing: Optional[bool] = Field( + default=None, + description="Skip this path when it does not exist on the BMC. When omitted, uses collection_args.skip_if_missing.", + ) + ignore_failed_read: Optional[bool] = Field( + default=None, + description=( + "Pass --ignore-failed-read to tar so unreadable files do not abort the archive. " + "When omitted, uses collection_args.ignore_failed_read." + ), + ) + + @field_validator("name", "path", mode="before") + @classmethod + def _strip_required_text(cls, value: object) -> object: + if isinstance(value, str): + return value.strip() + return value + + @model_validator(mode="after") + def _validate_required_fields(self) -> "PathSpec": + if not self.name: + raise ValueError("name must not be empty") + if not self.path: + raise ValueError("path must not be empty") + if not self.path.startswith("/"): + raise ValueError("path must be an absolute BMC path") + return self + + +class BmcArchiveCollectorArgs(CollectorArgs): + paths: list[PathSpec] = Field( + default_factory=list, + description=( + "Named BMC paths to archive with tar czf -. " + "Configure in plugin config under plugins.OobBmcArchivePlugin.collection_args.paths." + ), + ) + sudo: bool = Field( + default=False, + description="Default sudo setting for paths that do not specify sudo.", + ) + timeout: int = Field( + default=600, + ge=1, + description="Default per-path tar timeout in seconds.", + ) + skip_if_missing: bool = Field( + default=False, + description="Skip paths that do not exist on the BMC instead of failing collection.", + ) + ignore_failed_read: bool = Field( + default=True, + description="Default tar --ignore-failed-read setting for paths that do not override it.", + ) + + @model_validator(mode="after") + def _validate_unique_path_names(self) -> "BmcArchiveCollectorArgs": + seen: set[str] = set() + duplicates: set[str] = set() + for path_spec in self.paths: + if path_spec.name in seen: + duplicates.add(path_spec.name) + seen.add(path_spec.name) + if duplicates: + raise ValueError(f"Duplicate path name(s): {sorted(duplicates)}") + return self diff --git a/test/unit/plugin/test_oob_bmc_archive_plugin.py b/test/unit/plugin/test_oob_bmc_archive_plugin.py new file mode 100644 index 00000000..f84925b0 --- /dev/null +++ b/test/unit/plugin/test_oob_bmc_archive_plugin.py @@ -0,0 +1,196 @@ +############################################################################### +# +# 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 unittest.mock import MagicMock + +import pytest + +from nodescraper.connection.inband.inband import BinaryFileArtifact, CommandArtifact +from nodescraper.enums import ExecutionStatus, OSFamily, SystemLocation +from nodescraper.models import SystemInfo +from nodescraper.pluginregistry import PluginRegistry +from nodescraper.plugins.ooband.bmc_archive import ( + BmcArchiveCollector, + BmcArchiveCollectorArgs, + OobBmcArchivePlugin, + PathSpec, +) + + +@pytest.fixture +def collector(monkeypatch): + monkeypatch.setattr( + "nodescraper.base.inbandcollectortask.InBandDataCollector.__init__", + lambda self, *args, **kwargs: None, + ) + collector = BmcArchiveCollector( + system_info=SystemInfo( + hostname="bmc", + location=SystemLocation.REMOTE, + os_family=OSFamily.LINUX, + ), + connection=MagicMock(), + ) + collector.result.status = ExecutionStatus.OK + collector.result.message = "" + collector.logger = MagicMock() + return collector + + +def test_oob_bmc_archive_plugin_registers(): + assert OobBmcArchivePlugin.is_valid() + assert OobBmcArchivePlugin.ANALYZER is None + assert "OobBmcArchivePlugin" in PluginRegistry().plugins + + +def test_plugin_log_directory_name_uses_oob_prefix(): + from nodescraper.utils import pascal_to_snake + + assert pascal_to_snake("OobBmcArchivePlugin") == "oob_bmc_archive_plugin" + + +def test_tar_command_uses_streaming_tar_and_redirect(collector): + cmd = collector._tar_command( + "/data/example_a", + "/tmp/node_scraper_archive_alpha.tar.gz", + ignore_failed_read=True, + ) + assert ( + cmd + == "tar czf - --ignore-failed-read '/data/example_a' > '/tmp/node_scraper_archive_alpha.tar.gz'" + ) + + +def test_collect_path_reads_archive_after_tar(collector, monkeypatch): + exists_result = CommandArtifact( + command="test -e '/data/example_a'", stdout="", stderr="", exit_code=0 + ) + tar_result = CommandArtifact( + command="tar czf - --ignore-failed-read '/data/example_a' > '/tmp/node_scraper_archive_alpha.tar.gz'", + stdout="", + stderr="", + exit_code=0, + ) + archive_bytes = b"fake-gzip-data" + read_result = BinaryFileArtifact(filename="archive_alpha.tar.gz", contents=archive_bytes) + rm_result = CommandArtifact( + command="rm -f '/tmp/node_scraper_archive_alpha.tar.gz'", + stdout="", + stderr="", + exit_code=0, + ) + + collector._run_sut_cmd = MagicMock(side_effect=[exists_result, tar_result, rm_result]) + collector._read_sut_file = MagicMock(return_value=read_result) + collector._log_event = MagicMock() + + path_spec = PathSpec(name="archive_alpha", path="/data/example_a") + result, archive = collector._collect_path( + path_spec, + default_sudo=False, + default_timeout=600, + default_skip_if_missing=False, + default_ignore_failed_read=True, + ) + + assert result.success is True + assert result.size_bytes == len(archive_bytes) + assert archive is not None + assert archive.filename == "archive_alpha.tar.gz" + collector._run_sut_cmd.assert_any_call( + "tar czf - --ignore-failed-read '/data/example_a' > '/tmp/node_scraper_archive_alpha.tar.gz'", + sudo=False, + timeout=600, + log_artifact=True, + ) + collector._read_sut_file.assert_called_once_with( + "/tmp/node_scraper_archive_alpha.tar.gz", + encoding=None, + strip=False, + log_artifact=True, + ) + + +def test_collect_path_skips_missing_path_when_configured(collector): + missing_result = CommandArtifact( + command="test -e '/data/missing'", + stdout="", + stderr="", + exit_code=1, + ) + collector._run_sut_cmd = MagicMock(return_value=missing_result) + collector._log_event = MagicMock() + + path_spec = PathSpec(name="archive_missing", path="/data/missing") + result, archive = collector._collect_path( + path_spec, + default_sudo=False, + default_timeout=600, + default_skip_if_missing=True, + default_ignore_failed_read=True, + ) + + assert result.skipped is True + assert result.success is False + assert archive is None + collector._run_sut_cmd.assert_called_once() + + +def test_collect_data_not_ran_without_paths(collector): + collector._log_event = MagicMock() + task_result, data = collector.collect_data(BmcArchiveCollectorArgs(paths=[])) + + assert task_result.status == ExecutionStatus.NOT_RAN + assert data is None + assert "collection_args.paths" in task_result.message + + +def test_collect_data_reports_partial_failures(collector, monkeypatch): + exists_ok = CommandArtifact(command="test -e", stdout="", stderr="", exit_code=0) + ok_tar = CommandArtifact(command="tar", stdout="", stderr="", exit_code=0) + fail_tar = CommandArtifact(command="tar", stdout="", stderr="missing", exit_code=2) + no_archive = CommandArtifact(command="test -s", stdout="", stderr="", exit_code=1) + rm = CommandArtifact(command="rm", stdout="", stderr="", exit_code=0) + archive = BinaryFileArtifact(filename="archive_alpha.tar.gz", contents=b"data") + + collector._run_sut_cmd = MagicMock( + side_effect=[exists_ok, ok_tar, rm, exists_ok, fail_tar, no_archive, rm] + ) + collector._read_sut_file = MagicMock(return_value=archive) + collector._log_event = MagicMock() + + args = BmcArchiveCollectorArgs( + paths=[ + PathSpec(name="archive_alpha", path="/data/example_a"), + PathSpec(name="archive_beta", path="/data/example_b"), + ] + ) + task_result, data = collector.collect_data(args) + + assert task_result.status == ExecutionStatus.ERROR + assert data is not None + assert len(data.archives) == 1 + assert data.results[0].success is True + assert data.results[1].success is False From d5e62a99dda41e1b849a2d8e560620c0f8ca9c20 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Thu, 11 Jun 2026 09:22:42 -0500 Subject: [PATCH 2/3] updates --- nodescraper/interfaces/connectionmanager.py | 2 +- .../bmc_archive/bmc_archive_collector.py | 41 +++++++++- .../ooband/bmc_archive/collector_args.py | 4 +- .../plugin/test_oob_bmc_archive_plugin.py | 82 ++++++++++++++++++- 4 files changed, 123 insertions(+), 6 deletions(-) diff --git a/nodescraper/interfaces/connectionmanager.py b/nodescraper/interfaces/connectionmanager.py index 6b468f06..d413ffaf 100644 --- a/nodescraper/interfaces/connectionmanager.py +++ b/nodescraper/interfaces/connectionmanager.py @@ -126,7 +126,7 @@ def __init__( def __init_subclass__(cls, **kwargs) -> None: super().__init_subclass__(**kwargs) - if hasattr(cls, "connect"): + if "connect" in cls.__dict__: cls.connect = connect_decorator(cls.connect) def __enter__(self): diff --git a/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py index f2c62378..a5c52c93 100644 --- a/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py +++ b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py @@ -42,11 +42,43 @@ class BmcArchiveCollector(InBandDataCollector[BmcArchiveDataModel, BmcArchiveCol SUPPORTED_OS_FAMILY = {OSFamily.LINUX} REMOTE_ARCHIVE_TEMPLATE = "/tmp/node_scraper_{name}.tar.gz" + # None until first probe in a run; collect_data resets so each collection re-probes. + _tar_ignore_failed_read_supported: bool | None = None def _remote_archive_path(self, name: str) -> str: safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name) return self.REMOTE_ARCHIVE_TEMPLATE.format(name=safe_name) + def _remote_tar_supports_ignore_failed_read(self, *, sudo: bool, timeout: int) -> bool: + """Return True only if remote tar accepts GNU's --ignore-failed-read.""" + cached = getattr(self, "_tar_ignore_failed_read_supported", None) + if cached is not None: + return cached + probe = self._run_sut_cmd( + "tar cf - --ignore-failed-read /dev/null", + sudo=sudo, + timeout=min(timeout, 60), + log_artifact=False, + ) + stderr = (probe.stderr or "").lower() + if probe.exit_code == 0: + self._tar_ignore_failed_read_supported = True + return True + if any( + phrase in stderr + for phrase in ( + "unrecognized option", + "invalid option", + "unknown option", + "illegal option", + ) + ): + self._tar_ignore_failed_read_supported = False + return False + # Unrecognized failure: omit the flag so archiving still runs. + self._tar_ignore_failed_read_supported = False + return False + def _tar_command( self, path: str, @@ -186,11 +218,16 @@ def _collect_path( result.exit_code = 2 return result, None + use_ignore_failed_read = ( + ignore_failed_read + and self._remote_tar_supports_ignore_failed_read(sudo=sudo, timeout=timeout) + ) + tar_res = self._run_sut_cmd( self._tar_command( path_spec.path, remote_archive, - ignore_failed_read=ignore_failed_read, + ignore_failed_read=use_ignore_failed_read, ), sudo=sudo, timeout=timeout, @@ -273,6 +310,8 @@ def collect_data( self.result.status = ExecutionStatus.NOT_RAN return self.result, None + self._tar_ignore_failed_read_supported = None + results: list[ArchiveCollectionResult] = [] archives: list[BinaryFileArtifact] = [] failures: list[str] = [] diff --git a/nodescraper/plugins/ooband/bmc_archive/collector_args.py b/nodescraper/plugins/ooband/bmc_archive/collector_args.py index c8d7d3cd..bea4d82c 100644 --- a/nodescraper/plugins/ooband/bmc_archive/collector_args.py +++ b/nodescraper/plugins/ooband/bmc_archive/collector_args.py @@ -97,7 +97,9 @@ class BmcArchiveCollectorArgs(CollectorArgs): ) ignore_failed_read: bool = Field( default=True, - description="Default tar --ignore-failed-read setting for paths that do not override it.", + description=( + "When true, pass GNU tar's --ignore-failed-read when the remote tar supports it." + ), ) @model_validator(mode="after") diff --git a/test/unit/plugin/test_oob_bmc_archive_plugin.py b/test/unit/plugin/test_oob_bmc_archive_plugin.py index f84925b0..9040d330 100644 --- a/test/unit/plugin/test_oob_bmc_archive_plugin.py +++ b/test/unit/plugin/test_oob_bmc_archive_plugin.py @@ -27,9 +27,11 @@ import pytest +from nodescraper.base import OOBSSHDataPlugin from nodescraper.connection.inband.inband import BinaryFileArtifact, CommandArtifact +from nodescraper.connection.redfish import RedfishConnectionManager from nodescraper.enums import ExecutionStatus, OSFamily, SystemLocation -from nodescraper.models import SystemInfo +from nodescraper.models import SystemInfo, TaskResult from nodescraper.pluginregistry import PluginRegistry from nodescraper.plugins.ooband.bmc_archive import ( BmcArchiveCollector, @@ -53,6 +55,10 @@ def collector(monkeypatch): ), connection=MagicMock(), ) + # InBandDataCollector.__init__ is stubbed, so Task/DataCollector init never runs. + collector.parent = None + collector.task_result_hooks = [] + collector.result = TaskResult(task=BmcArchiveCollector.__name__, parent=None) collector.result.status = ExecutionStatus.OK collector.result.message = "" collector.logger = MagicMock() @@ -65,6 +71,11 @@ def test_oob_bmc_archive_plugin_registers(): assert "OobBmcArchivePlugin" in PluginRegistry().plugins +def test_oob_bmc_archive_plugin_uses_redfish_connection_manager_like_oob_generic_collection(): + assert issubclass(OobBmcArchivePlugin, OOBSSHDataPlugin) + assert OobBmcArchivePlugin.CONNECTION_TYPE is RedfishConnectionManager + + def test_plugin_log_directory_name_uses_oob_prefix(): from nodescraper.utils import pascal_to_snake @@ -83,10 +94,61 @@ def test_tar_command_uses_streaming_tar_and_redirect(collector): ) +def test_collect_path_omits_ignore_failed_read_when_tar_lacks_option(collector, monkeypatch): + """If ``--ignore-failed-read`` is not supported, fall back to plain tar.""" + exists_result = CommandArtifact( + command="test -e '/data/example_a'", stdout="", stderr="", exit_code=0 + ) + probe_unsupported = CommandArtifact( + command="tar cf - --ignore-failed-read /dev/null", + stdout="", + stderr="tar: unrecognized option '--ignore-failed-read'\n", + exit_code=1, + ) + tar_plain = CommandArtifact( + command="tar czf - '/data/example_a' > '/tmp/node_scraper_archive_alpha.tar.gz'", + stdout="", + stderr="", + exit_code=0, + ) + read_result = BinaryFileArtifact(filename="archive_alpha.tar.gz", contents=b"x") + rm_result = CommandArtifact(command="rm -f", stdout="", stderr="", exit_code=0) + + collector._run_sut_cmd = MagicMock( + side_effect=[exists_result, probe_unsupported, tar_plain, rm_result] + ) + collector._read_sut_file = MagicMock(return_value=read_result) + collector._log_event = MagicMock() + + path_spec = PathSpec(name="archive_alpha", path="/data/example_a") + result, archive = collector._collect_path( + path_spec, + default_sudo=False, + default_timeout=600, + default_skip_if_missing=False, + default_ignore_failed_read=True, + ) + + assert result.success is True + assert archive is not None + collector._run_sut_cmd.assert_any_call( + "tar czf - '/data/example_a' > '/tmp/node_scraper_archive_alpha.tar.gz'", + sudo=False, + timeout=600, + log_artifact=True, + ) + + def test_collect_path_reads_archive_after_tar(collector, monkeypatch): exists_result = CommandArtifact( command="test -e '/data/example_a'", stdout="", stderr="", exit_code=0 ) + probe_result = CommandArtifact( + command="tar cf - --ignore-failed-read /dev/null", + stdout="", + stderr="", + exit_code=0, + ) tar_result = CommandArtifact( command="tar czf - --ignore-failed-read '/data/example_a' > '/tmp/node_scraper_archive_alpha.tar.gz'", stdout="", @@ -102,7 +164,9 @@ def test_collect_path_reads_archive_after_tar(collector, monkeypatch): exit_code=0, ) - collector._run_sut_cmd = MagicMock(side_effect=[exists_result, tar_result, rm_result]) + collector._run_sut_cmd = MagicMock( + side_effect=[exists_result, probe_result, tar_result, rm_result] + ) collector._read_sut_file = MagicMock(return_value=read_result) collector._log_event = MagicMock() @@ -169,6 +233,9 @@ def test_collect_data_not_ran_without_paths(collector): def test_collect_data_reports_partial_failures(collector, monkeypatch): exists_ok = CommandArtifact(command="test -e", stdout="", stderr="", exit_code=0) + probe_ok = CommandArtifact( + command="tar cf - --ignore-failed-read /dev/null", stdout="", stderr="", exit_code=0 + ) ok_tar = CommandArtifact(command="tar", stdout="", stderr="", exit_code=0) fail_tar = CommandArtifact(command="tar", stdout="", stderr="missing", exit_code=2) no_archive = CommandArtifact(command="test -s", stdout="", stderr="", exit_code=1) @@ -176,7 +243,16 @@ def test_collect_data_reports_partial_failures(collector, monkeypatch): archive = BinaryFileArtifact(filename="archive_alpha.tar.gz", contents=b"data") collector._run_sut_cmd = MagicMock( - side_effect=[exists_ok, ok_tar, rm, exists_ok, fail_tar, no_archive, rm] + side_effect=[ + exists_ok, + probe_ok, + ok_tar, + rm, + exists_ok, + fail_tar, + no_archive, + rm, + ] ) collector._read_sut_file = MagicMock(return_value=archive) collector._log_event = MagicMock() From 2d5af0d1397705762476eb2e3709bb17c225aeb9 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Thu, 11 Jun 2026 15:28:34 -0500 Subject: [PATCH 3/3] py3.9 --- .../plugins/ooband/bmc_archive/bmc_archive_collector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py index a5c52c93..547ba80d 100644 --- a/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py +++ b/nodescraper/plugins/ooband/bmc_archive/bmc_archive_collector.py @@ -39,11 +39,11 @@ class BmcArchiveCollector(InBandDataCollector[BmcArchiveDataModel, BmcArchiveCol """Archive BMC directories over SSH using tar czf - .""" DATA_MODEL = BmcArchiveDataModel - SUPPORTED_OS_FAMILY = {OSFamily.LINUX} + SUPPORTED_OS_FAMILY = {OSFamily.LINUX, OSFamily.UNKNOWN} REMOTE_ARCHIVE_TEMPLATE = "/tmp/node_scraper_{name}.tar.gz" # None until first probe in a run; collect_data resets so each collection re-probes. - _tar_ignore_failed_read_supported: bool | None = None + _tar_ignore_failed_read_supported: Optional[bool] = None def _remote_archive_path(self, name: str) -> str: safe_name = "".join(ch if ch.isalnum() or ch in "-_" else "_" for ch in name)