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()