From c788fbc5027d22e4d870d79482e4988b81b320ad Mon Sep 17 00:00:00 2001 From: Anson Yim Date: Thu, 29 Oct 2026 16:55:56 -0400 Subject: [PATCH] Add RedfishEventLogPlugin for Redfish LogService entries Collects /Entries with $skip pagination (stops at Members@odata.count, on a page with no new entries, or at max_pages) because some services repeat the same Members@odata.nextLink. Entries are stored newest first by Created; each page is saved as an artifact. Optional LogService.ClearLog after collection (INTERACTIVE only). The analyzer reports Critical entries as ERROR and Warning entries as WARNING events, collapsed by Severity and MessageId with a count, and supports since and MessageId include/exclude filters. --- README.md | 19 ++ .../ooband/redfish_event_log/__init__.py | 24 ++ .../ooband/redfish_event_log/analyzer_args.py | 69 ++++++ .../redfish_event_log/collector_args.py | 57 +++++ .../redfish_event_log/event_log_analyzer.py | 119 +++++++++ .../redfish_event_log/event_log_collector.py | 227 ++++++++++++++++++ .../redfish_event_log/event_log_data.py | 104 ++++++++ .../redfish_event_log/event_log_plugin.py | 64 +++++ pyproject.toml | 2 + .../plugin/test_redfish_event_log_analyzer.py | 170 +++++++++++++ .../test_redfish_event_log_collector.py | 223 +++++++++++++++++ 11 files changed, 1078 insertions(+) create mode 100644 nodescraper/plugins/ooband/redfish_event_log/__init__.py create mode 100644 nodescraper/plugins/ooband/redfish_event_log/analyzer_args.py create mode 100644 nodescraper/plugins/ooband/redfish_event_log/collector_args.py create mode 100644 nodescraper/plugins/ooband/redfish_event_log/event_log_analyzer.py create mode 100644 nodescraper/plugins/ooband/redfish_event_log/event_log_collector.py create mode 100644 nodescraper/plugins/ooband/redfish_event_log/event_log_data.py create mode 100644 nodescraper/plugins/ooband/redfish_event_log/event_log_plugin.py create mode 100644 test/unit/plugin/test_redfish_event_log_analyzer.py create mode 100644 test/unit/plugin/test_redfish_event_log_collector.py diff --git a/README.md b/README.md index 94702804..4853c70d 100644 --- a/README.md +++ b/README.md @@ -597,6 +597,25 @@ The RedfishEndpointPlugin collects Redfish URIs (GET responses) and optionally r **`analysis_args`** - **`checks`**: Optional. Map of URI to expected values or constraints for analysis. Supports exact match (e.g. `"PowerState": "On"`), `anyOf`, `min`/`max`, etc. +#### **RedfishEventLogPlugin** + +The RedfishEventLogPlugin reads every entry from a Redfish LogService (for example an EventLog) and reports Critical and Warning entries. It requires a Redfish connection config and supports [Redfish multi-target](#redfish-multi-target). Use `AmcRedfishEventLogPlugin` for the AMC EventLog via the SSH proxy. + +The collector reads `/Entries` and pages with `$skip` until `Members@odata.count` is reached. Each page is saved as an artifact, and entries are stored newest first by `Created`. It uses `$skip` instead of `Members@odata.nextLink` because some services return the same `nextLink` on every page. + +The analyzer reports Critical entries as errors and Warning entries as warnings. Entries with the same `Severity` and `MessageId` are reported once, with a count and the newest message. + +**`collection_args`** +- **`log_service_path`**: Redfish path to the LogService. Entries are read from `/Entries`. +- **`max_entries`**: Optional. Keep only the newest N entries. All pages are still read. +- **`max_pages`**: Optional (default `100`). Safety cap on the number of `$skip` pages requested. +- **`clear_logs_after_collection`**: Optional (default `false`). POST `LogService.ClearLog` after a successful collection. + +**`analysis_args`** +- **`since`**: Optional ISO 8601 time. Only entries created at or after it are analyzed. +- **`message_id_include`**: Optional list of regexes. Only entries whose `MessageId` matches one are analyzed. +- **`message_id_exclude`**: Optional list of regexes. Entries whose `MessageId` matches any are skipped. + #### **'summary' sub command** The 'summary' subcommand can be used to combine results from multiple runs of node-scraper to a diff --git a/nodescraper/plugins/ooband/redfish_event_log/__init__.py b/nodescraper/plugins/ooband/redfish_event_log/__init__.py new file mode 100644 index 00000000..cc12c9f7 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_event_log/__init__.py @@ -0,0 +1,24 @@ +############################################################################### +# +# MIT License +# +# Copyright (c) 2026 Advanced Micro Devices, Inc. +# +############################################################################### +from .analyzer_args import RedfishEventLogAnalyzerArgs +from .collector_args import RedfishEventLogCollectorArgs +from .event_log_analyzer import RedfishEventLogAnalyzer +from .event_log_collector import RedfishEventLogCollector +from .event_log_data import EventLogEntry, RedfishEventLogDataModel +from .event_log_plugin import AmcRedfishEventLogPlugin, RedfishEventLogPlugin + +__all__ = [ + "AmcRedfishEventLogPlugin", + "EventLogEntry", + "RedfishEventLogAnalyzer", + "RedfishEventLogAnalyzerArgs", + "RedfishEventLogCollector", + "RedfishEventLogCollectorArgs", + "RedfishEventLogDataModel", + "RedfishEventLogPlugin", +] diff --git a/nodescraper/plugins/ooband/redfish_event_log/analyzer_args.py b/nodescraper/plugins/ooband/redfish_event_log/analyzer_args.py new file mode 100644 index 00000000..7e4d15a7 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_event_log/analyzer_args.py @@ -0,0 +1,69 @@ +############################################################################### +# +# 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 re +from datetime import datetime, timezone +from typing import Optional + +from pydantic import Field, field_validator + +from nodescraper.models import AnalyzerArgs + + +class RedfishEventLogAnalyzerArgs(AnalyzerArgs): + """Analyzer args for Redfish LogService entries.""" + + since: Optional[datetime] = Field( + default=None, + description=( + "Only analyze entries created at or after this ISO 8601 time (naive values are UTC). " + "Entries without a parseable Created time are always analyzed." + ), + ) + message_id_include: Optional[list[str]] = Field( + default=None, + description="If set, only analyze entries whose MessageId matches one of these regexes.", + ) + message_id_exclude: list[str] = Field( + default_factory=list, + description="Skip entries whose MessageId matches any of these regexes.", + ) + + @field_validator("since") + @classmethod + def _since_utc(cls, value: Optional[datetime]) -> Optional[datetime]: + if value is not None and value.tzinfo is None: + return value.replace(tzinfo=timezone.utc) + return value + + @field_validator("message_id_include", "message_id_exclude") + @classmethod + def _valid_patterns(cls, patterns: Optional[list[str]]) -> Optional[list[str]]: + for pattern in patterns or []: + try: + re.compile(pattern) + except re.error as exc: + raise ValueError(f"invalid regex {pattern!r}: {exc}") from exc + return patterns diff --git a/nodescraper/plugins/ooband/redfish_event_log/collector_args.py b/nodescraper/plugins/ooband/redfish_event_log/collector_args.py new file mode 100644 index 00000000..4c2c8e35 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_event_log/collector_args.py @@ -0,0 +1,57 @@ +############################################################################### +# +# 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 + +from nodescraper.models import CollectorArgs + + +class RedfishEventLogCollectorArgs(CollectorArgs): + """Collector args for reading a Redfish LogService's Entries collection.""" + + log_service_path: str = Field( + default="redfish/v1/Systems/system/LogServices/EventLog", + description="Redfish path to the LogService; entries are read from /Entries.", + ) + max_entries: Optional[int] = Field( + default=None, + ge=1, + description="Keep only the newest N entries (by Created). All pages are still read.", + ) + max_pages: int = Field( + default=100, + ge=1, + le=10000, + description="Safety cap on the number of $skip pages requested.", + ) + clear_logs_after_collection: bool = Field( + default=False, + description=( + "If True, POST LogService.ClearLog to log_service_path after a successful " + "collection. Requires system_interaction_level >= INTERACTIVE." + ), + ) diff --git a/nodescraper/plugins/ooband/redfish_event_log/event_log_analyzer.py b/nodescraper/plugins/ooband/redfish_event_log/event_log_analyzer.py new file mode 100644 index 00000000..3d3d071e --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_event_log/event_log_analyzer.py @@ -0,0 +1,119 @@ +############################################################################### +# +# 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 re +from typing import Optional + +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.interfaces import DataAnalyzer +from nodescraper.models import TaskResult + +from .analyzer_args import RedfishEventLogAnalyzerArgs +from .event_log_data import EventLogEntry, RedfishEventLogDataModel, parse_created + +SEVERITY_PRIORITY: dict[str, EventPriority] = { + "critical": EventPriority.ERROR, + "warning": EventPriority.WARNING, +} +MAX_IDS_PER_EVENT = 20 + + +def _selected(entry: EventLogEntry, args: RedfishEventLogAnalyzerArgs) -> bool: + if args.since is not None: + created = parse_created(entry.created) + if created is not None and created < args.since: + return False + message_id = entry.message_id or "" + if args.message_id_include is not None and not any( + re.search(p, message_id) for p in args.message_id_include + ): + return False + return not any(re.search(p, message_id) for p in args.message_id_exclude) + + +class RedfishEventLogAnalyzer(DataAnalyzer[RedfishEventLogDataModel, RedfishEventLogAnalyzerArgs]): + """Reports Critical and Warning LogService entries as events.""" + + DATA_MODEL = RedfishEventLogDataModel + + DOCUMENTATION_ANALYSIS_ITEMS: tuple[str, ...] = ( + "Critical entries are reported as ERROR events and Warning entries as WARNING events; " + "OK and other severities are ignored.", + "Entries with the same Severity and MessageId are reported once with a count and the newest message.", + "analysis_args.since, message_id_include and message_id_exclude limit which entries are analyzed.", + ) + + def analyze_data( + self, + data: RedfishEventLogDataModel, + args: Optional[RedfishEventLogAnalyzerArgs] = None, + ) -> TaskResult: + """Report Critical and Warning entries, collapsed by Severity and MessageId.""" + if args is None: + args = RedfishEventLogAnalyzerArgs() + + selected = [e for e in data.entries if _selected(e, args)] + groups: dict[tuple[str, str], list[EventLogEntry]] = {} + for entry in selected: + severity = (entry.severity or "").lower() + if severity in SEVERITY_PRIORITY: + key = (severity, entry.message_id or entry.message or "") + groups.setdefault(key, []).append(entry) + + counts = {severity: 0 for severity in SEVERITY_PRIORITY} + for (severity, _), group in groups.items(): + counts[severity] += len(group) + newest = group[0] + label = newest.message_id or "LogEntry" + repeat = f" (x{len(group)})" if len(group) > 1 else "" + detail = f": {newest.message}" if newest.message else "" + self._log_event( + category=EventCategory.PLATFORM, + description=f"{newest.severity} {label}{repeat}{detail}", + data={ + "log_service_path": data.log_service_path, + "severity": newest.severity, + "message_id": newest.message_id, + "count": len(group), + "newest_created": newest.created, + "oldest_created": group[-1].created, + "message_args": newest.message_args, + "ids": [e.id for e in group[:MAX_IDS_PER_EVENT]], + }, + priority=SEVERITY_PRIORITY[severity], + console_log=True, + ) + + self.result.message = ( + f"EventLog: {counts['critical']} critical, {counts['warning']} warning " + f"in {len(selected)} of {len(data.entries)} entries" + ) + if counts["critical"]: + self.result.status = ExecutionStatus.ERROR + elif counts["warning"]: + self.result.status = ExecutionStatus.WARNING + else: + self.result.status = ExecutionStatus.OK + return self.result diff --git a/nodescraper/plugins/ooband/redfish_event_log/event_log_collector.py b/nodescraper/plugins/ooband/redfish_event_log/event_log_collector.py new file mode 100644 index 00000000..6cfbdb8e --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_event_log/event_log_collector.py @@ -0,0 +1,227 @@ +############################################################################### +# +# 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 json +from datetime import datetime, timezone +from typing import Any, NamedTuple, Optional + +from nodescraper.base import RedfishDataCollector +from nodescraper.connection.redfish.redfish_clear_log import ( + _parse_clear_endpoint, + clear_redfish_logs, +) +from nodescraper.enums import ( + EventCategory, + EventPriority, + ExecutionStatus, + SystemInteractionLevel, +) +from nodescraper.models import TaskResult + +from .collector_args import RedfishEventLogCollectorArgs +from .event_log_data import EventLogEntry, RedfishEventLogDataModel, parse_created + +_OLDEST = datetime.min.replace(tzinfo=timezone.utc) + + +class _EntriesRead(NamedTuple): + members: list[dict[str, Any]] + reported_count: int + error: Optional[str] + complete: bool + + +def _member_key(member: dict[str, Any]) -> str: + key = member.get("@odata.id") or member.get("Id") + return str(key) if key else json.dumps(member, sort_keys=True, default=str) + + +def _reported_count(data: dict[str, Any]) -> int: + try: + return int(data.get("Members@odata.count") or 0) + except (TypeError, ValueError): + return 0 + + +class RedfishEventLogCollector( + RedfishDataCollector[RedfishEventLogDataModel, RedfishEventLogCollectorArgs] +): + """Reads every entry from a Redfish LogService's Entries collection.""" + + DATA_MODEL = RedfishEventLogDataModel + + DOCUMENTATION_COLLECTION_ITEMS: tuple[str, ...] = ( + "Redfish GET of /Entries, paged with $skip until " + "Members@odata.count is reached; each page is saved as an artifact.", + "Entries are sorted newest first by Created; collection_args.max_entries keeps only the newest N.", + "Optional LogService.ClearLog after collection (collection_args.clear_logs_after_collection).", + ) + + def collect_data( + self, args: Optional[RedfishEventLogCollectorArgs] = None + ) -> tuple[TaskResult, Optional[RedfishEventLogDataModel]]: + """Read the LogService entries and return them newest first.""" + if args is None: + args = RedfishEventLogCollectorArgs() + entries_path = f"{args.log_service_path.rstrip('/')}/Entries" + + read = self._read_entries(entries_path, args.max_pages) + if read.error is not None: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Could not read {entries_path}: {read.error}", + priority=EventPriority.ERROR, + console_log=True, + ) + self.result.message = f"EventLog: could not read {entries_path}" + self.result.status = ExecutionStatus.EXECUTION_FAILURE + return self.result, None + + entries = sorted( + (EventLogEntry.from_redfish(m) for m in read.members), + key=lambda e: parse_created(e.created) or _OLDEST, + reverse=True, + ) + if args.max_entries: + entries = entries[: args.max_entries] + total = read.reported_count or len(read.members) + + self.result.message = f"EventLog: {len(entries)} of {total} entries from {entries_path}" + self.result.status = ExecutionStatus.OK + if not read.complete: + self.result.status = ExecutionStatus.WARNING + self.result.message += f" (read {len(read.members)}; pagination stopped early)" + + if args.clear_logs_after_collection: + if self.system_interaction_level >= SystemInteractionLevel.INTERACTIVE: + self._clear_log_service(args.log_service_path) + else: + self.logger.debug( + "Skipping log clear: system_interaction_level is %s (requires INTERACTIVE)", + self.system_interaction_level.name, + ) + + return self.result, RedfishEventLogDataModel( + log_service_path=args.log_service_path, + total_count=total, + entries=entries, + ) + + def _read_entries(self, entries_path: str, max_pages: int) -> _EntriesRead: + """Page through an Entries collection with $skip. + + $skip is used instead of Members@odata.nextLink because some services return the same + nextLink on every page, which never terminates. + + Args: + entries_path: Redfish path of the Entries collection. + max_pages: Maximum number of pages to request. + + Returns: + _EntriesRead: Members read, reported count, first-page error (if any), and whether + every reported entry was read. + """ + members: list[dict[str, Any]] = [] + seen: set[str] = set() + reported = 0 + skip = 0 + for page in range(max_pages): + path = entries_path if skip == 0 else f"{entries_path}?$skip={skip}" + res = self._run_redfish_get(path) + if not res.success or res.data is None: + if page == 0: + return _EntriesRead([], 0, res.error or f"HTTP {res.status_code}", False) + self._log_event( + category=EventCategory.RUNTIME, + description=f"EventLog page {path} failed: {res.error or res.status_code}", + priority=EventPriority.WARNING, + console_log=True, + ) + return _EntriesRead(members, reported, None, False) + + if page == 0: + reported = _reported_count(res.data) + page_members = [m for m in res.data.get("Members") or [] if isinstance(m, dict)] + new = [m for m in page_members if _member_key(m) not in seen] + seen.update(_member_key(m) for m in new) + members.extend(new) + skip += len(page_members) + + if not new: + complete = not reported or len(members) >= reported + if not complete: + self._log_event( + category=EventCategory.RUNTIME, + description=( + f"EventLog {entries_path}: page at $skip={skip - len(page_members)} " + f"returned no new entries ({len(members)} of {reported} read)" + ), + priority=EventPriority.WARNING, + console_log=True, + ) + return _EntriesRead(members, reported, None, complete) + if reported and len(members) >= reported: + return _EntriesRead(members, reported, None, True) + if not reported and "Members@odata.nextLink" not in res.data: + return _EntriesRead(members, reported, None, True) + + self._log_event( + category=EventCategory.RUNTIME, + description=( + f"EventLog {entries_path}: stopped after max_pages={max_pages} " + f"({len(members)} of {reported or '?'} read)" + ), + priority=EventPriority.WARNING, + console_log=True, + ) + return _EntriesRead(members, reported, None, False) + + def _clear_log_service(self, log_service_path: str) -> None: + """POST LogService.ClearLog to the log service if it advertises the action. + + Args: + log_service_path: Redfish path to the LogService. + """ + svc_res = self._run_redfish_get(log_service_path, log_artifact=False) + if not svc_res.success or not svc_res.data: + self.logger.debug("Skipping log clear: could not GET log service %s", log_service_path) + return + endpoint = _parse_clear_endpoint(svc_res.data) + if endpoint is None: + self.logger.debug("LogService %s does not advertise ClearLog action", log_service_path) + return + clear_results = clear_redfish_logs(self.connection, [endpoint], self.logger) + if clear_results and clear_results[0].success: + self.result.message += "; cleared log store" + elif clear_results: + self._log_event( + category=EventCategory.RUNTIME, + description=f"Failed to clear log store {endpoint.path}: {clear_results[0].error}", + priority=EventPriority.WARNING, + console_log=True, + ) + if self.result.status == ExecutionStatus.OK: + self.result.status = ExecutionStatus.WARNING + self.result.message += "; log clear failed" diff --git a/nodescraper/plugins/ooband/redfish_event_log/event_log_data.py b/nodescraper/plugins/ooband/redfish_event_log/event_log_data.py new file mode 100644 index 00000000..0181d5d5 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_event_log/event_log_data.py @@ -0,0 +1,104 @@ +############################################################################### +# +# 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 __future__ import annotations + +from datetime import datetime, timezone +from typing import Any, Optional + +from pydantic import BaseModel, Field + +from nodescraper.models import DataModel + + +def parse_created(value: Optional[str]) -> Optional[datetime]: + """Parse a LogEntry Created timestamp into an aware datetime. + + Args: + value: ISO 8601 timestamp, e.g. 2026-01-01T00:00:00+00:00 or ...Z. + + Returns: + Optional[datetime]: Parsed time (naive values are taken as UTC), or None if unparseable. + """ + if not value: + return None + text = value.strip() + # Python 3.9's fromisoformat does not accept the "Z" suffix. + if text.endswith("Z"): + text = text[:-1] + "+00:00" + try: + parsed = datetime.fromisoformat(text) + except ValueError: + return None + return parsed if parsed.tzinfo else parsed.replace(tzinfo=timezone.utc) + + +class EventLogEntry(BaseModel): + """Subset of a Redfish LogEntry used for analysis.""" + + id: str = "" + odata_id: Optional[str] = None + created: Optional[str] = None + severity: Optional[str] = None + message_id: Optional[str] = None + message: Optional[str] = None + message_args: list[str] = Field(default_factory=list) + entry_type: Optional[str] = None + + @classmethod + def from_redfish(cls, member: dict[str, Any]) -> EventLogEntry: + """Build an entry from a LogEntry JSON object. + + Args: + member: One element of the Entries collection's Members list. + + Returns: + EventLogEntry: Parsed entry; missing properties are left empty. + """ + args = member.get("MessageArgs") or [] + return cls( + id=str(member.get("Id") or ""), + odata_id=member.get("@odata.id"), + created=member.get("Created"), + severity=member.get("Severity"), + message_id=member.get("MessageId"), + message=member.get("Message"), + message_args=[str(a) for a in args] if isinstance(args, list) else [str(args)], + entry_type=member.get("EntryType"), + ) + + +class RedfishEventLogDataModel(DataModel): + """Entries read from one Redfish LogService, newest first.""" + + log_service_path: str + total_count: int = Field( + default=0, + description=( + "Members@odata.count reported on the first page, or the number of entries read " + "when the service omits it." + ), + ) + entries: list[EventLogEntry] = Field(default_factory=list) diff --git a/nodescraper/plugins/ooband/redfish_event_log/event_log_plugin.py b/nodescraper/plugins/ooband/redfish_event_log/event_log_plugin.py new file mode 100644 index 00000000..3cfe5bf7 --- /dev/null +++ b/nodescraper/plugins/ooband/redfish_event_log/event_log_plugin.py @@ -0,0 +1,64 @@ +############################################################################### +# +# 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 OOBandDataPlugin +from nodescraper.connection.redfish import RedfishSshProxyConnectionManager + +from .analyzer_args import RedfishEventLogAnalyzerArgs +from .collector_args import RedfishEventLogCollectorArgs +from .event_log_analyzer import RedfishEventLogAnalyzer +from .event_log_collector import RedfishEventLogCollector +from .event_log_data import RedfishEventLogDataModel + + +class RedfishEventLogPlugin( + OOBandDataPlugin[ + RedfishEventLogDataModel, + RedfishEventLogCollectorArgs, + RedfishEventLogAnalyzerArgs, + ] +): + """Collect a Redfish LogService's entries (e.g. EventLog) and report Critical/Warning entries. + + Uses RedfishConnectionManager. Set collection_args.log_service_path to the LogService to read. + """ + + DATA_MODEL = RedfishEventLogDataModel + COLLECTOR = RedfishEventLogCollector + ANALYZER = RedfishEventLogAnalyzer + COLLECTOR_ARGS = RedfishEventLogCollectorArgs + ANALYZER_ARGS = RedfishEventLogAnalyzerArgs + + +class AmcRedfishEventLogPlugin(RedfishEventLogPlugin): + """Collect AMC Redfish EventLog entries through the BMC SSH proxy. + + Same collection and analysis as RedfishEventLogPlugin; uses + RedfishSshProxyConnectionManager so both plugins can run in the same + node-scraper invocation (BMC EventLog on the direct connection, AMC + EventLog on the proxy). + """ + + CONNECTION_TYPE = RedfishSshProxyConnectionManager # type: ignore[assignment] diff --git a/pyproject.toml b/pyproject.toml index 5f771458..f7a887ad 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -55,6 +55,7 @@ node-scraper = "nodescraper.cli:cli_entry" [project.entry-points."nodescraper.plugins"] AmcRedfishDiagPlugin = "nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_plugin:AmcRedfishDiagPlugin" AmcRedfishEndpointPlugin = "nodescraper.plugins.ooband.redfish_endpoint.endpoint_plugin:AmcRedfishEndpointPlugin" +AmcRedfishEventLogPlugin = "nodescraper.plugins.ooband.redfish_event_log.event_log_plugin:AmcRedfishEventLogPlugin" AmcRedfishInventoryPlugin = "nodescraper.plugins.ooband.redfish_inventory.inventory_plugin:AmcRedfishInventoryPlugin" AmdSmiFlavorPlugin = "nodescraper.plugins.inband.amdsmi.amdsmi_flavor_plugin:AmdSmiFlavorPlugin" AmdSmiPlugin = "nodescraper.plugins.inband.amdsmi.amdsmi_plugin:AmdSmiPlugin" @@ -82,6 +83,7 @@ PciePlugin = "nodescraper.plugins.inband.pcie.pcie_plugin:PciePlugin" ProcessPlugin = "nodescraper.plugins.inband.process.process_plugin:ProcessPlugin" RdmaPlugin = "nodescraper.plugins.inband.rdma.rdma_plugin:RdmaPlugin" RedfishEndpointPlugin = "nodescraper.plugins.ooband.redfish_endpoint.endpoint_plugin:RedfishEndpointPlugin" +RedfishEventLogPlugin = "nodescraper.plugins.ooband.redfish_event_log.event_log_plugin:RedfishEventLogPlugin" RedfishInventoryPlugin = "nodescraper.plugins.ooband.redfish_inventory.inventory_plugin:RedfishInventoryPlugin" RedfishOemDiagPlugin = "nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_plugin:RedfishOemDiagPlugin" RegexSearchPlugin = "nodescraper.plugins.inband.regex_search.regex_search_plugin:RegexSearchPlugin" diff --git a/test/unit/plugin/test_redfish_event_log_analyzer.py b/test/unit/plugin/test_redfish_event_log_analyzer.py new file mode 100644 index 00000000..91dc61af --- /dev/null +++ b/test/unit/plugin/test_redfish_event_log_analyzer.py @@ -0,0 +1,170 @@ +############################################################################### +# +# 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 + +import pytest +from pydantic import ValidationError + +from nodescraper.enums import EventPriority, ExecutionStatus +from nodescraper.plugins.ooband.redfish_event_log import ( + EventLogEntry, + RedfishEventLogAnalyzer, + RedfishEventLogAnalyzerArgs, + RedfishEventLogDataModel, +) + +ERRORS = "ResourceEvent.1.3.ResourceErrorsDetected" +THRESHOLD = "ResourceEvent.1.3.ResourceWarningThresholdExceeded" +STATUS_OK = "ResourceEvent.1.3.ResourceStatusChangedOK" + + +def _entry( + entry_id: int, + severity: Optional[str], + message_id: Optional[str], + day: int = 1, + message: Optional[str] = None, +) -> EventLogEntry: + return EventLogEntry( + id=str(entry_id), + created=f"2026-01-{day:02d}T00:00:00+00:00", + severity=severity, + message_id=message_id, + message=message if message is not None else f"entry {entry_id}", + ) + + +def _analyze(entries: list[EventLogEntry], system_info, **args): + data = RedfishEventLogDataModel( + log_service_path="/redfish/v1/Systems/1/LogServices/EventLog", + total_count=len(entries), + entries=entries, + ) + analyzer = RedfishEventLogAnalyzer(system_info=system_info) + return analyzer.analyze_data(data, RedfishEventLogAnalyzerArgs(**args)) + + +def test_empty_log_is_ok(system_info): + result = _analyze([], system_info) + + assert result.status == ExecutionStatus.OK + assert result.events == [] + + +def test_only_ok_entries_are_ok(system_info): + result = _analyze([_entry(1, "OK", STATUS_OK)], system_info) + + assert result.status == ExecutionStatus.OK + assert result.events == [] + + +def test_critical_and_warning_map_to_error_and_warning(system_info): + entries = [ + _entry(3, "Critical", ERRORS, day=3), + _entry(2, "Warning", THRESHOLD, day=2), + _entry(1, "OK", STATUS_OK, day=1), + ] + + result = _analyze(entries, system_info) + + assert result.status == ExecutionStatus.ERROR + priorities = {e.data["message_id"]: e.priority for e in result.events} + assert priorities == {ERRORS: EventPriority.ERROR, THRESHOLD: EventPriority.WARNING} + assert "1 critical, 1 warning" in result.message + + +def test_warning_only_is_warning(system_info): + result = _analyze([_entry(1, "Warning", THRESHOLD)], system_info) + + assert result.status == ExecutionStatus.WARNING + + +def test_repeats_collapse_with_count_and_newest_message(system_info): + entries = [ + _entry(3, "Critical", ERRORS, day=3, message="newest"), + _entry(2, "Critical", ERRORS, day=2, message="middle"), + _entry(1, "Critical", ERRORS, day=1, message="oldest"), + ] + + result = _analyze(entries, system_info) + + assert len(result.events) == 1 + event = result.events[0] + assert "(x3)" in event.description + assert event.description.endswith(": newest") + assert event.data["count"] == 3 + assert event.data["ids"] == ["3", "2", "1"] + assert event.data["oldest_created"].startswith("2026-01-01") + + +def test_severity_match_is_case_insensitive(system_info): + result = _analyze([_entry(1, "CRITICAL", ERRORS)], system_info) + + assert result.status == ExecutionStatus.ERROR + + +def test_entries_without_message_id_group_by_message(system_info): + entries = [ + _entry(2, "Warning", None, message="fan degraded"), + _entry(1, "Warning", None, message="fan degraded"), + _entry(3, "Warning", None, message="psu degraded"), + ] + + result = _analyze(entries, system_info) + + assert sorted(e.data["count"] for e in result.events) == [1, 2] + assert all("LogEntry" in e.description for e in result.events) + + +def test_since_skips_older_entries_but_keeps_undated(system_info): + entries = [ + _entry(2, "Critical", ERRORS, day=5), + _entry(1, "Critical", THRESHOLD, day=1), + EventLogEntry(id="9", created="unknown", severity="Warning", message_id=THRESHOLD), + ] + + result = _analyze(entries, system_info, since="2026-01-03T00:00:00") + + assert {(e.data["message_id"], e.data["count"]) for e in result.events} == { + (ERRORS, 1), + (THRESHOLD, 1), + } + assert "in 2 of 3 entries" in result.message + + +def test_message_id_include_and_exclude(system_info): + entries = [_entry(2, "Critical", ERRORS), _entry(1, "Warning", THRESHOLD)] + + included = _analyze(entries, system_info, message_id_include=[r"ResourceErrors"]) + excluded = _analyze(entries, system_info, message_id_exclude=[r"Threshold"]) + + assert [e.data["message_id"] for e in included.events] == [ERRORS] + assert [e.data["message_id"] for e in excluded.events] == [ERRORS] + + +def test_invalid_regex_is_rejected(): + with pytest.raises(ValidationError): + RedfishEventLogAnalyzerArgs(message_id_exclude=["("]) diff --git a/test/unit/plugin/test_redfish_event_log_collector.py b/test/unit/plugin/test_redfish_event_log_collector.py new file mode 100644 index 00000000..5b3eaf80 --- /dev/null +++ b/test/unit/plugin/test_redfish_event_log_collector.py @@ -0,0 +1,223 @@ +############################################################################### +# +# 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, timezone +from typing import Any, Optional +from unittest.mock import MagicMock, patch + +import pytest + +from nodescraper.connection.redfish import RedfishGetResult +from nodescraper.connection.redfish.redfish_clear_log import ClearLogResult +from nodescraper.enums import ExecutionStatus, SystemInteractionLevel +from nodescraper.plugins.ooband.redfish_event_log import ( + EventLogEntry, + RedfishEventLogCollector, + RedfishEventLogCollectorArgs, +) +from nodescraper.plugins.ooband.redfish_event_log.event_log_data import parse_created + +SERVICE = "/redfish/v1/Systems/1/LogServices/EventLog" +ENTRIES = f"{SERVICE}/Entries" + + +def _entry(entry_id: int, day: int, severity: str = "OK") -> dict[str, Any]: + return { + "@odata.id": f"{ENTRIES}/{entry_id}", + "Id": str(entry_id), + "Created": f"2026-01-{day:02d}T00:00:00+00:00", + "Severity": severity, + "MessageId": "ResourceEvent.1.3.ResourceStatusChangedOK", + "Message": f"entry {entry_id}", + } + + +def _page(members: list, count: Optional[int] = None, next_link: Optional[str] = None) -> dict: + body: dict[str, Any] = {"Members": members} + if count is not None: + body["Members@odata.count"] = count + if next_link is not None: + body["Members@odata.nextLink"] = next_link + return body + + +def _connection(pages: dict[str, Optional[dict]]) -> MagicMock: + conn = MagicMock() + + def run_get(path: str) -> RedfishGetResult: + body = pages.get(str(path)) + if body is None: + return RedfishGetResult( + path=str(path), success=False, error="not found", status_code=404 + ) + return RedfishGetResult(path=str(path), success=True, data=body, status_code=200) + + conn.run_get.side_effect = run_get + return conn + + +def _collect(pages, system_info, level=SystemInteractionLevel.INTERACTIVE, **args): + conn = _connection(pages) + collector = RedfishEventLogCollector( + system_info=system_info, connection=conn, system_interaction_level=level + ) + result, data = collector.collect_data( + RedfishEventLogCollectorArgs(log_service_path=SERVICE, **args) + ) + requested = [str(c.args[0]) for c in conn.run_get.call_args_list] + return result, data, requested + + +def test_pages_with_skip_and_ignores_static_next_link(system_info): + static_next = f"{ENTRIES}?$skip=2" + pages = { + ENTRIES: _page([_entry(1, 1), _entry(2, 3)], count=3, next_link=static_next), + f"{ENTRIES}?$skip=2": _page([_entry(3, 2)], count=3, next_link=static_next), + } + + result, data, requested = _collect(pages, system_info) + + assert result.status == ExecutionStatus.OK + assert requested == [ENTRIES, f"{ENTRIES}?$skip=2"] + assert [e.id for e in data.entries] == ["2", "3", "1"] + assert data.total_count == 3 + assert len(result.artifacts) == 2 + + +def test_single_page_without_count_or_next_link(system_info): + result, data, requested = _collect({ENTRIES: _page([_entry(1, 1)])}, system_info) + + assert result.status == ExecutionStatus.OK + assert requested == [ENTRIES] + assert data.total_count == 1 + + +def test_empty_log(system_info): + result, data, _ = _collect({ENTRIES: _page([], count=0)}, system_info) + + assert result.status == ExecutionStatus.OK + assert data.entries == [] + + +def test_service_ignoring_skip_stops_with_warning(system_info): + same_page = _page([_entry(1, 1), _entry(2, 2)], count=5) + pages = {ENTRIES: same_page, f"{ENTRIES}?$skip=2": same_page} + + result, data, requested = _collect(pages, system_info) + + assert result.status == ExecutionStatus.WARNING + assert requested == [ENTRIES, f"{ENTRIES}?$skip=2"] + assert len(data.entries) == 2 + assert any("no new entries" in e.description for e in result.events) + + +def test_first_page_failure_returns_no_data(system_info): + result, data, _ = _collect({}, system_info) + + assert result.status == ExecutionStatus.EXECUTION_FAILURE + assert data is None + + +def test_later_page_failure_keeps_partial_data(system_info): + pages = {ENTRIES: _page([_entry(1, 1)], count=2)} + + result, data, _ = _collect(pages, system_info) + + assert result.status == ExecutionStatus.WARNING + assert [e.id for e in data.entries] == ["1"] + assert data.total_count == 2 + + +def test_max_entries_keeps_newest(system_info): + pages = {ENTRIES: _page([_entry(1, 1), _entry(2, 3), _entry(3, 2)], count=3)} + + _, data, _ = _collect(pages, system_info, max_entries=2) + + assert [e.id for e in data.entries] == ["2", "3"] + assert data.total_count == 3 + + +def test_max_pages_cap(system_info): + pages = { + ENTRIES: _page([_entry(1, 1)], count=3), + f"{ENTRIES}?$skip=1": _page([_entry(2, 2)], count=3), + } + + result, data, requested = _collect(pages, system_info, max_pages=2) + + assert result.status == ExecutionStatus.WARNING + assert len(requested) == 2 + assert len(data.entries) == 2 + + +@patch("nodescraper.plugins.ooband.redfish_event_log.event_log_collector.clear_redfish_logs") +def test_clear_log_when_interactive(mock_clear, system_info): + target = f"{SERVICE}/Actions/LogService.ClearLog" + pages = { + ENTRIES: _page([_entry(1, 1)], count=1), + SERVICE: {"Actions": {"#LogService.ClearLog": {"target": target}}}, + } + mock_clear.return_value = [ClearLogResult(path=target, success=True)] + + result, _, _ = _collect(pages, system_info, clear_logs_after_collection=True) + + mock_clear.assert_called_once() + assert mock_clear.call_args.args[1][0].path == target + assert "cleared log store" in result.message + + +@patch("nodescraper.plugins.ooband.redfish_event_log.event_log_collector.clear_redfish_logs") +def test_clear_log_skipped_when_passive(mock_clear, system_info): + pages = {ENTRIES: _page([_entry(1, 1)], count=1)} + + _collect( + pages, + system_info, + level=SystemInteractionLevel.PASSIVE, + clear_logs_after_collection=True, + ) + + mock_clear.assert_not_called() + + +def test_entry_from_redfish_tolerates_missing_and_scalar_fields(): + entry = EventLogEntry.from_redfish({"Id": 7, "MessageArgs": "SN0001"}) + + assert entry.id == "7" + assert entry.message_args == ["SN0001"] + assert entry.severity is None + + +@pytest.mark.parametrize( + "value, expected", + [ + ("2026-01-01T00:00:00Z", datetime(2026, 1, 1, tzinfo=timezone.utc)), + ("2026-01-01T00:00:00", datetime(2026, 1, 1, tzinfo=timezone.utc)), + ("not a time", None), + (None, None), + ], +) +def test_parse_created(value, expected): + assert parse_created(value) == expected