diff --git a/nodescraper/plugins/inband/dmesg/collector_args.py b/nodescraper/plugins/inband/dmesg/collector_args.py index 4863ad0..a2313c5 100644 --- a/nodescraper/plugins/inband/dmesg/collector_args.py +++ b/nodescraper/plugins/inband/dmesg/collector_args.py @@ -28,4 +28,11 @@ class DmesgCollectorArgs(CollectorArgs): + """Collector args + + Args: + CollectorArgs (CollectorArgs): specific dmesg collector args + """ + + collect_rotated_logs: bool = False skip_sudo: bool = False diff --git a/nodescraper/plugins/inband/dmesg/dmesg_collector.py b/nodescraper/plugins/inband/dmesg/dmesg_collector.py index 9be5979..a06abd1 100644 --- a/nodescraper/plugins/inband/dmesg/dmesg_collector.py +++ b/nodescraper/plugins/inband/dmesg/dmesg_collector.py @@ -23,9 +23,11 @@ # SOFTWARE. # ############################################################################### +import re from typing import Optional from nodescraper.base import InBandDataCollector +from nodescraper.connection.inband import TextFileArtifact from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily from nodescraper.models import TaskResult @@ -42,6 +44,113 @@ class DmesgCollector(InBandDataCollector[DmesgData, DmesgCollectorArgs]): DMESG_CMD = "dmesg --time-format iso -x" + DMESG_LOGS_CMD = ( + r"ls -1 /var/log/dmesg* 2>/dev/null | grep -E '^/var/log/dmesg(\.[0-9]+(\.gz)?)?$' || true" + ) + + def _shell_quote(self, s: str) -> str: + """Single quote fix + + Args: + s (str): path to be converted + + Returns: + str: path to be returned + """ + return "'" + s.replace("'", "'\"'\"'") + "'" + + def _nice_dmesg_name(self, path: str) -> str: + """Map path to filename + + Args: + path (str): file path + + Returns: + str: new local filename + """ + prefix = "rotated_" + base = path.rstrip("/").rsplit("/", 1)[-1] + + if base == "dmesg": + return f"{prefix}dmesg_log.log" + + m = re.fullmatch(r"dmesg\.(\d+)\.gz", base) + if m: + return f"{prefix}dmesg.{m.group(1)}.gz.log" + + m = re.fullmatch(r"dmesg\.(\d+)", base) + if m: + return f"{prefix}dmesg.{m.group(1)}.log" + + middle = base[:-3] if base.endswith(".gz") else base + return f"{prefix}{middle}.log" + + def _collect_dmesg_rotations(self): + """Collect dmesg logs""" + list_res = self._run_sut_cmd(self.DMESG_LOGS_CMD, sudo=True) + paths = [p.strip() for p in (list_res.stdout or "").splitlines() if p.strip()] + if not paths: + self._log_event( + category=EventCategory.OS, + description="No /var/log/dmesg files found (including rotations).", + data={"list_exit_code": list_res.exit_code}, + priority=EventPriority.WARNING, + ) + return 0 + + collected_logs, failed_logs = [], [] + for p in paths: + qp = self._shell_quote(p) + if p.endswith(".gz"): + cmd = f"gzip -dc {qp} 2>/dev/null || zcat {qp} 2>/dev/null" + res = self._run_sut_cmd(cmd, sudo=True, log_artifact=False) + if res.exit_code == 0 and res.stdout is not None: + fname = self._nice_dmesg_name(p) + self.logger.info("Collected dmesg log: %s", fname) + self.result.artifacts.append( + TextFileArtifact(filename=fname, contents=res.stdout) + ) + collected_logs.append( + {"path": p, "as": fname, "bytes": len(res.stdout.encode("utf-8", "ignore"))} + ) + else: + failed_logs.append( + {"path": p, "exit_code": res.exit_code, "stderr": res.stderr, "cmd": cmd} + ) + else: + cmd = f"cat {qp}" + res = self._run_sut_cmd(cmd, sudo=True, log_artifact=False) + if res.exit_code == 0 and res.stdout is not None: + fname = self._nice_dmesg_name(p) + self.logger.info("Collected dmesg log: %s", fname) + self.result.artifacts.append( + TextFileArtifact(filename=fname, contents=res.stdout) + ) + collected_logs.append( + {"path": p, "as": fname, "bytes": len(res.stdout.encode("utf-8", "ignore"))} + ) + else: + failed_logs.append( + {"path": p, "exit_code": res.exit_code, "stderr": res.stderr, "cmd": cmd} + ) + + if collected_logs: + self._log_event( + category=EventCategory.OS, + description="Collected dmesg rotated files", + data={"collected": collected_logs}, + priority=EventPriority.INFO, + ) + self.result.message = self.result.message or "dmesg rotated files collected" + + if failed_logs: + self._log_event( + category=EventCategory.OS, + description="Some dmesg files could not be collected.", + data={"failed": failed_logs}, + priority=EventPriority.WARNING, + ) + def _get_dmesg_content(self) -> str: """run dmesg command on system and return output @@ -79,6 +188,8 @@ def collect_data( return self.result, None dmesg_content = self._get_dmesg_content() + if args.collect_rotated_logs: + self._collect_dmesg_rotations() if dmesg_content: dmesg_data = DmesgData(dmesg_content=dmesg_content) diff --git a/test/unit/plugin/test_dmesg_collector.py b/test/unit/plugin/test_dmesg_collector.py index 58a0bd3..27810ca 100644 --- a/test/unit/plugin/test_dmesg_collector.py +++ b/test/unit/plugin/test_dmesg_collector.py @@ -23,6 +23,8 @@ # SOFTWARE. # ############################################################################### +import types + import pytest from nodescraper.connection.inband.inband import CommandArtifact @@ -141,3 +143,136 @@ def test_data_model(): assert dmesg_data2.dmesg_content == ( "2023-06-01T01:00:00,685236-05:00 test message1\n2023-06-01T02:30:00,685106-05:00 test message2" ) + + +class DummyRes: + def __init__(self, command="", stdout="", exit_code=0, stderr=""): + self.command = command + self.stdout = stdout + self.exit_code = exit_code + self.stderr = stderr + + +def get_collector(monkeypatch, run_map, system_info, conn_mock): + c = DmesgCollector( + system_info=system_info, + system_interaction_level=SystemInteractionLevel.INTERACTIVE, + connection=conn_mock, + ) + c.result = types.SimpleNamespace(artifacts=[], message=None) + c._events = [] + + def _log_event(**kw): + c._events.append(kw) + + def _run_sut_cmd(cmd, *args, **kwargs): + return run_map(cmd, *args, **kwargs) + + monkeypatch.setattr(c, "_log_event", _log_event, raising=True) + monkeypatch.setattr(c, "_run_sut_cmd", _run_sut_cmd, raising=True) + return c + + +def test_collect_rotations_good_path(monkeypatch, system_info, conn_mock): + ls_out = ( + "\n".join( + [ + "/var/log/dmesg_log", + "/var/log/dmesg.1", + "/var/log/dmesg.2.gz", + "/var/log/dmesg.10.gz", + ] + ) + + "\n" + ) + + def run_map(cmd, **kwargs): + if cmd.startswith("ls -1 /var/log/dmesg"): + return DummyRes(command=cmd, stdout=ls_out, exit_code=0) + if cmd.startswith("cat '"): + if "/var/log/dmesg.1'" in cmd: + return DummyRes(command=cmd, stdout="dmesg.1 content\n", exit_code=0) + if "/var/log/dmesg_log'" in cmd: + return DummyRes(command=cmd, stdout="dmesg content\n", exit_code=0) + if "gzip -dc" in cmd and "/var/log/dmesg.2.gz" in cmd: + return DummyRes(command=cmd, stdout="gz2 content\n", exit_code=0) + if "gzip -dc" in cmd and "/var/log/dmesg.10.gz" in cmd: + return DummyRes(command=cmd, stdout="gz10 content\n", exit_code=0) + return DummyRes(command=cmd, stdout="", exit_code=1, stderr="unexpected") + + c = get_collector(monkeypatch, run_map, system_info, conn_mock) + + c._collect_dmesg_rotations() + + names = {a.filename for a in c.result.artifacts} + assert names == { + "rotated_dmesg_log.log", + "rotated_dmesg.1.log", + "rotated_dmesg.2.gz.log", + "rotated_dmesg.10.gz.log", + } + + descs = [e["description"] for e in c._events] + assert "Collected dmesg rotated files" in descs + + +def test_collect_rotations_no_files(monkeypatch, system_info, conn_mock): + def run_map(cmd, **kwargs): + if cmd.startswith("ls -1 /var/log/dmesg"): + return DummyRes(command=cmd, stdout="", exit_code=0) + return DummyRes(command=cmd, stdout="", exit_code=1) + + c = get_collector(monkeypatch, run_map, system_info, conn_mock) + + c._collect_dmesg_rotations() + + assert c.result.artifacts == [] + + events = c._events + assert any( + e["description"].startswith("No /var/log/dmesg files found") + and e["priority"].name == "WARNING" + for e in events + ) + + +def test_collect_rotations_gz_failure(monkeypatch, system_info, conn_mock): + ls_out = "/var/log/dmesg.2.gz\n" + + def run_map(cmd, **kwargs): + if cmd.startswith("ls -1 /var/log/dmesg"): + return DummyRes(command=cmd, stdout=ls_out, exit_code=0) + if "gzip -dc" in cmd and "/var/log/dmesg.2.gz" in cmd: + return DummyRes(command=cmd, stdout="", exit_code=1, stderr="gzip: not found") + return DummyRes(command=cmd, stdout="", exit_code=1) + + c = get_collector(monkeypatch, run_map, system_info, conn_mock) + + c._collect_dmesg_rotations() + + assert c.result.artifacts == [] + + fail_events = [ + e for e in c._events if e["description"] == "Some dmesg files could not be collected." + ] + assert fail_events, "Expected a failure event" + failed = fail_events[-1]["data"]["failed"] + assert any(item["path"].endswith("/var/log/dmesg.2.gz") for item in failed) + + +def test_collect_data_integration(monkeypatch, system_info, conn_mock): + def run_map(cmd, **kwargs): + if cmd == DmesgCollector.DMESG_CMD: + return DummyRes(command=cmd, stdout="DMESG OUTPUT\n", exit_code=0) + if cmd.startswith("ls -1 /var/log/dmesg"): + return DummyRes(command=cmd, stdout="/var/log/dmesg\n", exit_code=0) + if cmd.startswith("cat '") and "/var/log/dmesg'" in cmd: + return DummyRes(command=cmd, stdout="dmesg file content\n", exit_code=0) + return DummyRes(command=cmd, stdout="", exit_code=1) + + c = get_collector(monkeypatch, run_map, system_info, conn_mock) + + result, data = c.collect_data() + + assert isinstance(data, DmesgData) + assert data.dmesg_content == "DMESG OUTPUT\n"