From 55e51de2c110b370c41711c95cd164cc41dd5e7e Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Thu, 22 Jan 2026 08:57:48 -0600 Subject: [PATCH 1/3] updated to use class var --- nodescraper/plugins/inband/journal/journal_collector.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nodescraper/plugins/inband/journal/journal_collector.py b/nodescraper/plugins/inband/journal/journal_collector.py index 6b41dcc1..0a1a995f 100644 --- a/nodescraper/plugins/inband/journal/journal_collector.py +++ b/nodescraper/plugins/inband/journal/journal_collector.py @@ -50,11 +50,11 @@ def _read_with_journalctl(self, args: Optional[JournalCollectorArgs] = None): str|None: system journal read """ - cmd = "journalctl --no-pager --system --output=short-iso" + cmd = self.CMD try: # safe check for args.boot if args is not None and getattr(args, "boot", None): - cmd = f"journalctl --no-pager -b {args.boot} --system --output=short-iso" + cmd = f"{self.CMD} -b {args.boot}" res = self._run_sut_cmd(cmd, sudo=True, log_artifact=False, strip=False) From bb264b11cd918de33166834850a58812da5c0b0f Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Thu, 22 Jan 2026 11:06:49 -0600 Subject: [PATCH 2/3] plugin updates --- .../plugins/inband/journal/analyzer_args.py | 41 +++ .../inband/journal/journal_analyzer.py | 238 ++++++++++++++++++ .../inband/journal/journal_collector.py | 68 ++++- .../plugins/inband/journal/journal_plugin.py | 10 +- .../plugins/inband/journal/journaldata.py | 98 ++++++++ test/unit/plugin/test_journal_collector.py | 163 +++++++++++- 6 files changed, 613 insertions(+), 5 deletions(-) create mode 100644 nodescraper/plugins/inband/journal/analyzer_args.py create mode 100644 nodescraper/plugins/inband/journal/journal_analyzer.py diff --git a/nodescraper/plugins/inband/journal/analyzer_args.py b/nodescraper/plugins/inband/journal/analyzer_args.py new file mode 100644 index 00000000..858b1237 --- /dev/null +++ b/nodescraper/plugins/inband/journal/analyzer_args.py @@ -0,0 +1,41 @@ +############################################################################### +# +# 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.models import TimeRangeAnalysisArgs + + +class JournalAnalyzerArgs(TimeRangeAnalysisArgs): + """Arguments for journal analyzer""" + + check_priority: Optional[int] = None + """Check against journal log priority levels. + emergency(0), alert(1), critical(2), error(3), warning(4), notice(5), info(6), debug(7). + If a journal log entry has a priority level less than or equal to check_priority, + an ERROR event will be raised.""" + + group: bool = True + """Groups entries if they have the same priority and the same message""" diff --git a/nodescraper/plugins/inband/journal/journal_analyzer.py b/nodescraper/plugins/inband/journal/journal_analyzer.py new file mode 100644 index 00000000..fbf14eaf --- /dev/null +++ b/nodescraper/plugins/inband/journal/journal_analyzer.py @@ -0,0 +1,238 @@ +############################################################################### +# +# 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 datetime import datetime +from typing import Optional, TypedDict + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult + +from .analyzer_args import JournalAnalyzerArgs +from .journaldata import JournalData, JournalJsonEntry + + +class JournalEvent(TypedDict): + count: int + first_occurrence: datetime + last_occurrence: datetime + + +class JournalPriority: + EMERGENCY = 0 + ALERT = 1 + CRITICAL = 2 + ERROR = 3 + WARNING = 4 + NOTICE = 5 + INFO = 6 + DEBUG = 7 + + +class JournalAnalyzer(DataAnalyzer[JournalData, JournalAnalyzerArgs]): + """Check journalctl for errors""" + + DATA_MODEL = JournalData + + @classmethod + def filter_journal( + cls, + journal_content_json: list[JournalJsonEntry], + analysis_range_start: Optional[datetime] = None, + analysis_range_end: Optional[datetime] = None, + ) -> list[JournalJsonEntry]: + """Filter a journal log by date + + Args: + journal_content_json (list[JournalJsonEntry]): unfiltered journal log + analysis_range_start (Optional[datetime], optional): start of analysis range. Defaults to None. + analysis_range_end (Optional[datetime], optional): end of analysis range. Defaults to None. + + Returns: + list[JournalJsonEntry]: filtered journal log + """ + + filtered_journal = [] + + found_start = False if analysis_range_start else True + + # Parse through the journal log and filter by date + for entry in journal_content_json: + date = entry.REALTIME_TIMESTAMP + + # Skip entries without valid timestamp + if date is None: + continue + + if analysis_range_start and not found_start and date >= analysis_range_start: + found_start = True + elif analysis_range_end and date >= analysis_range_end: + break + + # only read entries after starting timestamp is found, ignore entries that do not have valid date + if found_start: + filtered_journal.append(entry) + + return filtered_journal + + def _priority_to_entry_priority(self, priority: int) -> EventPriority: + if priority <= JournalPriority.ERROR: + entry_priority = EventPriority.ERROR + elif priority == JournalPriority.WARNING: + entry_priority = EventPriority.WARNING + elif priority >= JournalPriority.NOTICE: + entry_priority = EventPriority.INFO + else: + # Unknown? + entry_priority = EventPriority.ERROR + return entry_priority + + def _analyze_journal_entries_by_priority( + self, journal_content_json: list[JournalJsonEntry], priority: int, group: bool + ) -> None: + """Analyze a list of Journal Entries for a priority. + if WARNING, CRITICAL or it is unknown then log an error/warning Journal Entry. + Parameters + ---------- + journal_content_json : list[JournalJsonEntry] + List of JournalJsonEntry to analyze + priority : int + Priority threshold to check against + group : bool + Whether to group similar entries + """ + # Use a tuple of (message, priority) as the key instead of the JournalJsonEntry object + journal_event_map: dict[tuple[str, int], JournalEvent] = {} + + # Check against journal log priority levels. emergency(0), alert(1), critical(2), error(3), warning(4), notice(5), info(6), debug(7) + for entry in journal_content_json: + if entry.PRIORITY <= priority: + self.result.status = ExecutionStatus.ERROR + if not group: + entry_dict = entry.model_dump() # Convert JournalJsonEntry to dictionary + entry_dict["task_name"] = self.__class__.__name__ + self._log_event( + category=EventCategory.OS, + description="Journal log entry with priority level %s" % entry.PRIORITY, + data=entry_dict, + priority=self._priority_to_entry_priority(entry.PRIORITY), + console_log=False, + ) + else: + # Handle MESSAGE as either string or list + message = entry.MESSAGE + if isinstance(message, list): + message = " ".join(message) + + # Create a tuple key from message and priority + entry_key = (message, entry.PRIORITY) + if journal_event_map.get(entry_key) is None: + journal_event_map[entry_key] = { + "count": 1, + "first_occurrence": ( + entry.REALTIME_TIMESTAMP + if entry.REALTIME_TIMESTAMP + else datetime.fromtimestamp(0) + ), + "last_occurrence": ( + entry.REALTIME_TIMESTAMP + if entry.REALTIME_TIMESTAMP + else datetime.fromtimestamp(0) + ), + } + else: + journal_event_map[entry_key]["count"] += 1 + if entry.REALTIME_TIMESTAMP: + journal_event_map[entry_key][ + "last_occurrence" + ] = entry.REALTIME_TIMESTAMP + + # log all events that were grouped + if group: + for (message, entry_priority), event_data in journal_event_map.items(): + self._log_event( + category=EventCategory.OS, + description="Journal entries found in OS journal log", + priority=self._priority_to_entry_priority(entry_priority), + data={ + "message": message, + "priority": entry_priority, + "count": event_data["count"], + "first_occurrence": event_data["first_occurrence"], + "last_occurrence": event_data["last_occurrence"], + }, + console_log=False, + ) + + def analyze_data( + self, data: JournalData, args: Optional[JournalAnalyzerArgs] = None + ) -> TaskResult: + """Analyze the OS journal log for errors + + Parameters + ---------- + data : JournalData + Journal data to analyze + args : Optional[JournalAnalyzerArgs], optional + Analysis arguments, by default None + + Returns + ------- + TaskResult + A TaskResult object containing the result of the analysis + If journal log entries are found ExecutionStatus.OK + If journal log entries are found with priority level less than or equal to check_priority ExecutionStatus.ERROR + """ + if args is None: + args = JournalAnalyzerArgs() + + journal_content_json = data.journal_content_json + + # Filter by time range if specified + if args.analysis_range_start or args.analysis_range_end: + self.logger.info( + "Filtering journal log using range %s - %s", + args.analysis_range_start, + args.analysis_range_end, + ) + journal_content_json = self.filter_journal( + journal_content_json=journal_content_json, + analysis_range_start=args.analysis_range_start, + analysis_range_end=args.analysis_range_end, + ) + + self.result.status = ExecutionStatus.OK + + if args.check_priority is not None: + self._analyze_journal_entries_by_priority( + journal_content_json, args.check_priority, args.group + ) + + if self.result.status == ExecutionStatus.OK: + self.result.message = "No journal errors found" + else: + self.result.message = f"Found journal entries with priority <= {args.check_priority}" + + return self.result diff --git a/nodescraper/plugins/inband/journal/journal_collector.py b/nodescraper/plugins/inband/journal/journal_collector.py index 0a1a995f..d244eda3 100644 --- a/nodescraper/plugins/inband/journal/journal_collector.py +++ b/nodescraper/plugins/inband/journal/journal_collector.py @@ -23,6 +23,7 @@ # SOFTWARE. # ############################################################################### +import json from typing import Optional from pydantic import ValidationError @@ -33,7 +34,7 @@ from nodescraper.utils import get_exception_details from .collector_args import JournalCollectorArgs -from .journaldata import JournalData +from .journaldata import JournalData, JournalJsonEntry class JournalCollector(InBandDataCollector[JournalData, JournalCollectorArgs]): @@ -42,6 +43,7 @@ class JournalCollector(InBandDataCollector[JournalData, JournalCollectorArgs]): SUPPORTED_OS_FAMILY = {OSFamily.LINUX} DATA_MODEL = JournalData CMD = "journalctl --no-pager --system --output=short-iso" + CMD_JSON = "journalctl --no-pager --system --output=json" def _read_with_journalctl(self, args: Optional[JournalCollectorArgs] = None): """Read journal logs using journalctl @@ -84,6 +86,63 @@ def _read_with_journalctl(self, args: Optional[JournalCollectorArgs] = None): return res.stdout + def _read_with_journalctl_json( + self, args: Optional[JournalCollectorArgs] = None + ) -> Optional[list[JournalJsonEntry]]: + """Read journal logs in JSON format using journalctl + + Returns: + list[JournalJsonEntry]|None: system journal read as JSON entries + """ + + cmd = self.CMD_JSON + try: + # safe check for args.boot + if args is not None and getattr(args, "boot", None): + cmd = f"{self.CMD_JSON} -b {args.boot}" + + res = self._run_sut_cmd(cmd, sudo=True, log_artifact=False, strip=False) + + except ValidationError as val_err: + self._log_event( + category=EventCategory.OS, + description="Exception while running journalctl JSON", + data=get_exception_details(val_err), + priority=EventPriority.ERROR, + console_log=True, + ) + return None + + if res.exit_code != 0: + self._log_event( + category=EventCategory.OS, + description="Error reading journalctl JSON", + data={"command": res.command, "exit_code": res.exit_code}, + priority=EventPriority.ERROR, + console_log=True, + ) + return None + + # Parse JSON entries + json_entries: list[JournalJsonEntry] = [] + for line in res.stdout.splitlines(): + if not line.strip(): + continue + try: + entry_dict = json.loads(line) + json_entries.append(JournalJsonEntry(**entry_dict)) + except (json.JSONDecodeError, ValidationError) as e: + self._log_event( + category=EventCategory.OS, + description="Failed to parse journal JSON entry", + data={"error": str(e), "line": line[:200]}, + priority=EventPriority.WARNING, + console_log=False, + ) + continue + + return json_entries + def collect_data( self, args: Optional[JournalCollectorArgs] = None, @@ -100,8 +159,13 @@ def collect_data( args = JournalCollectorArgs() journal_log = self._read_with_journalctl(args) + journal_json = self._read_with_journalctl_json(args) + if journal_log: - data = JournalData(journal_log=journal_log) + data = JournalData( + journal_log=journal_log, + journal_content_json=journal_json if journal_json else [], + ) self.result.message = self.result.message or "Journal data collected" return self.result, data return self.result, None diff --git a/nodescraper/plugins/inband/journal/journal_plugin.py b/nodescraper/plugins/inband/journal/journal_plugin.py index a3044fbe..320759b2 100644 --- a/nodescraper/plugins/inband/journal/journal_plugin.py +++ b/nodescraper/plugins/inband/journal/journal_plugin.py @@ -25,16 +25,22 @@ ############################################################################### from nodescraper.base import InBandDataPlugin +from .analyzer_args import JournalAnalyzerArgs from .collector_args import JournalCollectorArgs +from .journal_analyzer import JournalAnalyzer from .journal_collector import JournalCollector from .journaldata import JournalData -class JournalPlugin(InBandDataPlugin[JournalData, JournalCollectorArgs, None]): - """Plugin for collection of journal data""" +class JournalPlugin(InBandDataPlugin[JournalData, JournalCollectorArgs, JournalAnalyzerArgs]): + """Plugin for collection and analysis of journal data""" DATA_MODEL = JournalData COLLECTOR = JournalCollector COLLECTOR_ARGS = JournalCollectorArgs + + ANALYZER = JournalAnalyzer + + ANALYZER_ARGS = JournalAnalyzerArgs diff --git a/nodescraper/plugins/inband/journal/journaldata.py b/nodescraper/plugins/inband/journal/journaldata.py index 8c1d06d9..82ee8e70 100644 --- a/nodescraper/plugins/inband/journal/journaldata.py +++ b/nodescraper/plugins/inband/journal/journaldata.py @@ -24,14 +24,112 @@ # ############################################################################### import os +from datetime import datetime +from typing import Optional + +from pydantic import ConfigDict, Field, field_validator from nodescraper.models import DataModel +class JournalJsonEntry(DataModel): + """Data model for journalctl json log entry""" + + model_config = ConfigDict(populate_by_name=True, extra="allow") # allow extra fields + TRANSPORT: Optional[str] = Field(None, alias="_TRANSPORT") + MACHINE_ID: Optional[str] = Field(None, alias="_MACHINE_ID") + HOSTNAME: Optional[str] = Field(None, alias="_HOSTNAME") + SYSLOG_IDENTIFIER: Optional[str] = Field(None, alias="SYSLOG_IDENTIFIER") + CURSOR: Optional[str] = Field(None, alias="__CURSOR") + SYSLOG_FACILITY: Optional[int] = Field(None, alias="SYSLOG_FACILITY") + SOURCE_REALTIME_TIMESTAMP: Optional[datetime] = Field(None, alias="_SOURCE_REALTIME_TIMESTAMP") + REALTIME_TIMESTAMP: Optional[datetime] = Field(None, alias="__REALTIME_TIMESTAMP") + PRIORITY: int = Field(default=7, alias="PRIORITY") # Default to DEBUG (7) if not present + BOOT_ID: Optional[str] = Field(None, alias="_BOOT_ID") + SOURCE_MONOTONIC_TIMESTAMP: Optional[float] = Field(None, alias="_SOURCE_MONOTONIC_TIMESTAMP") + MONOTONIC_TIMESTAMP: Optional[float] = Field(None, alias="__MONOTONIC_TIMESTAMP") + MESSAGE: str | list[str] = Field(default="", alias="MESSAGE") + + # assume datetime has microseconds + @field_validator("SOURCE_REALTIME_TIMESTAMP", mode="before") + @classmethod + def validate_source_realtime_timestamp(cls, v): + if v is None: + return None + try: + return datetime.fromisoformat(v) + except ValueError: + return datetime.fromtimestamp(float(v) / 1e6) + + # assume datetime has microseconds + @field_validator("REALTIME_TIMESTAMP", mode="before") + @classmethod + def validate_realtime_timestamp(cls, v): + if v is None: + return None + try: + return datetime.fromisoformat(v) + except ValueError: + return datetime.fromtimestamp(float(v) / 1e6) + + @field_validator("SOURCE_MONOTONIC_TIMESTAMP", mode="before") + @classmethod + def validate_source_monotonic_timestamp(cls, v): + if v is None: + return None + return float(v) + + @field_validator("MONOTONIC_TIMESTAMP", mode="before") + @classmethod + def validate_monotonic_timestamp(cls, v): + if v is None: + return None + return float(v) + + @field_validator("PRIORITY", mode="before") + @classmethod + def validate_priority(cls, v): + priority_map = { + "EMERG": 0, + "ALERT": 1, + "CRIT": 2, + "ERR": 3, + "WARNING": 4, + "NOTICE": 5, + "INFO": 6, + "DEBUG": 7, + } + if isinstance(v, str): + return priority_map.get(v.upper(), 7) # Default to DEBUG if unknown + return int(v) + + @field_validator("SYSLOG_FACILITY", mode="before") + @classmethod + def validate_syslog_facility(cls, v): + if v is None: + return None + return int(v) + + @field_validator("MESSAGE", mode="before") + @classmethod + def validate_message(cls, v): + """Convert MESSAGE field to string or list[str] based on input type""" + if v is None: + return "" + + # If it's a list but not all items are strings, convert each item to string + if isinstance(v, list): + return [str(item) for item in v] + + # Return string representation for any other type + return str(v) + + class JournalData(DataModel): """Data model for journal logs""" journal_log: str + journal_content_json: list[JournalJsonEntry] = Field(default_factory=list) def log_model(self, log_path: str): """Log data model to a file diff --git a/test/unit/plugin/test_journal_collector.py b/test/unit/plugin/test_journal_collector.py index 6e6ede01..4865baa2 100644 --- a/test/unit/plugin/test_journal_collector.py +++ b/test/unit/plugin/test_journal_collector.py @@ -24,10 +24,14 @@ # ############################################################################### import types +from datetime import datetime +from nodescraper.enums import ExecutionStatus from nodescraper.enums.systeminteraction import SystemInteractionLevel +from nodescraper.plugins.inband.journal.analyzer_args import JournalAnalyzerArgs +from nodescraper.plugins.inband.journal.journal_analyzer import JournalAnalyzer from nodescraper.plugins.inband.journal.journal_collector import JournalCollector -from nodescraper.plugins.inband.journal.journaldata import JournalData +from nodescraper.plugins.inband.journal.journaldata import JournalData, JournalJsonEntry class DummyRes: @@ -69,3 +73,160 @@ def run_map(cmd, **kwargs): assert isinstance(data, JournalData) assert data.journal_log == '{"MESSAGE":"hello"}\n' + + +def test_journal_filter(): + """Test filtering journal entries based on timestamp range.""" + journal_data = [ + { + "TRANSPORT": "kernel", + "MACHINE_ID": "dummy-machine-id-123456789abcdef", + "HOSTNAME": "test-hostname-001", + "SYSLOG_IDENTIFIER": "kernel", + "CURSOR": "dummy-cursor-s1234;i=1000;b=dummy-boot-abc123;m=100;t=1000;x=dummy-x1", + "SYSLOG_FACILITY": 0, + "SOURCE_REALTIME_TIMESTAMP": None, + "REALTIME_TIMESTAMP": "2025-02-23T21:03:46.685975", + "PRIORITY": 1, + "BOOT_ID": "dummy-boot-id-aabbccdd11223344", + "SOURCE_MONOTONIC_TIMESTAMP": 0.0, + "MONOTONIC_TIMESTAMP": 11418011.0, + "MESSAGE": "Test kernel message - alert level", + }, + { + "TRANSPORT": "kernel", + "MACHINE_ID": "dummy-machine-id-123456789abcdef", + "HOSTNAME": "test-hostname-001", + "SYSLOG_IDENTIFIER": "kernel", + "CURSOR": "dummy-cursor-s1234;i=1001;b=dummy-boot-abc123;m=101;t=1001;x=dummy-x2", + "SYSLOG_FACILITY": 0, + "SOURCE_REALTIME_TIMESTAMP": None, + "REALTIME_TIMESTAMP": "2025-02-23T21:03:46.686007", + "PRIORITY": 5, + "BOOT_ID": "dummy-boot-id-aabbccdd11223344", + "SOURCE_MONOTONIC_TIMESTAMP": 0.0, + "MONOTONIC_TIMESTAMP": 11418043.0, + "MESSAGE": "Test kernel message - notice level", + }, + { + "TRANSPORT": "kernel", + "MACHINE_ID": "dummy-machine-id-123456789abcdef", + "HOSTNAME": "test-hostname-001", + "SYSLOG_IDENTIFIER": "kernel", + "CURSOR": "dummy-cursor-s1234;i=1002;b=dummy-boot-abc123;m=102;t=1002;x=dummy-x3", + "SYSLOG_FACILITY": 0, + "SOURCE_REALTIME_TIMESTAMP": None, + "REALTIME_TIMESTAMP": "2025-02-23T21:03:46.686019", + "PRIORITY": 2, + "BOOT_ID": "dummy-boot-id-aabbccdd11223344", + "SOURCE_MONOTONIC_TIMESTAMP": 0.0, + "MONOTONIC_TIMESTAMP": 11418056.0, + "MESSAGE": "Test kernel message - critical level", + }, + { + "TRANSPORT": "kernel", + "MACHINE_ID": "dummy-machine-id-123456789abcdef", + "HOSTNAME": "test-hostname-001", + "SYSLOG_IDENTIFIER": "kernel", + "CURSOR": "dummy-cursor-s1234;i=1003;b=dummy-boot-abc123;m=103;t=1003;x=dummy-x4", + "SYSLOG_FACILITY": 0, + "SOURCE_REALTIME_TIMESTAMP": None, + "REALTIME_TIMESTAMP": "2025-02-23T21:03:46.686027", + "PRIORITY": 7, + "BOOT_ID": "dummy-boot-id-aabbccdd11223344", + "SOURCE_MONOTONIC_TIMESTAMP": 0.0, + "MONOTONIC_TIMESTAMP": 11418064.0, + "MESSAGE": "Test kernel message - debug level", + }, + ] + + journal_content_json = [JournalJsonEntry(**entry) for entry in journal_data] + + start_range = datetime.fromisoformat("2025-02-23T21:03:46.686006") + end_range = datetime.fromisoformat("2025-02-23T21:03:46.686020") + + filtered_journal = JournalAnalyzer.filter_journal(journal_content_json, start_range, end_range) + assert filtered_journal == [JournalJsonEntry(**entry) for entry in journal_data[1:3]] + + filtered_journal = JournalAnalyzer.filter_journal(journal_content_json, start_range, None) + assert filtered_journal == [JournalJsonEntry(**entry) for entry in journal_data[1:]] + + filtered_journal = JournalAnalyzer.filter_journal(journal_content_json, None, end_range) + assert filtered_journal == [JournalJsonEntry(**entry) for entry in journal_data[:3]] + + +def test_check_priority(system_info): + """Test checking priority of journal entries.""" + journal_data_dict = { + "journal_log": "", + "journal_content_json": [ + { + "TRANSPORT": "kernel", + "MACHINE_ID": "dummy-machine-id-123456789abcdef", + "HOSTNAME": "test-hostname-002", + "SYSLOG_IDENTIFIER": "kernel", + "CURSOR": "dummy-cursor-s2000;i=2000;b=dummy-boot-def456;m=200;t=2000;x=dummy-y1", + "SYSLOG_FACILITY": 0, + "SOURCE_REALTIME_TIMESTAMP": None, + "REALTIME_TIMESTAMP": "2025-02-23T21:03:46.685975", + "PRIORITY": 1, + "BOOT_ID": "dummy-boot-id-11223344aabbccdd", + "SOURCE_MONOTONIC_TIMESTAMP": 0.0, + "MONOTONIC_TIMESTAMP": 11418011.0, + "MESSAGE": "Test system alert message", + }, + { + "TRANSPORT": "kernel", + "MACHINE_ID": "dummy-machine-id-123456789abcdef", + "HOSTNAME": "test-hostname-002", + "SYSLOG_IDENTIFIER": "kernel", + "CURSOR": "dummy-cursor-s2000;i=2001;b=dummy-boot-def456;m=201;t=2001;x=dummy-y2", + "SYSLOG_FACILITY": 0, + "SOURCE_REALTIME_TIMESTAMP": None, + "REALTIME_TIMESTAMP": "2025-02-23T21:03:46.686007", + "PRIORITY": 5, + "BOOT_ID": "dummy-boot-id-11223344aabbccdd", + "SOURCE_MONOTONIC_TIMESTAMP": 0.0, + "MONOTONIC_TIMESTAMP": 11418043.0, + "MESSAGE": "Test notice level message", + }, + { + "TRANSPORT": "kernel", + "MACHINE_ID": "dummy-machine-id-123456789abcdef", + "HOSTNAME": "test-hostname-002", + "SYSLOG_IDENTIFIER": "kernel", + "CURSOR": "dummy-cursor-s2000;i=2002;b=dummy-boot-def456;m=202;t=2002;x=dummy-y3", + "SYSLOG_FACILITY": 0, + "SOURCE_REALTIME_TIMESTAMP": None, + "REALTIME_TIMESTAMP": "2025-02-23T21:03:46.686019", + "PRIORITY": 2, + "BOOT_ID": "dummy-boot-id-11223344aabbccdd", + "SOURCE_MONOTONIC_TIMESTAMP": 0.0, + "MONOTONIC_TIMESTAMP": 11418056.0, + "MESSAGE": "Test critical level message", + }, + { + "TRANSPORT": "kernel", + "MACHINE_ID": "dummy-machine-id-123456789abcdef", + "HOSTNAME": "test-hostname-002", + "SYSLOG_IDENTIFIER": "kernel", + "CURSOR": "dummy-cursor-s2000;i=2003;b=dummy-boot-def456;m=203;t=2003;x=dummy-y4", + "SYSLOG_FACILITY": 0, + "SOURCE_REALTIME_TIMESTAMP": None, + "REALTIME_TIMESTAMP": "2025-02-23T21:03:46.686027", + "PRIORITY": 7, + "BOOT_ID": "dummy-boot-id-11223344aabbccdd", + "SOURCE_MONOTONIC_TIMESTAMP": 0.0, + "MONOTONIC_TIMESTAMP": 11418064.0, + "MESSAGE": "Test debug level message", + }, + ], + } + + journal_data = JournalData(**journal_data_dict) + analyzer = JournalAnalyzer(system_info=system_info) + args = JournalAnalyzerArgs(check_priority=5) + res = analyzer.analyze_data(data=journal_data, args=args) + + assert res.status == ExecutionStatus.ERROR + assert len(res.events) == 3 From 4ce0d8e4c7a16f5d694b30c770bf33ea35643c45 Mon Sep 17 00:00:00 2001 From: Alexandra Bara Date: Thu, 22 Jan 2026 11:15:03 -0600 Subject: [PATCH 3/3] fix for py3.9 --- nodescraper/plugins/inband/journal/journaldata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/nodescraper/plugins/inband/journal/journaldata.py b/nodescraper/plugins/inband/journal/journaldata.py index 82ee8e70..ca218b11 100644 --- a/nodescraper/plugins/inband/journal/journaldata.py +++ b/nodescraper/plugins/inband/journal/journaldata.py @@ -25,7 +25,7 @@ ############################################################################### import os from datetime import datetime -from typing import Optional +from typing import Optional, Union from pydantic import ConfigDict, Field, field_validator @@ -48,7 +48,7 @@ class JournalJsonEntry(DataModel): BOOT_ID: Optional[str] = Field(None, alias="_BOOT_ID") SOURCE_MONOTONIC_TIMESTAMP: Optional[float] = Field(None, alias="_SOURCE_MONOTONIC_TIMESTAMP") MONOTONIC_TIMESTAMP: Optional[float] = Field(None, alias="__MONOTONIC_TIMESTAMP") - MESSAGE: str | list[str] = Field(default="", alias="MESSAGE") + MESSAGE: Union[str, list[str]] = Field(default="", alias="MESSAGE") # assume datetime has microseconds @field_validator("SOURCE_REALTIME_TIMESTAMP", mode="before")