Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 4 additions & 4 deletions nodescraper/cli/helper.py
Original file line number Diff line number Diff line change
Expand Up @@ -72,14 +72,14 @@ def get_system_info(args: argparse.Namespace) -> SystemInfo:
if args.sys_platform:
system_info.platform = args.sys_platform

if args.sys_location:
conn = getattr(args, "connection_config", None) or {}
location_name = args.sys_location or conn.get("sys_location")
if location_name:
try:
location = getattr(SystemLocation, args.sys_location)
system_info.location = getattr(SystemLocation, str(location_name).upper())
except Exception as e:
raise argparse.ArgumentTypeError("Invalid input for system location") from e

system_info.location = location

return system_info


Expand Down
109 changes: 109 additions & 0 deletions nodescraper/helpers/plugin_execution_target.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,109 @@
###############################################################################
#
# MIT License
#
# Copyright (c) 2026 Advanced Micro Devices, Inc.
#
# Permission is hereby granted, free of charge, to any person obtaining a copy
# of this software and associated documentation files (the "Software"), to deal
# in the Software without restriction, including without limitation the rights
# to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
# copies of the Software, and to permit persons to whom the Software is
# furnished to do so, subject to the following conditions:
#
# The above copyright notice and this permission notice shall be included in all
# copies or substantial portions of the Software.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
# AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
# LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
# OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
# SOFTWARE.
#
###############################################################################
from __future__ import annotations

from typing import Any, Optional, Union

from pydantic import BaseModel

from nodescraper.connection.inband.inbandmanager import InBandConnectionManager
from nodescraper.connection.redfish.redfish_manager import RedfishConnectionManager
from nodescraper.enums import SystemLocation
from nodescraper.interfaces import ConnectionManager, PluginInterface
from nodescraper.models import SystemInfo


def _host_from_connection_args(raw: Optional[Union[dict[str, Any], BaseModel]]) -> Optional[str]:
if raw is None:
return None
if isinstance(raw, dict):
for key in ("host", "hostname", "ip"):
value = raw.get(key)
if value:
return str(value)
return None
for key in ("host", "hostname", "ip"):
value = getattr(raw, key, None)
if value is not None and str(value):
return str(value)
return None


def format_in_band_target_summary(
system_info: SystemInfo,
connection_configs: Optional[dict[str, Union[dict[str, Any], BaseModel]]] = None,
) -> str:
"""Return a short summary of the default in-band execution target."""
if system_info.location == SystemLocation.REMOTE:
host = _host_from_connection_args((connection_configs or {}).get("InBandConnectionManager"))
if host:
return f"In-band default: remote host via SSH ({host})"
return "In-band default: remote host via SSH"
host_name = system_info.name or "local host"
return f"In-band default: local host ({host_name})"


def format_plugin_execution_target(
plugin_class: type[PluginInterface],
*,
system_info: SystemInfo,
connection_manager: Optional[ConnectionManager] = None,
connection_configs: Optional[dict[str, Union[dict[str, Any], BaseModel]]] = None,
) -> Optional[str]:
"""Return a one-line description of where a plugin collects data from."""
connection_type = getattr(plugin_class, "CONNECTION_TYPE", None)
if connection_type is None:
return None

configs = connection_configs or {}
manager_args = (
getattr(connection_manager, "connection_args", None) if connection_manager else None
)

if connection_type is RedfishConnectionManager or issubclass(
connection_type, RedfishConnectionManager
):
host = _host_from_connection_args(manager_args) or _host_from_connection_args(
configs.get("RedfishConnectionManager")
)
if host:
return f"Execution target: BMC via Redfish OOB ({host})"
return "Execution target: BMC via Redfish OOB"

if connection_type is InBandConnectionManager or issubclass(
connection_type, InBandConnectionManager
):
if system_info.location == SystemLocation.REMOTE:
host = _host_from_connection_args(manager_args) or _host_from_connection_args(
configs.get("InBandConnectionManager")
)
if host:
return f"Execution target: remote host via SSH ({host})"
return "Execution target: remote host via SSH"
host_name = system_info.name or "local host"
return f"Execution target: local host ({host_name})"

return f"Execution target: {connection_type.__name__}"
17 changes: 16 additions & 1 deletion nodescraper/pluginexecutor.py
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,10 @@
from nodescraper.base.oobsshdataplugin import OOBSSHDataPlugin
from nodescraper.connection.oob_ssh import OobSshConnectionManager
from nodescraper.constants import DEFAULT_LOGGER
from nodescraper.helpers.plugin_execution_target import (
format_in_band_target_summary,
format_plugin_execution_target,
)
from nodescraper.interfaces import ConnectionManager, DataPlugin, PluginInterface
from nodescraper.interfaces.taskresulthook import TaskResultHook
from nodescraper.models import PluginConfig, SystemInfo
Expand Down Expand Up @@ -123,7 +127,10 @@ def __init__(
self.logger.info("System SKU: %s", self.system_info.sku)
if self.system_info.platform:
self.logger.info("System Platform: %s", self.system_info.platform)
self.logger.info("System location: %s", self.system_info.location)
self.logger.info(
"%s",
format_in_band_target_summary(self.system_info, self.connection_configs),
)

@staticmethod
def _deep_merge_plugin_args(existing: dict, incoming: dict) -> dict:
Expand Down Expand Up @@ -270,6 +277,14 @@ def run_queue(self) -> list[PluginResult]:
continue

self.logger.info("-" * 50)
execution_target = format_plugin_execution_target(
plugin_class,
system_info=self.system_info,
connection_manager=init_payload.get("connection_manager"),
connection_configs=self.connection_configs,
)
if execution_target:
self.logger.info("(%s) %s", plugin_name, execution_target)
plugin_result = plugin_inst.run(**run_payload)
plugin_results.append(plugin_result)
for hook in self.plugin_run_result_hooks:
Expand Down
12 changes: 6 additions & 6 deletions nodescraper/plugins/serviceability/mi4xx/mi4xx_analyzer_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,19 +33,19 @@


class Mi4xxServiceabilityAnalyzerArgs(ServiceabilityAnalyzerArgs):
"""Analysis args for Mi4xxServiceabilityPlugin (AFSE entry point)."""
"""Analysis args for Mi4xxServiceabilityPlugin (Hub entry point afse)."""

hub_entry_point: str = Field(
default="afse",
description="Registered AFSE entry point name (MI4XX service hub).",
description="Registered Hub entry point name (default afse).",
)
hub_display_name: Optional[str] = Field(
default="AFSE",
default="Hub",
description="Label for analyzer status messages.",
)
hub_python_module: Optional[str] = Field(
default=None,
description="Not used for MI4XX; AFSE is selected via hub_entry_point afse.",
description="Not used for MI4XX; Hub is selected via hub_entry_point afse.",
)
rf_event_log_uri: str = Field(
default="/redfish/v1/Systems/Instinct_Accelerators/LogServices/EventLog/Entries",
Expand All @@ -65,10 +65,10 @@ def resolved_rf_event_log_uri(self) -> str:
return str(self.rf_event_log_uri).strip()

@model_validator(mode="after")
def _mi4xx_uses_afse(self) -> "Mi4xxServiceabilityAnalyzerArgs":
def _mi4xx_uses_afse_entry_point(self) -> "Mi4xxServiceabilityAnalyzerArgs":
if self.hub_python_module:
raise ValueError(
"Mi4xxServiceabilityPlugin uses AFSE via hub_entry_point; "
"Mi4xxServiceabilityPlugin uses Hub via hub_entry_point afse; "
"hub_python_module is not supported"
)
if str(self.hub_entry_point).strip().lower() != "afse":
Expand Down
64 changes: 59 additions & 5 deletions nodescraper/plugins/serviceability/se_adapter.py
Original file line number Diff line number Diff line change
Expand Up @@ -279,11 +279,53 @@ def _optional_int(value: Any) -> Optional[int]:


def _entry_point_result_rows(hub_result: dict[str, Any]) -> list[Any]:
"""Extract triage rows from the entry-point hub analyze response."""
"""Extract triage.results rows from the entry-point hub analyze response."""
triage = hub_result.get("triage")
if isinstance(triage, dict):
nested = triage.get("results")
if isinstance(nested, list):
return nested
results = hub_result.get("results")
return results if isinstance(results, list) else []


def _entry_point_top_rows(hub_result: dict[str, Any]) -> list[Any]:
"""Extract triage.top rows from the entry-point hub analyze response."""
top = hub_result.get("top")
if isinstance(top, list):
return top
triage = hub_result.get("triage")
if isinstance(triage, dict):
nested = triage.get("top")
if isinstance(nested, list):
return nested
return []


def _service_action_steps_from_row(
row: dict[str, Any],
san: int,
sag: Optional[dict[str, Any]],
) -> list[str]:
sa = row.get("service_action")
if isinstance(sa, dict):
steps = sa.get("steps")
if isinstance(steps, list):
descriptions: list[str] = []
for step in steps:
if not isinstance(step, dict):
continue
description = step.get("description")
if description is None:
continue
text = str(description).strip()
if text:
descriptions.append(text)
if descriptions:
return descriptions
return service_action_step_descriptions_from_sag(san, sag)


def _service_action_title_from_row(row: dict[str, Any]) -> Optional[str]:
title = row.get("service_action_title")
if title is not None:
Expand Down Expand Up @@ -358,7 +400,7 @@ def _hub_triage_result_from_row(
service_action_title=title,
service_action_category=category,
service_action_severity=sa_severity,
service_action_steps=service_action_step_descriptions_from_sag(san, sag),
service_action_steps=_service_action_steps_from_row(row, san, sag),
afid_summary=_afid_summary_from_sag(afid, sag),
)

Expand Down Expand Up @@ -424,9 +466,10 @@ def format_serviceability_solution_lines(block: ServiceabilityBlock) -> list[str
if block.afid_sag_file_version:
lines.append(f"AFID_SAG file: {block.afid_sag_file_version}")
if block.hub_triage_results:
lines.append("Hub triage results:")
for index, row in enumerate(block.hub_triage_results, start=1):
lines.extend(_format_hub_triage_result_lines(index, row))
lines.append(
f"{len(block.hub_triage_results)} prioritized recommendation(s); "
"see recommendation tables below"
)
return lines
if block.short_service_info:
lines.append("short_service_info:")
Expand Down Expand Up @@ -530,6 +573,7 @@ def serviceability_block_from_entry_point_hub(
hub_label: str = "Service hub",
rf_event_count: int = 0,
afid_sag_path: Optional[str] = None,
hub_analyze_response: Optional[dict[str, Any]] = None,
) -> ServiceabilityBlock:
"""Build a ServiceabilityBlock from a registered entry-point hub analyze() response."""
hub_name = str(hub_result.get("engine") or hub_label)
Expand Down Expand Up @@ -606,6 +650,14 @@ def serviceability_block_from_entry_point_hub(
parsed = _hub_triage_result_from_row(row, sag)
if parsed is not None:
triage_results.append(parsed)

top_results: list[HubTriageResult] = []
for row in _entry_point_top_rows(hub_result):
if not isinstance(row, dict):
continue
parsed = _hub_triage_result_from_row(row, sag)
if parsed is not None:
top_results.append(parsed)
sag_metadata = None
afid_sag_file_version = None
if sag:
Expand All @@ -629,5 +681,7 @@ def serviceability_block_from_entry_point_hub(
hub_version=hub_version,
afid_sag_file_version=afid_sag_file_version,
afid_sag_metadata=sag_metadata,
hub_analyze_response=hub_analyze_response,
hub_top_results=top_results,
hub_triage_results=triage_results,
)
31 changes: 31 additions & 0 deletions nodescraper/plugins/serviceability/se_models.py
Original file line number Diff line number Diff line change
Expand Up @@ -74,6 +74,29 @@ class ServiceabilitySolution(BaseModel):
)


class PrioritizedServiceAction(BaseModel):
"""One hub-ranked service action row for tables and API-style consumers."""

rank: int
afid: int
location: str
count: int = 1
service_action_num: int
service_action_title: Optional[str] = None
service_action_category: Optional[str] = None
priority: Optional[int] = None
sa_severity: Optional[int] = None
tier: Optional[int] = None
tier_label: Optional[str] = None
fru: Optional[str] = None
fru_rank: Optional[int] = None
hub_sort_priority: Optional[int] = None
multi_mask: Optional[int] = None
afid_summary: Optional[str] = None
service_action_steps: List[str] = Field(default_factory=list)
serviceable_units: Optional[List[str]] = None


class HubTriageResult(BaseModel):
"""One service hub triage row with SAG-enriched action details."""

Expand Down Expand Up @@ -130,6 +153,14 @@ class ServiceabilityBlock(BaseModel):
"per-unit dict payloads are collapsed, identical messages merged with unit lists)."
),
)
hub_analyze_response: Optional[dict[str, Any]] = Field(
default=None,
description="Unmodified Hub analyze() JSON (triage.top, triage.results, status, pid, revision).",
)
hub_top_results: List[HubTriageResult] = Field(
default_factory=list,
description="Hub triage.top rows (highest se_sort_priority).",
)
hub_triage_results: List[HubTriageResult] = Field(
default_factory=list,
description="Full service hub triage rows with SAG-enriched action details.",
Expand Down
Loading
Loading