Skip to content
7 changes: 7 additions & 0 deletions nodescraper/plugins/inband/dmesg/collector_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
111 changes: 111 additions & 0 deletions nodescraper/plugins/inband/dmesg/dmesg_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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

Expand Down Expand Up @@ -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)
Expand Down
135 changes: 135 additions & 0 deletions test/unit/plugin/test_dmesg_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
# SOFTWARE.
#
###############################################################################
import types

import pytest

from nodescraper.connection.inband.inband import CommandArtifact
Expand Down Expand Up @@ -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"