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