diff --git a/docs/PLUGIN_DOC.md b/docs/PLUGIN_DOC.md index 5ec01914..731878f0 100644 --- a/docs/PLUGIN_DOC.md +++ b/docs/PLUGIN_DOC.md @@ -42,8 +42,8 @@ | --- | --- | --- | --- | --- | --- | --- | | OobGenericCollectionPlugin | Runs each command from collection_args.commands on the target (in-band host or BMC over OOB SSH).
Commands are user-configured; there are no fixed CMD_\* class fields. | **Analyzer Args:**
- `checks`: list[nodescraper.plugins.generic_collection.analyzer_args.CommandCheck] — Per-command validation rules keyed by collected command name. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `commands`: list[nodescraper.plugins.generic_collection.collector_args.CommandSpec] — Named commands to run. Each entry must include 'name' and 'command'. Prefer small textual stdout; see class docstring...
- `sudo`: bool — Default sudo setting for commands that do not specify sudo.
- `timeout`: int — Default per-command timeout in seconds.
- `include_stdout`: bool — Default: include each command's stdout in collected results for analysis. When false, stdout is omitted from stored r... | [GenericCollectionDataModel](#GenericCollectionDataModel-Model) | [GenericCollectionCollector](#Collector-Class-GenericCollectionCollector) | [GenericAnalyzer](#Data-Analyzer-Class-GenericAnalyzer) | | OobBmcArchivePlugin | SSH (BMC) shell: tar+gzip archives for each path in collection_args (see PathSpec entries).
Uses sudo on the BMC when collection_args paths require elevated access. | - | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `paths`: list[nodescraper.plugins.ooband.bmc_archive.collector_args.PathSpec] — Named BMC paths to archive with tar czf -. Configure in plugin config under plugins.OobBmcArchivePlugin.collection_ar...
- `sudo`: bool — Default sudo setting for paths that do not specify sudo.
- `timeout`: int — Default per-path tar timeout in seconds.
- `skip_if_missing`: bool — Skip paths that do not exist on the BMC instead of failing collection.
- `ignore_failed_read`: bool — When true, pass GNU tar's --ignore-failed-read when the remote tar supports it. | [BmcArchiveDataModel](#BmcArchiveDataModel-Model) | [BmcArchiveCollector](#Collector-Class-BmcArchiveCollector) | - | -| RedfishEndpointPlugin | Redfish GET: explicit paths from collection_args.uris (parallel when max_workers>1).
Optional paged GET following the Members collection OData nextLink field when follow_next_link is true.
Redfish GET tree: when discover_tree is true, walks from api_root using OData resource id links and Members navigation (depth and endpoint caps from collection_args). | For each entry in analysis_args.checks, reads JSON paths in collected responses and compares values to constraints (eq, min/max, anyOf, regex, etc.).
URI key `*` runs checks against every collected response body.
**Analyzer Args:**
- `checks`: dict[str, dict[str, Union[int, float, str, bool, dict[str, Any]]]] — Map: URI or `*` -> { property_path: constraint }. URI keys must match a key in the collected responses (exact match).... | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `uris`: list[str] — Redfish URIs to GET. Ignored when discover_tree is True.
- `discover_tree`: bool — If True, discover endpoints from the BMC Redfish tree (service root and links) instead of using uris.
- `tree_max_depth`: int — When discover_tree is True: max traversal depth (1=service root only, 2=root + collections, 3=+ members).
- `tree_max_endpoints`: int — When discover_tree is True: max endpoints to discover (0=no limit).
- `max_workers`: int — Max concurrent GETs (1=sequential). Use >1 for async endpoint fetches.
- `follow_next_link`: bool — If True, follow Redfish Members collection OData nextLink pagination for each URI and merge all pages into a single r...
- `max_pages`: int — When follow_next_link is True: safety cap on the number of pages to follow per URI (default 200). | [RedfishEndpointDataModel](#RedfishEndpointDataModel-Model) | [RedfishEndpointCollector](#Collector-Class-RedfishEndpointCollector) | [RedfishEndpointAnalyzer](#Data-Analyzer-Class-RedfishEndpointAnalyzer) | -| RedfishOemDiagPlugin | Redfish LogService.CollectDiagnosticData for each entry in collection_args.oem_diagnostic_types (collection_args.log_service_path selects the LogService).
Optional binary archives under the plugin log path when log_path is set. | Summarizes success/failure per OEM diagnostic type from collected results.
When analysis_args.require_all_success is true, fails the run if any type failed collection.
**Analyzer Args:**
- `require_all_success`: bool — If True, analysis fails when any OEM type collection failed. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `log_service_path`: str — Redfish path to the LogService (e.g. DiagLogs).
- `oem_diagnostic_types_allowable`: Optional[list[str]] — Allowable OEM diagnostic types for this architecture/BMC. When set, used for validation and as default for oem_diagno...
- `oem_diagnostic_types`: list[str] — OEM diagnostic types to collect. When empty and oem_diagnostic_types_allowable is set, defaults to that list.
- `task_timeout_s`: int — Max seconds to wait for each BMC task. | [RedfishOemDiagDataModel](#RedfishOemDiagDataModel-Model) | [RedfishOemDiagCollector](#Collector-Class-RedfishOemDiagCollector) | [RedfishOemDiagAnalyzer](#Data-Analyzer-Class-RedfishOemDiagAnalyzer) | +| RedfishEndpointPlugin | Redfish GET: explicit paths from collection_args.uris (parallel when max_workers>1).
Optional paged GET following the Members collection OData nextLink field when follow_next_link is true.
Redfish GET tree: when discover_tree is true, walks from api_root using OData resource id links and Members navigation (depth and endpoint caps from collection_args). | For each entry in analysis_args.checks, reads JSON paths in collected responses and compares values to constraints (eq, min/max, anyOf, regex, etc.).
URI key `*` runs checks against every collected response body.
**Analyzer Args:**
- `checks`: dict[str, dict[str, Union[int, float, str, bool, dict[str, Any]]]] — Map: URI or `*` -> { property_path: constraint }. URI keys must match a key in the collected responses (exact match).... | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `uris`: list[str] — Redfish URIs to GET. Ignored when discover_tree is True.
- `discover_tree`: bool — If True, discover endpoints from the BMC Redfish tree (service root and links) instead of using uris.
- `tree_max_depth`: int — When discover_tree is True: max traversal depth (1=service root only, 2=root + collections, 3=+ members).
- `tree_max_endpoints`: int — When discover_tree is True: max endpoints to discover (0=no limit).
- `max_workers`: int — Max concurrent GETs (1=sequential). Use >1 for async endpoint fetches.
- `follow_next_link`: bool — If True, follow Redfish Members collection OData nextLink pagination for each URI and merge all pages into a single r...
- `max_pages`: int — When follow_next_link is True: safety cap on the number of pages to follow per URI (default 200).
- `clear_log_uris`: list[str] — Redfish LogService.ClearLog action URIs to POST {} to after successful collection. Each URI is posted with an empty J... | [RedfishEndpointDataModel](#RedfishEndpointDataModel-Model) | [RedfishEndpointCollector](#Collector-Class-RedfishEndpointCollector) | [RedfishEndpointAnalyzer](#Data-Analyzer-Class-RedfishEndpointAnalyzer) | +| RedfishOemDiagPlugin | Redfish LogService.CollectDiagnosticData for each entry in collection_args.oem_diagnostic_types (collection_args.log_service_path selects the LogService).
Optional binary archives under the plugin log path when log_path is set. | Summarizes success/failure per OEM diagnostic type from collected results.
When analysis_args.require_all_success is true, fails the run if any type failed collection.
**Analyzer Args:**
- `require_all_success`: bool — If True, analysis fails when any OEM type collection failed. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `log_service_path`: str — Redfish path to the LogService (e.g. DiagLogs).
- `oem_diagnostic_types_allowable`: Optional[list[str]] — Allowable OEM diagnostic types for this architecture/BMC. When set, used for validation and as default for oem_diagno...
- `oem_diagnostic_types`: list[str] — OEM diagnostic types to collect. When empty and oem_diagnostic_types_allowable is set, defaults to that list.
- `task_timeout_s`: int — Max seconds to wait for each BMC task.
- `clear_logs_after_collection`: bool — If True, POST LogService.ClearLog to the configured log_service_path after a successful collection. Requires system_i... | [RedfishOemDiagDataModel](#RedfishOemDiagDataModel-Model) | [RedfishOemDiagCollector](#Collector-Class-RedfishOemDiagCollector) | [RedfishOemDiagAnalyzer](#Data-Analyzer-Class-RedfishOemDiagAnalyzer) | | ServiceabilityPluginMI3XX | Redfish GET: BMC event log Entries (collection_args.rf_event_log_uri; optional uri alias).
Paginated Members collection and optional top, reference_time/time_operator filters.
Redfish GET: CPER AdditionalDataURI binaries for DiagnosticDataType=CPER events (base64 in data model).
Optional chassis Assembly GETs (rf_assembly_uri_template + rf_chassis_devices).
Optional firmware bundle inventory GET (rf_firmware_bundle_uri) for component details. | Builds AFID events from collected Redfish event log members (and optional assembly metadata).
Optionally decodes CPER attachments via analysis_args.cper_decode_module before hub analysis.
Runs the configured Python service hub (hub_python_module) to produce service recommendations.
When analysis_args.skip_hub is true, only builds AFID events without running the hub.
**Analyzer Args:**
- `hub_python_module`: Optional[str] — Import path for the hub module (class implements hub_analyze_method); hub_options forwards kwargs.
- `hub_entry_point`: Optional[str] — Registered hub entry point name when hub_python_module is omitted (required in analysis_args).
- `hub_raise_on_error`: bool — When True, entry-point hub analyze() exceptions raise instead of status:error.
- `hub_prefer_rf_events`: bool — When True and Redfish rf_events are available without decoded CPER data, pass raw rf_events to the entry-point hub fo...
- `hub_display_name`: Optional[str] — Optional label for analyzer status messages.
- `afid_sag_path`: Optional[str] — Path to AFID_SAG.json. When omitted, uses /opt/amd/afid/AFID_SAG.json.
- `hub_init_path_kwarg`: str — Hub __init__ keyword that receives afid_sag_path.
- `hub_analyze_method`: str — Hub method called with rf_events first (default get_service_info).
- `skip_hub`: bool — If True, only build afid_events without running the service hub.
- `cper_decode_module`: Optional[str] — Module import path for CPER decoding when events include CPER attachments.
- `cper_decode_method`: str — Callable on cper_decode_module: file-like CPER in, (return_code, decode_dict) out.
- `hub_options`: Optional[dict[str, Any]] — Extra kwargs for hub __init__ and analyze; collected cper_data overrides cper_data key.
- `from_ac_cycle`: int — from_ac_cycle kwarg for the hub analyze call (merged after hub_options).
- `from_date`: Optional[str] — Optional from_date for the hub analyze call (merged after hub_options).
- `designation_serials`: Optional[dict[str, str]] — Optional designation_serials for the hub analyze call (merged after hub_options).
- `suppress_service_actions`: Optional[list[str]] — Optional suppress_service_actions for the hub analyze call (merged after hub_options). | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `uri`: Optional[str] — Optional alias for ``rf_event_log_uri``. When both ``uri`` and ``rf_event_log_uri`` are explicitly set to non-empty v...
- `rf_event_log_uri`: str — Redfish URI for the event log ``Entries`` collection.
- `rf_chassis_devices`: Optional[List[str]] — Chassis designations for Assembly GETs; required with ``rf_assembly_uri_template``.
- `rf_assembly_uri_template`: Optional[str] — Redfish URI template containing ``{device}`` for each chassis Assembly resource.
- `rf_firmware_bundle_uri`: Optional[str] — Redfish URI for firmware bundle inventory when subclasses extract component details.
- `follow_next_link`: bool — If True, follow Members@odata.nextLink up to max_pages; else single GET.
- `max_pages`: int — Safety cap on the number of pages when following event log pagination.
- `top`: Optional[int] — Most recent N entries via $skip after count probe; None collects full window.
- `reference_time`: Optional[str] — Optional ISO-8601 date or date-time used with time_operator (e.g. 2026-05-17 or 2026-05-17T13:01:00).
- `time_operator`: Optional[Literal['>', '>=', '<', '<=', '==']] — Comparison operator applied when reference_time is set. | [ServiceabilityDataModel](#ServiceabilityDataModel-Model) | [MI3XXCollector](#Collector-Class-MI3XXCollector) | [MI3XXAnalyzer](#Data-Analyzer-Class-MI3XXAnalyzer) | | Mi4xxServiceabilityPlugin | Redfish GET: Instinct accelerator event log Entries (collection_args.rf_event_log_uri).
MI4xx-only pagination: follows Members@odata.nextLink and falls back to $skip when the BMC reports more entries than one page returns.
Paginated Members collection and optional top, reference_time/time_operator filters.
Optional chassis Assembly GETs (rf_assembly_uri_template + rf_chassis_devices).
Optional firmware bundle inventory GET (rf_firmware_bundle_uri) for component details.
Optional AFID_SAG-backed FRU grouping summary when collection_args.afid_sag_path is set. | Builds AFID events from collected Redfish event log members (and optional assembly metadata).
Runs the configured entry-point service hub (hub_entry_point in analysis_args) to produce service recommendations.
When analysis_args.skip_hub is true, only builds AFID events without running the hub.
Supports offline analysis from a prior collection via --data with --collection False.
**Analyzer Args:**
- `hub_python_module`: Optional[str] — Not used for MI4XX; Hub is selected via hub_entry_point afse.
- `hub_entry_point`: str — Registered Hub entry point name (default afse).
- `hub_raise_on_error`: bool — When True, entry-point hub analyze() exceptions raise instead of status:error.
- `hub_prefer_rf_events`: bool — When True and Redfish rf_events are available without decoded CPER data, pass raw rf_events to the entry-point hub fo...
- `hub_display_name`: Optional[str] — Label for analyzer status messages.
- `afid_sag_path`: Optional[str] — Path to AFID_SAG.json. When omitted, uses /opt/amd/afid/AFID_SAG.json.
- `hub_init_path_kwarg`: str — Hub __init__ keyword that receives afid_sag_path.
- `hub_analyze_method`: str — Hub method called with rf_events first (default get_service_info).
- `skip_hub`: bool — If True, only build afid_events without running the service hub.
- `cper_decode_module`: Optional[str] — Module import path for CPER decoding when events include CPER attachments.
- `cper_decode_method`: str — Callable on cper_decode_module: file-like CPER in, (return_code, decode_dict) out.
- `hub_options`: Optional[dict[str, Any]] — Extra kwargs for hub __init__ and analyze; collected cper_data overrides cper_data key.
- `from_ac_cycle`: int — from_ac_cycle kwarg for the hub analyze call (merged after hub_options).
- `from_date`: Optional[str] — Optional from_date for the hub analyze call (merged after hub_options).
- `designation_serials`: Optional[dict[str, str]] — Optional designation_serials for the hub analyze call (merged after hub_options).
- `suppress_service_actions`: Optional[list[str]] — Optional suppress_service_actions for the hub analyze call (merged after hub_options).
- `rf_event_log_uri`: str — Redfish URI for the Instinct accelerator event log Entries collection. | **Collection Args:**
- `html_view`: bool — When true, include logged command artifacts in command_artifacts.html using human-readable output.
- `rf_event_log_uri`: str — Redfish URI for the Instinct accelerator event log Entries collection.
- `follow_next_link`: bool — If True, follow Members@odata.nextLink up to max_pages; else single GET.
- `max_pages`: int — Safety cap on the number of pages when following event log pagination.
- `top`: Optional[int] — Most recent N entries via $skip after count probe; None collects full window.
- `reference_time`: Optional[str] — Optional ISO-8601 date or date-time used with time_operator (e.g. 2026-05-17 or 2026-05-17T13:01:00).
- `time_operator`: Optional[Literal['>', '>=', '<', '<=', '==']] — Comparison operator applied when reference_time is set.
- `rf_assembly_uri_template`: Optional[str] — Optional Redfish URI template containing {device} for chassis Assembly GETs.
- `rf_chassis_devices`: Optional[List[str]] — Optional chassis designations paired with rf_assembly_uri_template.
- `rf_firmware_bundle_uri`: Optional[str] — Optional Redfish URI for firmware bundle inventory.
- `afid_sag_path`: Optional[str] — Optional AFID_SAG.json path for collector FRU grouping logs. When omitted, FRU summary logging is skipped. | [ServiceabilityDataModel](#ServiceabilityDataModel-Model) | [MI4XXCollector](#Collector-Class-MI4XXCollector) | [MI4XXAnalyzer](#Data-Analyzer-Class-MI4XXAnalyzer) | diff --git a/nodescraper/base/redfishcollectortask.py b/nodescraper/base/redfishcollectortask.py index 1e5e7d4d..0da50b92 100644 --- a/nodescraper/base/redfishcollectortask.py +++ b/nodescraper/base/redfishcollectortask.py @@ -35,6 +35,7 @@ RedfishConnection, RedfishGetResult, ) +from nodescraper.connection.redfish.redfish_connection import RedfishPostResult from nodescraper.constants import DEFAULT_EVENT_REPORTER from nodescraper.enums import EventPriority, ExecutionStatus from nodescraper.generictypes import TCollectArg, TDataModel @@ -323,3 +324,53 @@ def _append_redfish_artifact( res.log_html = effective_html_view self.result.artifacts.append(res) return res + + def _run_redfish_post( + self, + path: str, + payload: Optional[dict] = None, + log_artifact: bool = True, + ) -> RedfishPostResult: + """Run a Redfish POST request and return the result. + + Args: + path: Redfish URI path. + payload: JSON payload body. Defaults to an empty dict. + log_artifact: If True, append the result to self.result.artifacts. + + Returns: + RedfishPostResult: path, success, data (or error), status_code. + """ + path_norm = path.strip() + if not path_norm.startswith("/"): + path_norm = "/" + path_norm + try: + resp = self.connection.post(path_norm, json=payload or {}) + success = resp.ok + status_code = resp.status_code + try: + data = resp.json() if resp.content else None + if not isinstance(data, dict): + data = None + except Exception: + data = None + error = ( + None if success else f"POST {path_norm} failed: {resp.status_code} {resp.reason}" + ) + result = RedfishPostResult( + path=path_norm, + success=success, + data=data, + error=error, + status_code=status_code, + ) + except Exception as exc: + result = RedfishPostResult( + path=path_norm, + success=False, + error=str(exc), + status_code=None, + ) + if log_artifact: + self.result.artifacts.append(result) + return result diff --git a/nodescraper/connection/redfish/__init__.py b/nodescraper/connection/redfish/__init__.py index 940837d6..f4b85243 100644 --- a/nodescraper/connection/redfish/__init__.py +++ b/nodescraper/connection/redfish/__init__.py @@ -23,10 +23,17 @@ # SOFTWARE. # ############################################################################### +from .redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + clear_redfish_logs, + discover_clear_log_endpoints, +) from .redfish_connection import ( RedfishConnection, RedfishConnectionError, RedfishGetResult, + RedfishPostResult, ) from .redfish_constants import ( RF_MEMBERS, @@ -54,9 +61,14 @@ from .ssh_proxy_params import RedfishSshProxyConnectionParams __all__ = [ + "ClearLogEndpoint", + "ClearLogResult", + "clear_redfish_logs", + "discover_clear_log_endpoints", "RedfishConnection", "RedfishConnectionError", "RedfishGetResult", + "RedfishPostResult", "MultiTargetRedfishConnection", "RedfishConnectionManager", "collected_multi_target_data", diff --git a/nodescraper/connection/redfish/redfish_clear_log.py b/nodescraper/connection/redfish/redfish_clear_log.py new file mode 100644 index 00000000..51c91bc3 --- /dev/null +++ b/nodescraper/connection/redfish/redfish_clear_log.py @@ -0,0 +1,267 @@ +############################################################################### +# +# 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. +# +############################################################################### +"""Redfish LogService.ClearLog discovery and execution.""" + +from __future__ import annotations + +import logging +from typing import Optional, Sequence + +from pydantic import BaseModel + +from .redfish_connection import RedfishConnection, RedfishConnectionError +from .redfish_constants import RF_MEMBERS, RF_ODATA_ID + +_module_logger = logging.getLogger(__name__) + +_ACTION_CLEAR_LOG_KEYS = ("#LogService.ClearLog", "LogService.ClearLog") +_NEVER_OVERWRITE = "NeverOverWrites" + +# Redfish DSP0266 standard LogServices sub-resource segment +_LOG_SERVICES_SEGMENT = "LogServices" + + +class ClearLogEndpoint(BaseModel): + """A discovered Redfish LogService.ClearLog action endpoint.""" + + path: str + overwrite_policy: str = "" + + +class ClearLogResult(BaseModel): + """The result of a single LogService.ClearLog POST.""" + + path: str + success: bool + status_code: Optional[int] = None + error: Optional[str] = None + + +def _odata_id_to_path(odata_id: str, base_url: str) -> str: + """Normalize an @odata.id value to a relative path (no leading slash). + + Args: + odata_id: Raw @odata.id string (relative path or full URL). + base_url: Connection base URL, used to strip the host prefix. + + Returns: + Relative path with no leading slash, or an empty string if invalid. + """ + if not odata_id or not isinstance(odata_id, str): + return "" + s = odata_id.strip() + base = base_url.rstrip("/") + if s.startswith(base + "/"): + s = s[len(base) :] + if s.startswith(("http://", "https://")): + from urllib.parse import urlparse + + s = urlparse(s).path or "/" + return s.lstrip("/") + + +def _parse_clear_endpoint(svc_data: dict) -> Optional[ClearLogEndpoint]: + """Extract a ClearLogEndpoint from a LogService resource body. + + Args: + svc_data: Parsed LogService JSON body. + + Returns: + ClearLogEndpoint if the service advertises ClearLog, else None. + """ + actions = svc_data.get("Actions") or {} + for key in _ACTION_CLEAR_LOG_KEYS: + action = actions.get(key) + if not isinstance(action, dict): + continue + target = action.get("target") or "" + if not target: + continue + return ClearLogEndpoint( + path=target.strip(), + overwrite_policy=svc_data.get("OverWritePolicy", ""), + ) + return None + + +def discover_clear_log_endpoints( + conn: RedfishConnection, + roots: Sequence[str] = ("Systems", "Managers"), + member_ids: Optional[dict[str, list[str]]] = None, + logger: Optional[logging.Logger] = None, +) -> list[ClearLogEndpoint]: + """Walk Redfish Systems/Managers LogServices to find ClearLog action endpoints. + + For each root, either walks the collection or uses explicit member IDs. + Follows the same discovery pattern as ARC's RedfishTool._discover_log_services. + + Args: + conn: Established Redfish connection. + roots: Root collections to search ("Systems", "Managers"). + member_ids: Optional map of root → list of member IDs to use directly + (bypasses the root collection GET). E.g. {"Managers": ["AMC"]}. + logger: Logger instance. + + Returns: + List of discovered ClearLogEndpoint objects. + """ + log = logger or _module_logger + api_root = (getattr(conn, "api_root", None) or "redfish/v1").strip("/") + base_url = getattr(conn, "base_url", "") + endpoints: list[ClearLogEndpoint] = [] + ids_map = member_ids or {} + + for root in roots: + explicit_ids = ids_map.get(root) + if explicit_ids is not None: + members = [{RF_ODATA_ID: f"/{api_root}/{root}/{mid}"} for mid in explicit_ids] + else: + try: + root_data = conn.get_response(f"/{api_root}/{root}") + if not root_data.ok: + log.debug( + "LogService discovery: GET %s/%s returned %s", + api_root, + root, + root_data.status_code, + ) + continue + try: + root_body = root_data.json() + except Exception: + continue + members = (root_body or {}).get(RF_MEMBERS) or [] + except RedfishConnectionError as exc: + log.debug("LogService discovery: failed to GET %s/%s: %s", api_root, root, exc) + continue + + for member in members: + if not isinstance(member, dict): + continue + odata_id = member.get(RF_ODATA_ID, "") + member_path = _odata_id_to_path(odata_id, base_url) + if not member_path: + continue + + ls_path = f"/{member_path}/{_LOG_SERVICES_SEGMENT}" + try: + ls_resp = conn.get_response(ls_path) + if not ls_resp.ok: + continue + try: + ls_body = ls_resp.json() + except Exception: + continue + except RedfishConnectionError: + continue + + for ls_member in (ls_body or {}).get(RF_MEMBERS) or []: + if not isinstance(ls_member, dict): + continue + ls_odata = ls_member.get(RF_ODATA_ID, "") + ls_svc_path = _odata_id_to_path(ls_odata, base_url) + if not ls_svc_path: + continue + + try: + svc_resp = conn.get_response(f"/{ls_svc_path}") + if not svc_resp.ok: + continue + try: + svc_data = svc_resp.json() + except Exception: + continue + except RedfishConnectionError: + continue + + endpoint = _parse_clear_endpoint(svc_data) + if endpoint is not None: + endpoints.append(endpoint) + log.debug( + "Discovered ClearLog endpoint: %s (OverWritePolicy=%s)", + endpoint.path, + endpoint.overwrite_policy, + ) + + if not endpoints: + log.debug("No ClearLog endpoints discovered in %s", list(roots)) + return endpoints + + +def clear_redfish_logs( + conn: RedfishConnection, + endpoints: list[ClearLogEndpoint], + logger: Optional[logging.Logger] = None, +) -> list[ClearLogResult]: + """POST LogService.ClearLog to each discovered endpoint, skipping NeverOverWrites. + + Mirrors ARC's RedfishTool.clear_log_stores: filters out NeverOverWrites + stores and continues on individual failures. + + Args: + conn: Established Redfish connection. + endpoints: Discovered clear-log endpoints from discover_clear_log_endpoints. + logger: Logger instance. + + Returns: + List of ClearLogResult, one per clearable endpoint. Endpoints with + OverWritePolicy=NeverOverWrites are omitted (not attempted). + """ + log = logger or _module_logger + clearable = [e for e in endpoints if e.overwrite_policy != _NEVER_OVERWRITE] + results: list[ClearLogResult] = [] + + for endpoint in clearable: + path = endpoint.path + try: + resp = conn.post(path, json={}) + success = resp.ok + status_code = resp.status_code + if success: + log.debug("Cleared log store: %s (status %s)", path, status_code) + results.append(ClearLogResult(path=path, success=True, status_code=status_code)) + else: + body_snippet = "" + try: + body_snippet = resp.text[:300] if resp.text else "" + except Exception: + pass + error = f"POST {path} returned {status_code}" + if body_snippet: + error = f"{error}: {body_snippet}" + log.warning("Failed to clear log store %s: %s", path, error) + results.append( + ClearLogResult(path=path, success=False, status_code=status_code, error=error) + ) + except RedfishConnectionError as exc: + log.warning("Failed to clear log store %s: %s", path, exc) + results.append(ClearLogResult(path=path, success=False, error=str(exc))) + + skipped = [e for e in endpoints if e.overwrite_policy == _NEVER_OVERWRITE] + for endpoint in skipped: + log.debug("Skipping ClearLog for %s: OverWritePolicy=NeverOverWrites", endpoint.path) + + return results diff --git a/nodescraper/connection/redfish/redfish_connection.py b/nodescraper/connection/redfish/redfish_connection.py index c1023ef9..511f1459 100644 --- a/nodescraper/connection/redfish/redfish_connection.py +++ b/nodescraper/connection/redfish/redfish_connection.py @@ -70,6 +70,30 @@ def to_html_entry(self) -> dict: } +class RedfishPostResult(BaseModel): + """Artifact for the result of a Redfish POST request.""" + + ARTIFACT_LOG_BASENAME: ClassVar[str] = "command_artifacts" + + path: str + success: bool + data: Optional[dict[str, Any]] = None + error: Optional[str] = None + status_code: Optional[int] = None + + def to_html_entry(self) -> dict: + """Return a dict suitable for HTML command artifact rendering.""" + stdout = json.dumps(self.data, indent=2, sort_keys=True) if self.data is not None else "" + stderr = self.error or "" + exit_code = 0 if self.success else (self.status_code if self.status_code is not None else 1) + return { + "command": f"POST {self.path}", + "stdout": stdout, + "stderr": stderr, + "exit_code": exit_code, + } + + class RedfishHttpResponse(Protocol): """Status, headers, and body from a Redfish GET or POST.""" diff --git a/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_collector.py b/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_collector.py index 41397fb2..ddd6018c 100644 --- a/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_collector.py +++ b/nodescraper/plugins/ooband/amc_redfish_diag/amc_diag_collector.py @@ -28,8 +28,18 @@ from nodescraper.base import RedfishDataCollector from nodescraper.connection.redfish import collect_oem_diagnostic_data +from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogResult, + clear_redfish_logs, + discover_clear_log_endpoints, +) from nodescraper.connection.redfish.redfish_constants import RF_MEMBERS, RF_ODATA_ID -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.enums import ( + EventCategory, + EventPriority, + ExecutionStatus, + SystemInteractionLevel, +) from nodescraper.models import TaskResult from nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_data import ( OemDiagTypeResult, @@ -142,8 +152,69 @@ def collect_data( self.result.status = ExecutionStatus.WARNING else: self.result.status = ExecutionStatus.ERROR + + if ( + args.clear_logs_after_collection + and success_count > 0 + and self.system_interaction_level >= SystemInteractionLevel.INTERACTIVE + ): + self._clear_log_stores(args) + elif args.clear_logs_after_collection and success_count == 0: + self.logger.debug("Skipping log clear: no AMC diag collections succeeded") + elif args.clear_logs_after_collection: + self.logger.debug( + "Skipping log clear: system_interaction_level is %s (requires INTERACTIVE)", + self.system_interaction_level.name, + ) + return self.result, RedfishOemDiagDataModel(results=results) + def _clear_log_stores(self, args: "AmcRedfishDiagCollectorArgs") -> None: + """Discover and clear all ClearLog-capable log stores for the configured members. + + Uses explicit member IDs from args when set, mirroring ARC's + RedfishTool._discover_log_services(system_ids=..., manager_ids=...) pattern. + Failures downgrade the overall result to WARNING. + + Args: + args: Collection args carrying member IDs and collection specs. + """ + roots = list({spec.root for spec in args.collections}) + member_ids: dict[str, list[str]] = {} + if args.manager_ids: + member_ids["Managers"] = list(args.manager_ids) + if args.system_ids: + member_ids["Systems"] = list(args.system_ids) + + endpoints = discover_clear_log_endpoints( + self.connection, + roots=roots, + member_ids=member_ids or None, + logger=self.logger, + ) + if not endpoints: + self.logger.debug("No ClearLog endpoints found under roots %s", roots) + return + + clear_results: list[ClearLogResult] = clear_redfish_logs( + self.connection, endpoints, self.logger + ) + cleared = [r for r in clear_results if r.success] + failed = [r for r in clear_results if not r.success] + if cleared: + self.result.message += f"; cleared {len(cleared)} log store(s)" + if failed: + descriptions = "; ".join(f"{r.path}: {r.error}" for r in failed) + self._log_event( + category=EventCategory.RUNTIME, + description=f"Failed to clear {len(failed)} log store(s): {descriptions}", + priority=EventPriority.WARNING, + console_log=True, + ) + if self.result.status == ExecutionStatus.OK: + self.result.status = ExecutionStatus.WARNING + self.result.message += f"; {len(failed)} log store(s) failed to clear" + def _member_paths(self, root: str, args: AmcRedfishDiagCollectorArgs) -> list[str]: """Resolve Systems or Managers member URIs to probe. diff --git a/nodescraper/plugins/ooband/amc_redfish_diag/collector_args.py b/nodescraper/plugins/ooband/amc_redfish_diag/collector_args.py index 1e8dd5e3..751dc32f 100644 --- a/nodescraper/plugins/ooband/amc_redfish_diag/collector_args.py +++ b/nodescraper/plugins/ooband/amc_redfish_diag/collector_args.py @@ -84,3 +84,11 @@ class AmcRedfishDiagCollectorArgs(CollectorArgs): le=3600, description="Max seconds to wait for each CollectDiagnosticData task.", ) + clear_logs_after_collection: bool = Field( + default=False, + description=( + "If True, discover and POST LogService.ClearLog to all log stores under the " + "configured Systems/Managers members after a successful collection. " + "Requires system_interaction_level >= INTERACTIVE." + ), + ) diff --git a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py index 6583075e..2f5a4f47 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/collector_args.py @@ -71,6 +71,15 @@ class RedfishEndpointCollectorArgs(CollectorArgs): description="When follow_next_link is True: safety cap on the number of pages to follow per URI (default 200).", ) + clear_log_uris: list[str] = Field( + default_factory=list, + description=( + "Redfish LogService.ClearLog action URIs to POST {} to after successful collection. " + "Each URI is posted with an empty JSON body. " + "Requires system_interaction_level >= INTERACTIVE." + ), + ) + @field_validator("uris", mode="before") @classmethod def strip_uris(cls, v: list[str]) -> list[str]: diff --git a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py index d76cb33d..1c0f021f 100644 --- a/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py +++ b/nodescraper/plugins/ooband/redfish_endpoint/endpoint_collector.py @@ -35,7 +35,17 @@ RedfishConnection, RedfishGetResult, ) -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + clear_redfish_logs, +) +from nodescraper.enums import ( + EventCategory, + EventPriority, + ExecutionStatus, + SystemInteractionLevel, +) from nodescraper.models import TaskResult from .collector_args import RedfishEndpointCollectorArgs @@ -193,6 +203,7 @@ def collect_data( data = RedfishEndpointDataModel(responses=responses) self.result.message = f"Collected {len(responses)} Redfish endpoint(s) from tree" self.result.status = ExecutionStatus.OK + self._apply_clear_log_uris(args) return self.result, data # 2) URI list: when discover_tree is false/absent and uris are provided @@ -279,4 +290,46 @@ def collect_data( data = RedfishEndpointDataModel(responses=responses) self.result.message = f"Collected {len(responses)} Redfish endpoint(s)" self.result.status = ExecutionStatus.OK + self._apply_clear_log_uris(args) return self.result, data + + def _apply_clear_log_uris(self, args: Optional[RedfishEndpointCollectorArgs]) -> None: + """POST {} to each URI in args.clear_log_uris (LogService.ClearLog endpoints). + + Skips when system_interaction_level < INTERACTIVE. Failures downgrade + the overall result to WARNING. + + Args: + args: Collector args carrying the clear_log_uris list. + """ + if args is None: + return + uris = [u.strip() for u in (args.clear_log_uris or []) if u and u.strip()] + if not uris: + return + if self.system_interaction_level < SystemInteractionLevel.INTERACTIVE: + self.logger.debug( + "Skipping log clear: system_interaction_level is %s (requires INTERACTIVE)", + self.system_interaction_level.name, + ) + return + + endpoints = [ClearLogEndpoint(path=uri) for uri in uris] + clear_results: list[ClearLogResult] = clear_redfish_logs( + self.connection, endpoints, self.logger + ) + cleared = [r for r in clear_results if r.success] + failed = [r for r in clear_results if not r.success] + if cleared: + self.result.message += f"; cleared {len(cleared)} log store(s)" + if failed: + descriptions = "; ".join(f"{r.path}: {r.error}" for r in failed) + self._log_event( + category=EventCategory.RUNTIME, + description=f"Failed to clear {len(failed)} log store(s): {descriptions}", + priority=EventPriority.WARNING, + console_log=True, + ) + if self.result.status == ExecutionStatus.OK: + self.result.status = ExecutionStatus.WARNING + self.result.message += f"; {len(failed)} log store(s) failed to clear" diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/collector_args.py b/nodescraper/plugins/ooband/redfish_oem_diag/collector_args.py index 7eb6bac7..627d5bff 100644 --- a/nodescraper/plugins/ooband/redfish_oem_diag/collector_args.py +++ b/nodescraper/plugins/ooband/redfish_oem_diag/collector_args.py @@ -55,6 +55,13 @@ class RedfishOemDiagCollectorArgs(CollectorArgs): le=3600, description="Max seconds to wait for each BMC task.", ) + clear_logs_after_collection: bool = Field( + default=False, + description=( + "If True, POST LogService.ClearLog to the configured log_service_path after a " + "successful collection. Requires system_interaction_level >= INTERACTIVE." + ), + ) @model_validator(mode="after") def _default_oem_diagnostic_types(self) -> RedfishOemDiagCollectorArgs: diff --git a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py index 3eca21c5..7483cadf 100644 --- a/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py +++ b/nodescraper/plugins/ooband/redfish_oem_diag/oem_diag_collector.py @@ -28,7 +28,16 @@ from nodescraper.base import RedfishDataCollector from nodescraper.connection.redfish import collect_oem_diagnostic_data -from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +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 RedfishOemDiagCollectorArgs @@ -100,4 +109,47 @@ def collect_data( success_count = sum(1 for r in results.values() if r.success) self.result.message = f"OEM diag: {success_count}/{len(results)} types collected" self.result.status = ExecutionStatus.OK if success_count else ExecutionStatus.ERROR + + if ( + args.clear_logs_after_collection + and success_count > 0 + and self.system_interaction_level >= SystemInteractionLevel.INTERACTIVE + ): + self._clear_log_service(args.log_service_path) + elif args.clear_logs_after_collection and success_count == 0: + self.logger.debug("Skipping log clear: no OEM diag types collected successfully") + elif args.clear_logs_after_collection: + self.logger.debug( + "Skipping log clear: system_interaction_level is %s (requires INTERACTIVE)", + self.system_interaction_level.name, + ) + return self.result, RedfishOemDiagDataModel(results=results) + + def _clear_log_service(self, log_service_path: str) -> None: + """POST LogService.ClearLog to the configured log service if it supports 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 and not clear_results[0].success: + 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/test/unit/connection/redfish/test_redfish_clear_log.py b/test/unit/connection/redfish/test_redfish_clear_log.py new file mode 100644 index 00000000..feab74d0 --- /dev/null +++ b/test/unit/connection/redfish/test_redfish_clear_log.py @@ -0,0 +1,373 @@ +############################################################################### +# +# 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 unittest.mock import MagicMock + +from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + _odata_id_to_path, + _parse_clear_endpoint, + clear_redfish_logs, + discover_clear_log_endpoints, +) +from nodescraper.connection.redfish.redfish_connection import RedfishConnectionError + + +def _ok_resp(body: dict) -> MagicMock: + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + resp.json.return_value = body + return resp + + +def _err_resp(status: int = 404) -> MagicMock: + resp = MagicMock() + resp.ok = False + resp.status_code = status + resp.json.return_value = {} + return resp + + +class TestOdataIdToPath: + def test_relative_path(self): + assert _odata_id_to_path("/redfish/v1/Systems", "") == "redfish/v1/Systems" + + def test_strips_leading_slash(self): + assert _odata_id_to_path("/redfish/v1", "") == "redfish/v1" + + def test_full_url_extracts_path(self): + result = _odata_id_to_path("https://bmc/redfish/v1/Systems/1", "") + assert result == "redfish/v1/Systems/1" + + def test_strips_base_url(self): + result = _odata_id_to_path( + "https://bmc/redfish/v1/Systems", + "https://bmc", + ) + assert result == "redfish/v1/Systems" + + def test_empty_returns_empty(self): + assert _odata_id_to_path("", "") == "" + + def test_none_returns_empty(self): + assert _odata_id_to_path(None, "") == "" # type: ignore[arg-type] + + +class TestParseClearEndpoint: + def test_hash_prefixed_action(self): + svc_data = { + "Actions": { + "#LogService.ClearLog": { + "target": "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog" + } + }, + "OverWritePolicy": "WrapsWhenFull", + } + result = _parse_clear_endpoint(svc_data) + assert result is not None + assert ( + result.path + == "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog" + ) + assert result.overwrite_policy == "WrapsWhenFull" + + def test_unprefixed_action(self): + svc_data = { + "Actions": { + "LogService.ClearLog": { + "target": "/redfish/v1/Managers/BMC/LogServices/Log/Actions/LogService.ClearLog" + } + } + } + result = _parse_clear_endpoint(svc_data) + assert result is not None + assert "ClearLog" in result.path + + def test_no_clear_action_returns_none(self): + svc_data = {"Actions": {"#LogService.CollectDiagnosticData": {"target": "/some/path"}}} + assert _parse_clear_endpoint(svc_data) is None + + def test_empty_target_returns_none(self): + svc_data = {"Actions": {"#LogService.ClearLog": {"target": ""}}} + assert _parse_clear_endpoint(svc_data) is None + + def test_missing_actions_returns_none(self): + assert _parse_clear_endpoint({}) is None + + def test_default_overwrite_policy_empty_string(self): + svc_data = {"Actions": {"#LogService.ClearLog": {"target": "/path"}}} + result = _parse_clear_endpoint(svc_data) + assert result is not None + assert result.overwrite_policy == "" + + def test_never_overwrite_policy_preserved(self): + svc_data = { + "Actions": {"#LogService.ClearLog": {"target": "/path"}}, + "OverWritePolicy": "NeverOverWrites", + } + result = _parse_clear_endpoint(svc_data) + assert result is not None + assert result.overwrite_policy == "NeverOverWrites" + + +class TestClearRedfishLogs: + def test_success(self): + conn = MagicMock() + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + conn.post.return_value = resp + + endpoints = [ClearLogEndpoint(path="/path/ClearLog")] + results = clear_redfish_logs(conn, endpoints) + assert len(results) == 1 + assert results[0].success is True + assert results[0].status_code == 200 + conn.post.assert_called_once_with("/path/ClearLog", json={}) + + def test_failure_non_ok_response(self): + conn = MagicMock() + resp = MagicMock() + resp.ok = False + resp.status_code = 500 + conn.post.return_value = resp + + endpoints = [ClearLogEndpoint(path="/path/ClearLog")] + results = clear_redfish_logs(conn, endpoints) + assert len(results) == 1 + assert results[0].success is False + assert results[0].status_code == 500 + assert "500" in results[0].error + + def test_connection_error_returns_failure(self): + conn = MagicMock() + conn.post.side_effect = RedfishConnectionError("connection refused") + + endpoints = [ClearLogEndpoint(path="/path/ClearLog")] + results = clear_redfish_logs(conn, endpoints) + assert len(results) == 1 + assert results[0].success is False + assert "connection refused" in results[0].error + + def test_never_overwrite_skipped(self): + conn = MagicMock() + endpoints = [ClearLogEndpoint(path="/path", overwrite_policy="NeverOverWrites")] + results = clear_redfish_logs(conn, endpoints) + assert results == [] + conn.post.assert_not_called() + + def test_mixed_never_overwrite_and_clearable(self): + conn = MagicMock() + resp = MagicMock() + resp.ok = True + resp.status_code = 204 + conn.post.return_value = resp + + endpoints = [ + ClearLogEndpoint(path="/clear/a"), + ClearLogEndpoint(path="/clear/b", overwrite_policy="NeverOverWrites"), + ] + results = clear_redfish_logs(conn, endpoints) + assert len(results) == 1 + assert results[0].path == "/clear/a" + conn.post.assert_called_once_with("/clear/a", json={}) + + def test_continues_on_first_failure(self): + conn = MagicMock() + + def post_side(path, json): + r = MagicMock() + r.ok = path != "/bad" + r.status_code = 500 if path == "/bad" else 200 + return r + + conn.post.side_effect = post_side + endpoints = [ClearLogEndpoint(path="/bad"), ClearLogEndpoint(path="/good")] + results = clear_redfish_logs(conn, endpoints) + assert len(results) == 2 + assert results[0].success is False + assert results[1].success is True + + def test_empty_endpoints_returns_empty(self): + conn = MagicMock() + assert clear_redfish_logs(conn, []) == [] + conn.post.assert_not_called() + + +class TestDiscoverClearLogEndpoints: + def _make_conn(self, api_root="redfish/v1", base_url="https://bmc"): + conn = MagicMock() + conn.api_root = api_root + conn.base_url = base_url + return conn + + def test_discovers_clear_endpoint(self): + conn = self._make_conn() + + systems_body = {"Members": [{"@odata.id": "/redfish/v1/Systems/UBB"}]} + ls_list_body = {"Members": [{"@odata.id": "/redfish/v1/Systems/UBB/LogServices/DiagLogs"}]} + svc_body = { + "Actions": { + "#LogService.ClearLog": { + "target": "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog" + } + }, + "OverWritePolicy": "WrapsWhenFull", + } + + def get_response(path): + if "Systems" == path.strip("/").split("/")[-1] or path.endswith("/Systems"): + return _ok_resp(systems_body) + if "LogServices" in path and path.endswith("LogServices"): + return _ok_resp(ls_list_body) + if "DiagLogs" in path and "Actions" not in path: + return _ok_resp(svc_body) + return _err_resp() + + conn.get_response.side_effect = get_response + results = discover_clear_log_endpoints(conn, roots=["Systems"]) + assert len(results) == 1 + assert "ClearLog" in results[0].path + assert results[0].overwrite_policy == "WrapsWhenFull" + + def test_skips_member_with_no_log_services(self): + conn = self._make_conn() + + def get_response(path): + if path.endswith("/Systems"): + return _ok_resp({"Members": [{"@odata.id": "/redfish/v1/Systems/UBB"}]}) + if "LogServices" in path: + return _err_resp(404) + return _err_resp() + + conn.get_response.side_effect = get_response + results = discover_clear_log_endpoints(conn, roots=["Systems"]) + assert results == [] + + def test_skips_service_without_clear_action(self): + conn = self._make_conn() + + def get_response(path): + if path.endswith("/Systems"): + return _ok_resp({"Members": [{"@odata.id": "/redfish/v1/Systems/UBB"}]}) + if path.endswith("/LogServices"): + return _ok_resp( + {"Members": [{"@odata.id": "/redfish/v1/Systems/UBB/LogServices/EventLog"}]} + ) + if path.endswith("/EventLog"): + return _ok_resp( + {"Actions": {"#LogService.CollectDiagnosticData": {"target": "/collect"}}} + ) + return _err_resp() + + conn.get_response.side_effect = get_response + results = discover_clear_log_endpoints(conn, roots=["Systems"]) + assert results == [] + + def test_explicit_member_ids_skip_root_get(self): + conn = self._make_conn() + + svc_body = { + "Actions": { + "#LogService.ClearLog": { + "target": "/redfish/v1/Managers/AMC/LogServices/Log/Actions/LogService.ClearLog" + } + } + } + + def get_response(path): + if path.endswith("/LogServices"): + return _ok_resp( + {"Members": [{"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Log"}]} + ) + if path.endswith("/Log"): + return _ok_resp(svc_body) + return _err_resp() + + conn.get_response.side_effect = get_response + results = discover_clear_log_endpoints( + conn, roots=["Managers"], member_ids={"Managers": ["AMC"]} + ) + assert len(results) == 1 + # Root collection GET should never have been called + calls = [str(c) for c in conn.get_response.call_args_list] + assert not any( + "Managers'" == p.strip("'").strip("/").split("/")[-1] + for p in calls + if "LogServices" not in p and "Log" not in p + ) + + def test_connection_error_on_root_is_skipped(self): + conn = self._make_conn() + conn.get_response.side_effect = RedfishConnectionError("timeout") + results = discover_clear_log_endpoints(conn, roots=["Systems"]) + assert results == [] + + def test_non_ok_root_response_is_skipped(self): + conn = self._make_conn() + conn.get_response.return_value = _err_resp(503) + results = discover_clear_log_endpoints(conn, roots=["Systems"]) + assert results == [] + + def test_multiple_roots_combined(self): + conn = self._make_conn() + + clear_target_systems = ( + "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog" + ) + clear_target_managers = ( + "/redfish/v1/Managers/AMC/LogServices/Log/Actions/LogService.ClearLog" + ) + + def get_response(path): + if path.endswith("/redfish/v1/Systems"): + return _ok_resp({"Members": [{"@odata.id": "/redfish/v1/Systems/UBB"}]}) + if path.endswith("/redfish/v1/Managers"): + return _ok_resp({"Members": [{"@odata.id": "/redfish/v1/Managers/AMC"}]}) + if path.rstrip("/") == "/redfish/v1/Systems/UBB/LogServices": + return _ok_resp( + {"Members": [{"@odata.id": "/redfish/v1/Systems/UBB/LogServices/DiagLogs"}]} + ) + if path.rstrip("/") == "/redfish/v1/Managers/AMC/LogServices": + return _ok_resp( + {"Members": [{"@odata.id": "/redfish/v1/Managers/AMC/LogServices/Log"}]} + ) + if path.rstrip("/") == "/redfish/v1/Systems/UBB/LogServices/DiagLogs": + return _ok_resp( + {"Actions": {"#LogService.ClearLog": {"target": clear_target_systems}}} + ) + if path.rstrip("/") == "/redfish/v1/Managers/AMC/LogServices/Log": + return _ok_resp( + {"Actions": {"#LogService.ClearLog": {"target": clear_target_managers}}} + ) + return _err_resp() + + conn.get_response.side_effect = get_response + results = discover_clear_log_endpoints(conn, roots=["Systems", "Managers"]) + assert len(results) == 2 + paths = {r.path for r in results} + assert clear_target_systems in paths + assert clear_target_managers in paths diff --git a/test/unit/plugin/test_amc_redfish_diag.py b/test/unit/plugin/test_amc_redfish_diag.py index 5b5f019a..27bd7fd1 100644 --- a/test/unit/plugin/test_amc_redfish_diag.py +++ b/test/unit/plugin/test_amc_redfish_diag.py @@ -288,3 +288,160 @@ def test_failed_collection_skips_diag_logs_and_writes_events( event_log = (tmp_path / "events.json").read_text(encoding="utf-8") assert "ResourceInUse" in event_log assert "The resource is in use." in event_log + + +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.collect_oem_diagnostic_data") +@patch( + "nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.discover_clear_log_endpoints" +) +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.clear_redfish_logs") +def test_amc_clear_logs_called_after_success( + mock_clear, mock_discover, mock_collect, amc_collector +): + from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + ) + + mock_collect.return_value = (b"archive", {"Id": "1"}, None) + amc_collector.connection.run_get.side_effect = _get_side_effect + endpoint = ClearLogEndpoint( + path="/redfish/v1/Managers/dummy-amc/LogServices/Dump/Actions/LogService.ClearLog" + ) + mock_discover.return_value = [endpoint] + mock_clear.return_value = [ClearLogResult(path=endpoint.path, success=True, status_code=200)] + + result, data = amc_collector.collect_data( + args=AmcRedfishDiagCollectorArgs( + manager_ids=["dummy-amc"], + system_ids=["dummy-system"], + collections=[ + AmcDiagCollectionSpec(root="Managers", diagnostic_data_type="Manager"), + ], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.OK + mock_discover.assert_called_once() + mock_clear.assert_called_once() + + +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.collect_oem_diagnostic_data") +@patch( + "nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.discover_clear_log_endpoints" +) +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.clear_redfish_logs") +def test_amc_clear_logs_not_called_when_all_failed( + mock_clear, mock_discover, mock_collect, amc_collector +): + mock_collect.return_value = (None, None, "Task timeout") + amc_collector.connection.run_get.side_effect = _get_side_effect + + result, data = amc_collector.collect_data( + args=AmcRedfishDiagCollectorArgs( + manager_ids=["dummy-amc"], + collections=[ + AmcDiagCollectionSpec(root="Managers", diagnostic_data_type="Manager"), + ], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.ERROR + mock_discover.assert_not_called() + mock_clear.assert_not_called() + + +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.collect_oem_diagnostic_data") +@patch( + "nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.discover_clear_log_endpoints" +) +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.clear_redfish_logs") +def test_amc_clear_log_failure_downgrades_ok_to_warning( + mock_clear, mock_discover, mock_collect, amc_collector +): + from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + ) + + mock_collect.return_value = (b"archive", {"Id": "1"}, None) + amc_collector.connection.run_get.side_effect = _get_side_effect + endpoint = ClearLogEndpoint(path="/some/ClearLog") + mock_discover.return_value = [endpoint] + mock_clear.return_value = [ + ClearLogResult(path=endpoint.path, success=False, status_code=503, error="Server Error") + ] + + result, data = amc_collector.collect_data( + args=AmcRedfishDiagCollectorArgs( + manager_ids=["dummy-amc"], + collections=[ + AmcDiagCollectionSpec(root="Managers", diagnostic_data_type="Manager"), + ], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.WARNING + assert "log store(s) failed to clear" in result.message + warning_events = [e for e in result.events if e.priority == EventPriority.WARNING] + assert any("Failed to clear" in e.description for e in warning_events) + + +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.collect_oem_diagnostic_data") +@patch( + "nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.discover_clear_log_endpoints" +) +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.clear_redfish_logs") +def test_amc_clear_logs_skipped_when_passive( + mock_clear, mock_discover, mock_collect, system_info, redfish_conn_mock +): + from nodescraper.enums import SystemInteractionLevel + + mock_collect.return_value = (b"archive", {"Id": "1"}, None) + redfish_conn_mock.api_root = "redfish/v1" + redfish_conn_mock.run_get.side_effect = _get_side_effect + collector = AmcRedfishDiagCollector( + system_info=system_info, + connection=redfish_conn_mock, + system_interaction_level=SystemInteractionLevel.PASSIVE, + ) + result, data = collector.collect_data( + args=AmcRedfishDiagCollectorArgs( + manager_ids=["dummy-amc"], + collections=[ + AmcDiagCollectionSpec(root="Managers", diagnostic_data_type="Manager"), + ], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.OK + mock_discover.assert_not_called() + mock_clear.assert_not_called() + + +@patch("nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.collect_oem_diagnostic_data") +@patch( + "nodescraper.plugins.ooband.amc_redfish_diag.amc_diag_collector.discover_clear_log_endpoints" +) +def test_amc_discover_uses_correct_member_ids(mock_discover, mock_collect, amc_collector): + mock_collect.return_value = (b"archive", {"Id": "1"}, None) + amc_collector.connection.run_get.side_effect = _get_side_effect + mock_discover.return_value = [] + + amc_collector.collect_data( + args=AmcRedfishDiagCollectorArgs( + manager_ids=["AMC"], + system_ids=["MI450"], + collections=[ + AmcDiagCollectionSpec(root="Managers", diagnostic_data_type="Manager"), + AmcDiagCollectionSpec( + root="Systems", diagnostic_data_type="OEM", oem_data_type="AllLogs" + ), + ], + clear_logs_after_collection=True, + ) + ) + mock_discover.assert_called_once() + call_kwargs = mock_discover.call_args[1] + assert call_kwargs["member_ids"]["Managers"] == ["AMC"] + assert call_kwargs["member_ids"]["Systems"] == ["MI450"] diff --git a/test/unit/plugin/test_redfish_endpoint_collector.py b/test/unit/plugin/test_redfish_endpoint_collector.py index 501a0d8b..0afe4cb8 100644 --- a/test/unit/plugin/test_redfish_endpoint_collector.py +++ b/test/unit/plugin/test_redfish_endpoint_collector.py @@ -496,3 +496,140 @@ def run_get(path): assert "/redfish/v1" in data.responses or "redfish/v1" in data.responses assert any("Systems" in k for k in data.responses) assert redfish_conn_mock.copy.called + + +@MagicMock +def _ok_post_resp(): + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + resp.content = b"{}" + resp.json.return_value = {} + return resp + + +def test_clear_log_uris_posted_after_successful_collection( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + resp.content = b"{}" + resp.reason = "OK" + redfish_conn_mock.post.return_value = resp + + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1"], + clear_log_uris=[ + "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog" + ], + ) + ) + assert result.status == ExecutionStatus.OK + redfish_conn_mock.post.assert_called_once_with( + "/redfish/v1/Systems/UBB/LogServices/DiagLogs/Actions/LogService.ClearLog", + json={}, + ) + + +def test_clear_log_uris_failure_downgrades_to_warning( + redfish_endpoint_collector, redfish_conn_mock +): + from nodescraper.enums import EventPriority + + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + resp = MagicMock() + resp.ok = False + resp.status_code = 500 + resp.reason = "Internal Server Error" + resp.content = b"" + redfish_conn_mock.post.return_value = resp + + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1"], + clear_log_uris=["/some/ClearLog"], + ) + ) + assert result.status == ExecutionStatus.WARNING + assert "log store(s) failed to clear" in result.message + warning_events = [e for e in result.events if e.priority == EventPriority.WARNING] + assert any("Failed to clear" in e.description for e in warning_events) + + +def test_clear_log_uris_not_posted_when_passive(system_info, redfish_conn_mock): + from nodescraper.enums import SystemInteractionLevel + + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + from nodescraper.plugins.ooband.redfish_endpoint import RedfishEndpointCollector + + collector = RedfishEndpointCollector( + system_info=system_info, + connection=redfish_conn_mock, + system_interaction_level=SystemInteractionLevel.PASSIVE, + ) + result, data = collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1"], + clear_log_uris=["/some/ClearLog"], + ) + ) + assert result.status == ExecutionStatus.OK + redfish_conn_mock.post.assert_not_called() + + +def test_clear_log_uris_empty_list_no_post(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"], clear_log_uris=[]) + ) + assert result.status == ExecutionStatus.OK + redfish_conn_mock.post.assert_not_called() + + +def test_clear_log_uris_not_posted_when_collection_fails( + redfish_endpoint_collector, redfish_conn_mock +): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=False, error="Timeout", status_code=None + ) + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs( + uris=["/redfish/v1"], + clear_log_uris=["/some/ClearLog"], + ) + ) + assert result.status == ExecutionStatus.ERROR + redfish_conn_mock.post.assert_not_called() + + +def test_clear_log_uris_multiple_uris_all_posted(redfish_endpoint_collector, redfish_conn_mock): + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/redfish/v1", success=True, data={"Name": "Root"}, status_code=200 + ) + resp = MagicMock() + resp.ok = True + resp.status_code = 200 + resp.content = b"{}" + resp.reason = "OK" + redfish_conn_mock.post.return_value = resp + + clear_uris = ["/clear/a", "/clear/b"] + result, data = redfish_endpoint_collector.collect_data( + args=RedfishEndpointCollectorArgs(uris=["/redfish/v1"], clear_log_uris=clear_uris) + ) + assert result.status == ExecutionStatus.OK + assert redfish_conn_mock.post.call_count == 2 + posted_paths = {call.args[0] for call in redfish_conn_mock.post.call_args_list} + assert "/clear/a" in posted_paths + assert "/clear/b" in posted_paths diff --git a/test/unit/plugin/test_redfish_oem_diag_collector.py b/test/unit/plugin/test_redfish_oem_diag_collector.py index d41f09de..b28e9b59 100644 --- a/test/unit/plugin/test_redfish_oem_diag_collector.py +++ b/test/unit/plugin/test_redfish_oem_diag_collector.py @@ -140,3 +140,121 @@ def test_redfish_oem_diag_collector_output_dir_is_diag_logs( output_dir = mock_collect.call_args.kwargs["output_dir"] assert output_dir == (tmp_path / "diag_logs").resolve() assert not output_dir.exists() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector._parse_clear_endpoint") +def test_clear_logs_called_after_successful_collection( + mock_parse, mock_clear, mock_collect, redfish_oem_diag_collector, redfish_conn_mock +): + from nodescraper.connection.redfish import RedfishGetResult + from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + ) + + mock_collect.return_value = (b"data", {}, None) + mock_parse.return_value = ClearLogEndpoint(path="/clear/path") + mock_clear.return_value = [ClearLogResult(path="/clear/path", success=True, status_code=200)] + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/svc", success=True, data={"Actions": {}}, status_code=200 + ) + + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.OK + mock_clear.assert_called_once() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +def test_clear_logs_not_called_when_collection_fails( + mock_clear, mock_collect, redfish_oem_diag_collector +): + mock_collect.return_value = (None, None, "Task timeout") + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.ERROR + mock_clear.assert_not_called() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector._parse_clear_endpoint") +def test_clear_log_failure_downgrades_to_warning( + mock_parse, mock_clear, mock_collect, redfish_oem_diag_collector, redfish_conn_mock +): + from nodescraper.connection.redfish import RedfishGetResult + from nodescraper.connection.redfish.redfish_clear_log import ( + ClearLogEndpoint, + ClearLogResult, + ) + from nodescraper.enums import EventPriority + + mock_collect.return_value = (b"data", {}, None) + mock_parse.return_value = ClearLogEndpoint(path="/clear/path") + mock_clear.return_value = [ + ClearLogResult( + path="/clear/path", success=False, status_code=500, error="POST returned 500" + ) + ] + redfish_conn_mock.run_get.return_value = RedfishGetResult( + path="/svc", success=True, data={"Actions": {}}, status_code=200 + ) + + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.WARNING + assert "log clear failed" in result.message + warning_events = [e for e in result.events if e.priority == EventPriority.WARNING] + assert any("Failed to clear" in e.description for e in warning_events) + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +def test_clear_logs_skipped_when_passive_interaction( + mock_clear, mock_collect, system_info, redfish_conn_mock +): + from nodescraper.enums import SystemInteractionLevel + + mock_collect.return_value = (b"data", {}, None) + collector = RedfishOemDiagCollector( + system_info=system_info, + connection=redfish_conn_mock, + system_interaction_level=SystemInteractionLevel.PASSIVE, + ) + result, data = collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=True, + ) + ) + assert result.status == ExecutionStatus.OK + mock_clear.assert_not_called() + + +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.collect_oem_diagnostic_data") +@patch("nodescraper.plugins.ooband.redfish_oem_diag.oem_diag_collector.clear_redfish_logs") +def test_clear_logs_not_called_when_disabled(mock_clear, mock_collect, redfish_oem_diag_collector): + mock_collect.return_value = (b"data", {}, None) + result, data = redfish_oem_diag_collector.collect_data( + args=RedfishOemDiagCollectorArgs( + oem_diagnostic_types=["AllLogs"], + clear_logs_after_collection=False, + ) + ) + assert result.status == ExecutionStatus.OK + mock_clear.assert_not_called()