diff --git a/nodescraper/plugins/inband/network/network_collector.py b/nodescraper/plugins/inband/network/network_collector.py index 4a87936..ea978fd 100644 --- a/nodescraper/plugins/inband/network/network_collector.py +++ b/nodescraper/plugins/inband/network/network_collector.py @@ -27,6 +27,7 @@ from typing import Dict, List, Optional, Tuple from nodescraper.base import InBandDataCollector +from nodescraper.connection.inband import TextFileArtifact from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus, OSFamily from nodescraper.models import TaskResult @@ -65,6 +66,7 @@ class NetworkCollector(InBandDataCollector[NetworkDataModel, NetworkCollectorArg CMD_RULE = "ip rule show" CMD_NEIGHBOR = "ip neighbor show" CMD_ETHTOOL_TEMPLATE = "ethtool {interface}" + CMD_ETHTOOL_S_TEMPLATE = "ethtool -S {interface}" CMD_PING = "ping" CMD_WGET = "wget" CMD_CURL = "curl" @@ -468,6 +470,38 @@ def _parse_ethtool(self, interface: str, output: str) -> EthtoolInfo: return ethtool_info + def _parse_ethtool_statistics(self, output: str, interface: str) -> Dict[str, str]: + """Parse 'ethtool -S ' output into a key-value dictionary. + + Args: + output: Raw output from 'ethtool -S ' command + interface: Name of the network interface (for netdev key) + + Returns: + Dictionary of statistic name -> value (string) + """ + stats_dict: Dict[str, str] = {} + for line in output.splitlines(): + if ":" not in line: + continue + if "NIC statistics" in line: + stats_dict["netdev"] = interface + elif "]: " in line and line.strip().startswith("["): + # Format: " [0]: rx_ucast_packets: 162" + bracket_part, rest = line.split("]: ", 1) + index = bracket_part.strip().lstrip("[") + if ": " in rest: + stat_key, stat_value = rest.split(": ", 1) + key = f"{index}_{stat_key.strip()}" + stats_dict[key] = stat_value.strip() + else: + key, value = line.split(":", 1) + stats_dict[key.strip()] = value.strip() + else: + key, value = line.split(":", 1) + stats_dict[key.strip()] = value.strip() + return stats_dict + def _parse_niccli_listdev(self, output: str) -> List[BroadcomNicDevice]: """Parse 'niccli --list_devices' output into BroadcomNicDevice objects. @@ -1399,6 +1433,19 @@ def _collect_ethtool_info(self, interfaces: List[NetworkInterface]) -> Dict[str, if res_ethtool.exit_code == 0: ethtool_info = self._parse_ethtool(iface.name, res_ethtool.stdout) + # Collect ethtool -S (statistics) for error/health analysis + cmd_s = self.CMD_ETHTOOL_S_TEMPLATE.format(interface=iface.name) + res_ethtool_s = self._run_sut_cmd(cmd_s, sudo=True) + if res_ethtool_s.exit_code == 0 and res_ethtool_s.stdout: + ethtool_info.statistics = self._parse_ethtool_statistics( + res_ethtool_s.stdout, iface.name + ) + self.result.artifacts.append( + TextFileArtifact( + filename=f"{iface.name}.log", + contents=res_ethtool_s.stdout, + ) + ) ethtool_data[iface.name] = ethtool_info self._log_event( category=EventCategory.NETWORK, diff --git a/nodescraper/plugins/inband/network/networkdata.py b/nodescraper/plugins/inband/network/networkdata.py index e681751..8a0bf99 100644 --- a/nodescraper/plugins/inband/network/networkdata.py +++ b/nodescraper/plugins/inband/network/networkdata.py @@ -103,6 +103,8 @@ class EthtoolInfo(BaseModel): port: Optional[str] = None # Port type (e.g., "Twisted Pair") auto_negotiation: Optional[str] = None # Auto-negotiation status (e.g., "on", "off") link_detected: Optional[str] = None # Link detection status (e.g., "yes", "no") + # ethtool -S (statistics) output: parsed key-value for error/health analysis + statistics: Dict[str, str] = Field(default_factory=dict) class BroadcomNicDevice(BaseModel): diff --git a/test/unit/plugin/test_network_collector.py b/test/unit/plugin/test_network_collector.py index 2de1374..f6580c1 100644 --- a/test/unit/plugin/test_network_collector.py +++ b/test/unit/plugin/test_network_collector.py @@ -530,6 +530,23 @@ def test_parse_ethtool_empty_output(collector): assert len(ethtool_info.advertised_link_modes) == 0 +def test_parse_ethtool_statistics(collector): + """Test parsing ethtool -S output (statistics) for error/health analysis.""" + output = """NIC statistics: + [0]: rx_ucast_packets: 162692536538787551 + [0]: rx_errors: 0 + [1]: rx_ucast_packets: 79657418409137764 + rx_total_l4_csum_errors: 0 + rx_total_buf_errors: 0""" + stats = collector._parse_ethtool_statistics(output, "abc1p1") + assert stats.get("netdev") == "abc1p1" + assert stats.get("0_rx_ucast_packets") == "162692536538787551" + assert stats.get("0_rx_errors") == "0" + assert stats.get("1_rx_ucast_packets") == "79657418409137764" + assert stats.get("rx_total_l4_csum_errors") == "0" + assert stats.get("rx_total_buf_errors") == "0" + + def test_network_data_model_creation(collector): """Test creating NetworkDataModel with all components""" interface = NetworkInterface(