Skip to content

Dmesg log collection #35

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Open
wants to merge 10 commits into
base: development
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
1 change: 1 addition & 0 deletions nodescraper/plugins/inband/dmesg/dmesgdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,7 @@ class DmesgData(DataModel):
"""Data model for in band dmesg log"""

dmesg_content: str
dmesg_logs: list[dict] = None

@classmethod
def get_new_dmesg_lines(cls, current_dmesg: str, new_dmesg: str) -> str:
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"