Skip to content
Open
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
39 changes: 39 additions & 0 deletions nodescraper/plugins/inband/network/analyzer_args.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
###############################################################################
#
# MIT License
#
# Copyright (c) 2025 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.
#
###############################################################################
Comment thread
alexandraBara marked this conversation as resolved.
from typing import Optional

from pydantic import Field

from nodescraper.models import AnalyzerArgs


class NetworkAnalyzerArgs(AnalyzerArgs):
"""Arguments for network and ethtool analysis."""

expected_nic_firmware: Optional[str] = Field(
default=None,
description="Exact firmware version expected from each collected NIC.",
)
60 changes: 58 additions & 2 deletions nodescraper/plugins/inband/network/network_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -24,20 +24,25 @@
#
###############################################################################
import re
from typing import Optional

from nodescraper.base.regexanalyzer import RegexAnalyzer
from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus
from nodescraper.models import TaskResult
from nodescraper.utils import _validate_firmware_policy

from .analyzer_args import NetworkAnalyzerArgs
from .networkdata import NetworkDataModel


class NetworkAnalyzer(RegexAnalyzer[NetworkDataModel, None]):
class NetworkAnalyzer(RegexAnalyzer[NetworkDataModel, NetworkAnalyzerArgs]):
"""Check network statistics for errors."""

DATA_MODEL = NetworkDataModel

def analyze_data(self, data: NetworkDataModel, args=None) -> TaskResult:
def analyze_data(
self, data: NetworkDataModel, args: Optional[NetworkAnalyzerArgs] = None
) -> TaskResult:
"""Analyze ethtool -S statistics via RDMA-scoped vendor models.

Args:
Expand All @@ -47,6 +52,40 @@ def analyze_data(self, data: NetworkDataModel, args=None) -> TaskResult:
Returns:
TaskResult with OK, WARNING (no devices, or only warning-tier counters), or ERROR.
"""
args = args or NetworkAnalyzerArgs()
firmware_records = []
for interface, info in data.ethtool_info.items():
vendor = _vendor_for_driver(info.driver)
if vendor is None:
continue
firmware_records.append(
{
"identity": interface,
"version": info.firmware_version,
"source": "ethtool",
"vendor": vendor,
"driver": info.driver,
"interface": interface,
"pci_bdf": info.bus_info,
}
)
firmware_policy_issues = _validate_firmware_policy(
firmware_records,
(
{"expected_nic_firmware": args.expected_nic_firmware}
if args.expected_nic_firmware
else None
),
)
for issue in firmware_policy_issues:
self._log_event(
category=EventCategory.NETWORK,
description="Network adapter firmware policy mismatch",
data=issue,
priority=EventPriority.WARNING,
console_log=True,
)

if not data.ethtool_info and not data.ethtool_statistics:
self.result.message = "No network devices found"
self.result.status = ExecutionStatus.WARNING
Expand Down Expand Up @@ -165,8 +204,25 @@ def analyze_data(self, data: NetworkDataModel, args=None) -> TaskResult:
elif vendor_warning:
self.result.message = "Network warning counters non-zero in statistics"
self.result.status = ExecutionStatus.WARNING
elif firmware_policy_issues:
self.result.message = "Network adapter firmware policy mismatch"
self.result.status = ExecutionStatus.WARNING
else:
self.result.message = "No network errors detected in statistics"
self.result.status = ExecutionStatus.OK

return self.result


def _vendor_for_driver(driver: Optional[str]) -> Optional[str]:
"""Return a stable vendor label for common ethtool driver families."""
if not driver:
return None
normalized = driver.lower()
if normalized.startswith(("bnxt", "bnx2")):
return "Broadcom"
if normalized.startswith(("mlx", "ib_")):
return "Mellanox"
if normalized.startswith(("ionic",)):
return "Pensando"
return None
53 changes: 53 additions & 0 deletions nodescraper/plugins/inband/network/network_collector.py
Original file line number Diff line number Diff line change
Expand Up @@ -450,6 +450,24 @@ def _parse_ethtool(self, interface: str, output: str) -> EthtoolInfo:

return ethtool_info

def _parse_ethtool_driver_info(self, info: EthtoolInfo, output: str) -> None:
"""Merge ``ethtool -i`` identity and firmware fields into an interface model."""
for line in output.splitlines():
if ":" not in line:
continue
key, value = (part.strip() for part in line.split(":", 1))
if not key or not value:
continue
info.settings[key] = value
normalized_key = key.lower().replace("-", "_").replace(" ", "_")
if normalized_key == "driver":
info.driver = value
elif normalized_key in {"bus_info", "bus"}:
info.bus_info = value
elif normalized_key in {"firmware_version", "firmware"}:
firmware_match = re.search(r"\d+(?:\.\d+){2,}", value)
info.firmware_version = firmware_match.group(0) if firmware_match else value

def _parse_ethtool_statistics(self, output: str, interface: str) -> Dict[str, str]:
"""Parse 'ethtool -S <interface>' output into a key-value dictionary.

Expand Down Expand Up @@ -522,6 +540,41 @@ def _collect_ethtool_info(
priority=EventPriority.WARNING,
)

if iface.name == "lo" or any(flag.upper() == "LOOPBACK" for flag in iface.flags):
continue

# ``ethtool <interface>`` does not include firmware identity on all
# drivers, so always retain the separate ``ethtool -i`` response.
res_driver = self._run_sut_cmd(
self.CMD_ETHTOOL_I_TEMPLATE.format(interface=iface.name), sudo=True
)
if res_driver.exit_code == 0:
ethtool_info = ethtool_data.get(
iface.name, EthtoolInfo(interface=iface.name, raw_output="")
)
self._parse_ethtool_driver_info(ethtool_info, res_driver.stdout)
ethtool_data[iface.name] = ethtool_info
self._log_event(
category=EventCategory.NETWORK,
description=(
f"Collected ethtool -i driver info for interface: " f"{iface.name}"
),
priority=EventPriority.INFO,
)
else:
self._log_event(
category=EventCategory.NETWORK,
description=(
f"Error collecting ethtool -i driver info for interface: " f"{iface.name}"
),
data={
"command": res_driver.command,
"exit_code": res_driver.exit_code,
"stderr": res_driver.stderr,
},
priority=EventPriority.WARNING,
)

Comment thread
alexandraBara marked this conversation as resolved.
return ethtool_data, skipped

def _collect_ethtool_statistic(self, netdev: str, driver: str) -> Optional[EthtoolStatistics]:
Expand Down
4 changes: 3 additions & 1 deletion nodescraper/plugins/inband/network/network_plugin.py
Original file line number Diff line number Diff line change
Expand Up @@ -25,19 +25,21 @@
###############################################################################
from nodescraper.base import InBandDataPlugin

from .analyzer_args import NetworkAnalyzerArgs
from .collector_args import NetworkCollectorArgs
from .network_analyzer import NetworkAnalyzer
from .network_collector import NetworkCollector
from .networkdata import NetworkDataModel


class NetworkPlugin(InBandDataPlugin[NetworkDataModel, NetworkCollectorArgs, None]):
class NetworkPlugin(InBandDataPlugin[NetworkDataModel, NetworkCollectorArgs, NetworkAnalyzerArgs]):
"""Plugin for collection of network configuration data"""

DATA_MODEL = NetworkDataModel

COLLECTOR = NetworkCollector

COLLECTOR_ARGS = NetworkCollectorArgs
ANALYZER_ARGS = NetworkAnalyzerArgs

ANALYZER = NetworkAnalyzer
3 changes: 3 additions & 0 deletions nodescraper/plugins/inband/network/networkdata.py
Original file line number Diff line number Diff line change
Expand Up @@ -97,6 +97,9 @@ class EthtoolInfo(BaseModel):

interface: str # Interface name this info belongs to
raw_output: str # Raw ethtool command output
driver: Optional[str] = None # Kernel driver from ethtool -i
bus_info: Optional[str] = None # PCI bus identity from ethtool -i
firmware_version: Optional[str] = None # Firmware version from ethtool -i
settings: Dict[str, str] = Field(default_factory=dict) # Parsed key-value settings
supported_link_modes: List[str] = Field(default_factory=list) # Supported link modes
advertised_link_modes: List[str] = Field(default_factory=list) # Advertised link modes
Expand Down
4 changes: 4 additions & 0 deletions nodescraper/plugins/inband/nic/analyzer_args.py
Original file line number Diff line number Diff line change
Expand Up @@ -33,6 +33,10 @@
class NicAnalyzerArgs(AnalyzerArgs):
"""Analyzer args for niccli/nicctl data, with expected_values keyed by canonical command key."""

expected_nic_firmware: Optional[str] = Field(
default=None,
description="Exact firmware version expected from each collected NIC.",
)
expected_values: Optional[Dict[str, Dict[str, Any]]] = Field(
default=None,
description="Per-command expected checks keyed by canonical key (see command_to_canonical_key).",
Expand Down
72 changes: 71 additions & 1 deletion nodescraper/plugins/inband/nic/nic_analyzer.py
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus
from nodescraper.interfaces import DataAnalyzer
from nodescraper.models import TaskResult
from nodescraper.utils import _validate_firmware_policy

from .analyzer_args import NicAnalyzerArgs
from .nic_data import NicDataModel
Expand Down Expand Up @@ -103,6 +104,49 @@ def _normalize_tsa_map(d: Optional[Dict[Any, Any]]) -> Optional[Dict[int, str]]:
return {int(k): str(v) for k, v in d.items()}


def _firmware_records(data: NicDataModel) -> list[Dict[str, Any]]:
"""Build normalized records for Broadcom and Pensando NIC output."""
records: list[Dict[str, Any]] = []
devices = {device.device_num: device for device in data.broadcom_nic_devices}
for device_num, version in data.broadcom_nic_firmware.items():
device = devices.get(device_num)
records.append(
{
"identity": str(device_num),
"version": version,
"source": data.broadcom_cli_type or "broadcom",
"vendor": "Broadcom",
"model": device.model if device else None,
"interface": device.interface_name if device else None,
"pci_bdf": device.pci_address if device else None,
}
)
for device in data.broadcom_nic_devices:
if device.device_num not in data.broadcom_nic_firmware:
records.append(
{
"identity": str(device.device_num),
"version": None,
"source": data.broadcom_cli_type or "broadcom",
"vendor": "Broadcom",
"model": device.model,
"interface": device.interface_name,
"pci_bdf": device.pci_address,
}
)
for firmware in data.pensando_nic_version_firmware:
records.append(
{
"identity": firmware.nic_id,
"version": firmware.firmware_a,
"source": "nicctl",
"vendor": "Pensando",
"pci_bdf": firmware.pcie_bdf,
}
)
return records


class NicAnalyzer(DataAnalyzer[NicDataModel, NicAnalyzerArgs]):
"""Analyze niccli/nicctl data; checks Broadcom support_rdma, performance_profile (RoCE), pcie_relaxed_ordering (enabled), and getqos (expected QoS across adapters)."""

Expand All @@ -115,11 +159,33 @@ def analyze_data(
if args is None:
args = NicAnalyzerArgs()

firmware_policy_issues = _validate_firmware_policy(
_firmware_records(data),
(
{"expected_nic_firmware": args.expected_nic_firmware}
if args.expected_nic_firmware
else None
),
)
for issue in firmware_policy_issues:
self._log_event(
category=EventCategory.NETWORK,
description="Network adapter firmware policy mismatch",
data=issue,
priority=EventPriority.WARNING,
console_log=True,
)
has_firmware_data = bool(
data.broadcom_nic_devices
or data.broadcom_nic_firmware
or data.pensando_nic_version_firmware
)

has_broadcom = bool(data.broadcom_nic_support_rdma)
has_nicctl_logs = bool(
data.nicctl_card_logs and any((c or "").strip() for c in data.nicctl_card_logs.values())
)
if not has_broadcom and not has_nicctl_logs:
if not has_broadcom and not has_nicctl_logs and not has_firmware_data:
self.result.message = "No Broadcom support_rdma or nicctl card log data to check"
self.result.status = ExecutionStatus.OK
return self.result
Expand Down Expand Up @@ -295,12 +361,14 @@ def analyze_data(
console_log=True,
)

any_firmware_mismatch = bool(firmware_policy_issues)
if (
any_disabled
or any_non_roce
or any_relaxed_ordering_bad
or any_qos_mismatch
or any_nicctl_log_errors
or any_firmware_mismatch
):
self.result.status = ExecutionStatus.WARNING
parts = []
Expand All @@ -314,6 +382,8 @@ def analyze_data(
parts.append("getqos")
if any_nicctl_log_errors:
parts.append("nicctl_card_logs")
if any_firmware_mismatch:
parts.append("firmware")
self.result.message = f"Broadcom/nic check(s) failed: {' and/or '.join(parts)}"
else:
self.result.status = ExecutionStatus.OK
Expand Down
Loading
Loading