From e32142e69a3ddcf1ec842935f4751e799b09520b Mon Sep 17 00:00:00 2001 From: jaspals Date: Tue, 16 Dec 2025 20:12:30 +0000 Subject: [PATCH 1/2] Add ethtool support to NetworkPlugin --- .../inband/network/network_collector.py | 115 +++++++++++++++++- .../plugins/inband/network/networkdata.py | 20 ++- 2 files changed, 131 insertions(+), 4 deletions(-) diff --git a/nodescraper/plugins/inband/network/network_collector.py b/nodescraper/plugins/inband/network/network_collector.py index 660a6d9e..efc9d326 100644 --- a/nodescraper/plugins/inband/network/network_collector.py +++ b/nodescraper/plugins/inband/network/network_collector.py @@ -24,13 +24,14 @@ # ############################################################################### import re -from typing import List, Optional, Tuple +from typing import Dict, List, Optional, Tuple from nodescraper.base import InBandDataCollector from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus from nodescraper.models import TaskResult from .networkdata import ( + EthtoolInfo, IpAddress, Neighbor, NetworkDataModel, @@ -48,6 +49,7 @@ class NetworkCollector(InBandDataCollector[NetworkDataModel, None]): CMD_ROUTE = "ip route show" CMD_RULE = "ip rule show" CMD_NEIGHBOR = "ip neighbor show" + CMD_ETHTOOL_TEMPLATE = "sudo ethtool {interface}" def _parse_ip_addr(self, output: str) -> List[NetworkInterface]: """Parse 'ip addr show' output into NetworkInterface objects. @@ -370,6 +372,98 @@ def _parse_ip_neighbor(self, output: str) -> List[Neighbor]: return neighbors + def _parse_ethtool(self, interface: str, output: str) -> EthtoolInfo: + """Parse 'ethtool ' output into EthtoolInfo object. + + Args: + interface: Name of the network interface + output: Raw output from 'ethtool ' command + + Returns: + EthtoolInfo object with parsed data + """ + ethtool_info = EthtoolInfo(interface=interface, raw_output=output) + + # Parse line by line + current_section = None + for line in output.splitlines(): + line_stripped = line.strip() + if not line_stripped: + continue + + # Detect sections (lines ending with colon and no tab prefix) + if line_stripped.endswith(":") and not line.startswith("\t"): + current_section = line_stripped.rstrip(":") + continue + + # Parse key-value pairs (lines with colon in the middle) + if ":" in line_stripped: + # Split on first colon + parts = line_stripped.split(":", 1) + if len(parts) == 2: + key = parts[0].strip() + value = parts[1].strip() + + # Store in settings dict + ethtool_info.settings[key] = value + + # Extract specific important fields + if key == "Speed": + ethtool_info.speed = value + elif key == "Duplex": + ethtool_info.duplex = value + elif key == "Port": + ethtool_info.port = value + elif key == "Auto-negotiation": + ethtool_info.auto_negotiation = value + elif key == "Link detected": + ethtool_info.link_detected = value + + # Parse supported/advertised link modes (typically indented list items) + elif current_section in ["Supported link modes", "Advertised link modes"]: + # These are typically list items, possibly with speeds like "10baseT/Half" + if line.startswith("\t") or line.startswith(" "): + mode = line_stripped + if current_section == "Supported link modes": + ethtool_info.supported_link_modes.append(mode) + elif current_section == "Advertised link modes": + ethtool_info.advertised_link_modes.append(mode) + + return ethtool_info + + def _collect_ethtool_info(self, interfaces: List[NetworkInterface]) -> Dict[str, EthtoolInfo]: + """Collect ethtool information for all network interfaces. + + Args: + interfaces: List of NetworkInterface objects to collect ethtool info for + + Returns: + Dictionary mapping interface name to EthtoolInfo + """ + ethtool_data = {} + + for iface in interfaces: + cmd = self.CMD_ETHTOOL_TEMPLATE.format(interface=iface.name) + res_ethtool = self._run_sut_cmd(cmd) + + if res_ethtool.exit_code == 0: + ethtool_info = self._parse_ethtool(iface.name, res_ethtool.stdout) + ethtool_data[iface.name] = ethtool_info + self._log_event( + category=EventCategory.OS, + description=f"Collected ethtool info for interface: {iface.name}", + priority=EventPriority.INFO, + ) + else: + self._log_event( + category=EventCategory.OS, + description=f"Error collecting ethtool info for interface: {iface.name}", + data={"command": res_ethtool.command, "exit_code": res_ethtool.exit_code}, + priority=EventPriority.WARNING, + ) + + return ethtool_data + def collect_data( self, args=None, @@ -384,6 +478,7 @@ def collect_data( routes = [] rules = [] neighbors = [] + ethtool_data = {} # Collect interface/address information res_addr = self._run_sut_cmd(self.CMD_ADDR) @@ -403,6 +498,15 @@ def collect_data( console_log=True, ) + # Collect ethtool information for interfaces + if interfaces: + ethtool_data = self._collect_ethtool_info(interfaces) + self._log_event( + category=EventCategory.OS, + description=f"Collected ethtool info for {len(ethtool_data)} interfaces", + priority=EventPriority.INFO, + ) + # Collect routing table res_route = self._run_sut_cmd(self.CMD_ROUTE) if res_route.exit_code == 0: @@ -456,11 +560,16 @@ def collect_data( if interfaces or routes or rules or neighbors: network_data = NetworkDataModel( - interfaces=interfaces, routes=routes, rules=rules, neighbors=neighbors + interfaces=interfaces, + routes=routes, + rules=rules, + neighbors=neighbors, + ethtool_info=ethtool_data, ) self.result.message = ( f"Collected network data: {len(interfaces)} interfaces, " - f"{len(routes)} routes, {len(rules)} rules, {len(neighbors)} neighbors" + f"{len(routes)} routes, {len(rules)} rules, {len(neighbors)} neighbors, " + f"{len(ethtool_data)} ethtool entries" ) self.result.status = ExecutionStatus.OK return self.result, network_data diff --git a/nodescraper/plugins/inband/network/networkdata.py b/nodescraper/plugins/inband/network/networkdata.py index 8cccf4b7..5e94efc2 100644 --- a/nodescraper/plugins/inband/network/networkdata.py +++ b/nodescraper/plugins/inband/network/networkdata.py @@ -23,7 +23,7 @@ # SOFTWARE. # ############################################################################### -from typing import List, Optional +from typing import Dict, List, Optional from pydantic import BaseModel, Field @@ -90,6 +90,21 @@ class Neighbor(BaseModel): flags: List[str] = Field(default_factory=list) # Additional flags like "router", "proxy" +class EthtoolInfo(BaseModel): + """Ethtool information for a network interface""" + + interface: str # Interface name this info belongs to + raw_output: str # Raw ethtool command output + 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 + speed: Optional[str] = None # Link speed (e.g., "10000Mb/s") + duplex: Optional[str] = None # Duplex mode (e.g., "Full") + 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") + + class NetworkDataModel(DataModel): """Complete network configuration data""" @@ -97,3 +112,6 @@ class NetworkDataModel(DataModel): routes: List[Route] = Field(default_factory=list) rules: List[RoutingRule] = Field(default_factory=list) neighbors: List[Neighbor] = Field(default_factory=list) + ethtool_info: Dict[str, EthtoolInfo] = Field( + default_factory=dict + ) # Interface name -> EthtoolInfo mapping From 158b7668494c2e927015cc0032f81b3ed3057980 Mon Sep 17 00:00:00 2001 From: jaspals Date: Wed, 17 Dec 2025 21:40:47 +0000 Subject: [PATCH 2/2] ethtool utests and EventCategory.Network change --- nodescraper/enums/eventcategory.py | 3 + .../inband/network/network_collector.py | 22 +-- test/unit/plugin/test_network_collector.py | 140 +++++++++++++++++- 3 files changed, 147 insertions(+), 18 deletions(-) diff --git a/nodescraper/enums/eventcategory.py b/nodescraper/enums/eventcategory.py index a7e52d88..553119a8 100644 --- a/nodescraper/enums/eventcategory.py +++ b/nodescraper/enums/eventcategory.py @@ -63,6 +63,8 @@ class EventCategory(AutoNameStrEnum): SBIOS/VBIOS/IFWI Errors - INFRASTRUCTURE Network, IT issues, Downtime + - NETWORK + Network configuration, interfaces, routing, neighbors, ethtool data - RUNTIME Framework issues, does not include content failures - UNKNOWN @@ -82,5 +84,6 @@ class EventCategory(AutoNameStrEnum): SW_DRIVER = auto() BIOS = auto() INFRASTRUCTURE = auto() + NETWORK = auto() RUNTIME = auto() UNKNOWN = auto() diff --git a/nodescraper/plugins/inband/network/network_collector.py b/nodescraper/plugins/inband/network/network_collector.py index efc9d326..0f96e7c8 100644 --- a/nodescraper/plugins/inband/network/network_collector.py +++ b/nodescraper/plugins/inband/network/network_collector.py @@ -450,13 +450,13 @@ def _collect_ethtool_info(self, interfaces: List[NetworkInterface]) -> Dict[str, ethtool_info = self._parse_ethtool(iface.name, res_ethtool.stdout) ethtool_data[iface.name] = ethtool_info self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description=f"Collected ethtool info for interface: {iface.name}", priority=EventPriority.INFO, ) else: self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description=f"Error collecting ethtool info for interface: {iface.name}", data={"command": res_ethtool.command, "exit_code": res_ethtool.exit_code}, priority=EventPriority.WARNING, @@ -485,13 +485,13 @@ def collect_data( if res_addr.exit_code == 0: interfaces = self._parse_ip_addr(res_addr.stdout) self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description=f"Collected {len(interfaces)} network interfaces", priority=EventPriority.INFO, ) else: self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description="Error collecting network interfaces", data={"command": res_addr.command, "exit_code": res_addr.exit_code}, priority=EventPriority.ERROR, @@ -502,7 +502,7 @@ def collect_data( if interfaces: ethtool_data = self._collect_ethtool_info(interfaces) self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description=f"Collected ethtool info for {len(ethtool_data)} interfaces", priority=EventPriority.INFO, ) @@ -512,13 +512,13 @@ def collect_data( if res_route.exit_code == 0: routes = self._parse_ip_route(res_route.stdout) self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description=f"Collected {len(routes)} routes", priority=EventPriority.INFO, ) else: self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description="Error collecting routes", data={"command": res_route.command, "exit_code": res_route.exit_code}, priority=EventPriority.WARNING, @@ -529,13 +529,13 @@ def collect_data( if res_rule.exit_code == 0: rules = self._parse_ip_rule(res_rule.stdout) self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description=f"Collected {len(rules)} routing rules", priority=EventPriority.INFO, ) else: self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description="Error collecting routing rules", data={"command": res_rule.command, "exit_code": res_rule.exit_code}, priority=EventPriority.WARNING, @@ -546,13 +546,13 @@ def collect_data( if res_neighbor.exit_code == 0: neighbors = self._parse_ip_neighbor(res_neighbor.stdout) self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description=f"Collected {len(neighbors)} neighbor entries", priority=EventPriority.INFO, ) else: self._log_event( - category=EventCategory.OS, + category=EventCategory.NETWORK, description="Error collecting neighbor table", data={"command": res_neighbor.command, "exit_code": res_neighbor.exit_code}, priority=EventPriority.WARNING, diff --git a/test/unit/plugin/test_network_collector.py b/test/unit/plugin/test_network_collector.py index e6e52e2a..9d7e7546 100644 --- a/test/unit/plugin/test_network_collector.py +++ b/test/unit/plugin/test_network_collector.py @@ -32,6 +32,7 @@ from nodescraper.models.systeminfo import OSFamily from nodescraper.plugins.inband.network.network_collector import NetworkCollector from nodescraper.plugins.inband.network.networkdata import ( + EthtoolInfo, IpAddress, Neighbor, NetworkDataModel, @@ -75,6 +76,41 @@ def collector(system_info, conn_mock): IP_NEIGHBOR_OUTPUT = """50.50.1.50 dev eth0 lladdr 11:22:33:44:55:66 STALE 50.50.1.1 dev eth0 lladdr 99:88:77:66:55:44 REACHABLE""" +ETHTOOL_OUTPUT = """Settings for ethmock123: + Supported ports: [ TP ] + Supported link modes: 10mockbaseT/Half + 123mockbaseT/Half + 1234mockbaseT/Full + Supported pause frame use: Symmetric + Supports auto-negotiation: Yes + Supported FEC modes: Not reported + Advertised link modes: 10mockbaseT/Half 10mockbaseT/Full + 167mockbaseT/Half 167mockbaseT/Full + 1345mockbaseT/Full + Advertised pause frame use: Symmetric + Advertised auto-negotiation: Yes + Advertised FEC modes: Xyz ABCfec + Speed: 1000mockMb/s + Duplex: Full + Port: MockedTwisted Pair + PHYAD: 1 + Transceiver: internal + Auto-negotiation: on + MDI-X: on (auto) + Supports Wake-on: qwerty + Wake-on: g + Current message level: 0x123123 + Link detected: yes""" + +ETHTOOL_NO_LINK_OUTPUT = """Settings for ethmock1: + Supported ports: [ FIBRE ] + Supported link modes: 11122mockbaseT/Full + Speed: Unknown! + Duplex: Unknown! + Port: FIBRE + Auto-negotiation: off + Link detected: no""" + def test_parse_ip_addr_loopback(collector): """Test parsing loopback interface from ip addr output""" @@ -266,6 +302,9 @@ def run_sut_cmd_side_effect(cmd): return MagicMock(exit_code=0, stdout=IP_RULE_OUTPUT, command=cmd) elif "neighbor show" in cmd: return MagicMock(exit_code=0, stdout=IP_NEIGHBOR_OUTPUT, command=cmd) + elif "ethtool" in cmd: + # Fail ethtool commands (simulating no sudo or not supported) + return MagicMock(exit_code=1, stdout="", command=cmd) return MagicMock(exit_code=1, stdout="", command=cmd) collector._run_sut_cmd = MagicMock(side_effect=run_sut_cmd_side_effect) @@ -283,6 +322,7 @@ def run_sut_cmd_side_effect(cmd): assert "3 routes" in result.message assert "3 rules" in result.message assert "2 neighbors" in result.message + assert "ethtool" in result.message def test_collect_data_addr_failure(collector, conn_mock): @@ -299,6 +339,8 @@ def run_sut_cmd_side_effect(cmd): return MagicMock(exit_code=0, stdout=IP_RULE_OUTPUT, command=cmd) elif "neighbor show" in cmd: return MagicMock(exit_code=0, stdout=IP_NEIGHBOR_OUTPUT, command=cmd) + elif "ethtool" in cmd: + return MagicMock(exit_code=1, stdout="", command=cmd) return MagicMock(exit_code=1, stdout="", command=cmd) collector._run_sut_cmd = MagicMock(side_effect=run_sut_cmd_side_effect) @@ -312,6 +354,7 @@ def run_sut_cmd_side_effect(cmd): assert len(data.routes) == 3 # Success assert len(data.rules) == 3 # Success assert len(data.neighbors) == 2 # Success + assert len(data.ethtool_info) == 0 # No interfaces, so no ethtool data assert len(result.events) > 0 @@ -319,8 +362,11 @@ def test_collect_data_all_failures(collector, conn_mock): """Test collection when all commands fail""" collector.system_info.os_family = OSFamily.LINUX - # Mock all commands failing - collector._run_sut_cmd = MagicMock(return_value=MagicMock(exit_code=1, stdout="", command="ip")) + # Mock all commands failing (including ethtool) + def run_sut_cmd_side_effect(cmd): + return MagicMock(exit_code=1, stdout="", command=cmd) + + collector._run_sut_cmd = MagicMock(side_effect=run_sut_cmd_side_effect) result, data = collector.collect_data() @@ -389,30 +435,110 @@ def test_parse_ip_rule_with_action(collector): assert rule.table is None +def test_parse_ethtool_basic(collector): + """Test parsing basic ethtool output""" + ethtool_info = collector._parse_ethtool("ethmock123", ETHTOOL_OUTPUT) + + assert ethtool_info.interface == "ethmock123" + assert ethtool_info.speed == "1000mockMb/s" + assert ethtool_info.duplex == "Full" + assert ethtool_info.port == "MockedTwisted Pair" + assert ethtool_info.auto_negotiation == "on" + assert ethtool_info.link_detected == "yes" + assert "Speed" in ethtool_info.settings + assert ethtool_info.settings["Speed"] == "1000mockMb/s" + assert ethtool_info.settings["PHYAD"] == "1" + assert ethtool_info.raw_output == ETHTOOL_OUTPUT + + +def test_parse_ethtool_supported_link_modes(collector): + """Test parsing supported link modes from ethtool output""" + ethtool_info = collector._parse_ethtool("ethmock123", ETHTOOL_OUTPUT) + + # Check supported link modes are stored in settings dict + # Note: The current implementation stores link modes in settings dict, + # not in the supported_link_modes list + assert "Supported link modes" in ethtool_info.settings + assert "10mockbaseT/Half" in ethtool_info.settings["Supported link modes"] + + +def test_parse_ethtool_advertised_link_modes(collector): + """Test parsing advertised link modes from ethtool output""" + ethtool_info = collector._parse_ethtool("ethmock123", ETHTOOL_OUTPUT) + + # Check advertised link modes are stored in settings dict + # Note: The current implementation stores link modes in settings dict, + # not in the advertised_link_modes list + assert "Advertised link modes" in ethtool_info.settings + assert "10mockbaseT/Half" in ethtool_info.settings["Advertised link modes"] + assert "10mockbaseT/Full" in ethtool_info.settings["Advertised link modes"] + + +def test_parse_ethtool_no_link(collector): + """Test parsing ethtool output when link is down""" + ethtool_info = collector._parse_ethtool("ethmock1", ETHTOOL_NO_LINK_OUTPUT) + + assert ethtool_info.interface == "ethmock1" + assert ethtool_info.speed == "Unknown!" + assert ethtool_info.duplex == "Unknown!" + assert ethtool_info.port == "FIBRE" + assert ethtool_info.auto_negotiation == "off" + assert ethtool_info.link_detected == "no" + # Check supported link modes are stored in settings dict + assert "Supported link modes" in ethtool_info.settings + assert "11122mockbaseT/Full" in ethtool_info.settings["Supported link modes"] + + +def test_parse_ethtool_empty_output(collector): + """Test parsing empty ethtool output""" + ethtool_info = collector._parse_ethtool("eth0", "") + + assert ethtool_info.interface == "eth0" + assert ethtool_info.speed is None + assert ethtool_info.duplex is None + assert ethtool_info.link_detected is None + assert len(ethtool_info.settings) == 0 + assert len(ethtool_info.supported_link_modes) == 0 + assert len(ethtool_info.advertised_link_modes) == 0 + + def test_network_data_model_creation(collector): """Test creating NetworkDataModel with all components""" interface = NetworkInterface( - name="eth0", + name="ethmock123", index=1, state="UP", mtu=5678, addresses=[IpAddress(address="1.123.123.100", prefix_len=24, family="inet")], ) - route = Route(destination="default", gateway="2.123.123.1", device="eth0") + route = Route(destination="default", gateway="2.123.123.1", device="ethmock123") rule = RoutingRule(priority=100, source="1.123.123.0/24", table="main") neighbor = Neighbor( - ip_address="50.50.1.1", device="eth0", mac_address="11:22:33:44:55:66", state="REACHABLE" + ip_address="50.50.1.1", + device="ethmock123", + mac_address="11:22:33:44:55:66", + state="REACHABLE", + ) + + ethtool_info = EthtoolInfo( + interface="ethmock123", raw_output=ETHTOOL_OUTPUT, speed="1000mockMb/s", duplex="Full" ) data = NetworkDataModel( - interfaces=[interface], routes=[route], rules=[rule], neighbors=[neighbor] + interfaces=[interface], + routes=[route], + rules=[rule], + neighbors=[neighbor], + ethtool_info={"ethmock123": ethtool_info}, ) assert len(data.interfaces) == 1 assert len(data.routes) == 1 assert len(data.rules) == 1 assert len(data.neighbors) == 1 - assert data.interfaces[0].name == "eth0" + assert len(data.ethtool_info) == 1 + assert data.interfaces[0].name == "ethmock123" + assert data.ethtool_info["ethmock123"].speed == "1000mockMb/s"