From 567e14dac7656a68c3545470023f363a1fb0c0ee Mon Sep 17 00:00:00 2001 From: jaspals Date: Wed, 10 Dec 2025 00:07:30 +0000 Subject: [PATCH 1/5] support for network plugin --- .../plugins/inband/network/__init__.py | 28 ++ .../inband/network/network_collector.py | 465 ++++++++++++++++++ .../plugins/inband/network/network_plugin.py | 41 ++ .../plugins/inband/network/networkdata.py | 99 ++++ 4 files changed, 633 insertions(+) create mode 100644 nodescraper/plugins/inband/network/__init__.py create mode 100644 nodescraper/plugins/inband/network/network_collector.py create mode 100644 nodescraper/plugins/inband/network/network_plugin.py create mode 100644 nodescraper/plugins/inband/network/networkdata.py diff --git a/nodescraper/plugins/inband/network/__init__.py b/nodescraper/plugins/inband/network/__init__.py new file mode 100644 index 00000000..b3119397 --- /dev/null +++ b/nodescraper/plugins/inband/network/__init__.py @@ -0,0 +1,28 @@ +############################################################################### +# +# 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. +# +############################################################################### +from .network_plugin import NetworkPlugin + +__all__ = ["NetworkPlugin"] diff --git a/nodescraper/plugins/inband/network/network_collector.py b/nodescraper/plugins/inband/network/network_collector.py new file mode 100644 index 00000000..a179829e --- /dev/null +++ b/nodescraper/plugins/inband/network/network_collector.py @@ -0,0 +1,465 @@ +############################################################################### +# +# 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. +# +############################################################################### +import re +from typing import Optional + +from nodescraper.base import InBandDataCollector +from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus +from nodescraper.models import TaskResult + +from .networkdata import ( + IpAddress, + Neighbor, + NetworkDataModel, + NetworkInterface, + Route, + RoutingRule, +) + + +class NetworkCollector(InBandDataCollector[NetworkDataModel, None]): + """Collect network configuration details using ip command""" + + DATA_MODEL = NetworkDataModel + CMD_ADDR = "ip addr show" + CMD_ROUTE = "ip route show" + CMD_RULE = "ip rule show" + CMD_NEIGHBOR = "ip neighbor show" + + def _parse_ip_addr(self, output: str) -> list[NetworkInterface]: + """Parse 'ip addr show' output into NetworkInterface objects. + + Args: + output: Raw output from 'ip addr show' command + + Returns: + List of NetworkInterface objects + """ + interfaces = {} + current_interface = None + + for line in output.splitlines(): + # Check if this is an interface header line + # Format: 1: lo: mtu 65536 qdisc noqueue state UNKNOWN ... + if re.match(r"^\d+:", line): + parts = line.split() + + # Extract interface index and name + idx_str = parts[0].rstrip(":") + try: + index = int(idx_str) + except ValueError: + index = None + + ifname = parts[1].rstrip(":") + current_interface = ifname + + # Extract flags + flags: list[str] = [] + if "<" in line: + flag_match = re.search(r"<([^>]+)>", line) + if flag_match: + flags = flag_match.group(1).split(",") + + # Extract other attributes + mtu = None + qdisc = None + state = None + + for i, part in enumerate(parts): + if part == "mtu" and i + 1 < len(parts): + try: + mtu = int(parts[i + 1]) + except ValueError: + pass + elif part == "qdisc" and i + 1 < len(parts): + qdisc = parts[i + 1] + elif part == "state" and i + 1 < len(parts): + state = parts[i + 1] + + interfaces[ifname] = NetworkInterface( + name=ifname, + index=index, + state=state, + mtu=mtu, + qdisc=qdisc, + flags=flags, + ) + + # Check if this is a link line (contains MAC address) + # Format: link/ether 00:40:a6:96:d7:5a brd ff:ff:ff:ff:ff:ff + elif "link/" in line and current_interface: + parts = line.split() + if "link/ether" in parts: + idx = parts.index("link/ether") + if idx + 1 < len(parts): + interfaces[current_interface].mac_address = parts[idx + 1] + elif "link/loopback" in parts: + # Loopback interface + if len(parts) > 1: + interfaces[current_interface].mac_address = parts[1] + + # Check if this is an inet/inet6 address line + # Format: inet 10.228.152.67/22 brd 10.228.155.255 scope global noprefixroute enp129s0 + elif any(x in line for x in ["inet ", "inet6 "]) and current_interface: + parts = line.split() + + # Parse the IP address + family = None + address = None + prefix_len = None + scope = None + broadcast = None + + for i, part in enumerate(parts): + if part in ["inet", "inet6"]: + family = part + if i + 1 < len(parts): + addr_part = parts[i + 1] + if "/" in addr_part: + address, prefix = addr_part.split("/") + try: + prefix_len = int(prefix) + except ValueError: + pass + else: + address = addr_part + elif part == "scope" and i + 1 < len(parts): + scope = parts[i + 1] + elif part in ["brd", "broadcast"] and i + 1 < len(parts): + broadcast = parts[i + 1] + + if address and current_interface in interfaces: + ip_addr = IpAddress( + address=address, + prefix_len=prefix_len, + family=family, + scope=scope, + broadcast=broadcast, + label=current_interface, + ) + interfaces[current_interface].addresses.append(ip_addr) + + return list(interfaces.values()) + + def _parse_ip_route(self, output: str) -> list[Route]: + """Parse 'ip route show' output into Route objects. + + Args: + output: Raw output from 'ip route show' command + + Returns: + List of Route objects + """ + routes = [] + + for line in output.splitlines(): + line = line.strip() + if not line: + continue + + parts = line.split() + if not parts: + continue + + # First part is destination (can be "default" or a network) + destination = parts[0] + + route = Route(destination=destination) + + # Parse route attributes + i = 1 + while i < len(parts): + if parts[i] == "via" and i + 1 < len(parts): + route.gateway = parts[i + 1] + i += 2 + elif parts[i] == "dev" and i + 1 < len(parts): + route.device = parts[i + 1] + i += 2 + elif parts[i] == "proto" and i + 1 < len(parts): + route.protocol = parts[i + 1] + i += 2 + elif parts[i] == "scope" and i + 1 < len(parts): + route.scope = parts[i + 1] + i += 2 + elif parts[i] == "metric" and i + 1 < len(parts): + try: + route.metric = int(parts[i + 1]) + except ValueError: + pass + i += 2 + elif parts[i] == "src" and i + 1 < len(parts): + route.source = parts[i + 1] + i += 2 + elif parts[i] == "table" and i + 1 < len(parts): + route.table = parts[i + 1] + i += 2 + else: + i += 1 + + routes.append(route) + + return routes + + def _parse_ip_rule(self, output: str) -> list[RoutingRule]: + """Parse 'ip rule show' output into RoutingRule objects. + Example ip rule: 200: from 172.16.0.0/12 to 8.8.8.8 iif wlan0 oif eth0 fwmark 0x20 table vpn_table + + Args: + output: Raw output from 'ip rule show' command + + Returns: + List of RoutingRule objects + """ + rules = [] + + for line in output.splitlines(): + line = line.strip() + if not line: + continue + + parts = line.split() + if not parts: + continue + + # First part is priority followed by ":" + priority_str = parts[0].rstrip(":") + try: + priority = int(priority_str) + except ValueError: + continue + + rule = RoutingRule(priority=priority) + + # Parse rule attributes + i = 1 + while i < len(parts): + if parts[i] == "from" and i + 1 < len(parts): + if parts[i + 1] != "all": + rule.source = parts[i + 1] + i += 2 + elif parts[i] == "to" and i + 1 < len(parts): + if parts[i + 1] != "all": + rule.destination = parts[i + 1] + i += 2 + elif parts[i] in ["lookup", "table"] and i + 1 < len(parts): + rule.table = parts[i + 1] + if parts[i] == "lookup": + rule.action = "lookup" + i += 2 + elif parts[i] == "iif" and i + 1 < len(parts): + rule.iif = parts[i + 1] + i += 2 + elif parts[i] == "oif" and i + 1 < len(parts): + rule.oif = parts[i + 1] + i += 2 + elif parts[i] == "fwmark" and i + 1 < len(parts): + rule.fwmark = parts[i + 1] + i += 2 + elif parts[i] in ["unreachable", "prohibit", "blackhole"]: + rule.action = parts[i] + i += 1 + else: + i += 1 + + rules.append(rule) + + return rules + + def _parse_ip_neighbor(self, output: str) -> list[Neighbor]: + """Parse 'ip neighbor show' output into Neighbor objects. + + Args: + output: Raw output from 'ip neighbor show' command + + Returns: + List of Neighbor objects + """ + neighbors = [] + + # Known keyword-value pairs (keyword takes next element as value) + keyword_value_pairs = ["dev", "lladdr", "nud", "vlan", "via"] + + for line in output.splitlines(): + line = line.strip() + if not line: + continue + + parts = line.split() + if not parts: + continue + + # First part is the IP address + ip_address = parts[0] + + neighbor = Neighbor(ip_address=ip_address) + + # Parse neighbor attributes + i = 1 + while i < len(parts): + current = parts[i] + + # Check for known keyword-value pairs + if current in keyword_value_pairs and i + 1 < len(parts): + if current == "dev": + neighbor.device = parts[i + 1] + elif current == "lladdr": + neighbor.mac_address = parts[i + 1] + # Other keyword-value pairs can be added here as needed + i += 2 + + # Check if it's a state (all uppercase, typically single word) + elif current.isupper() and current.isalpha(): + # States: REACHABLE, STALE, DELAY, PROBE, FAILED, INCOMPLETE, PERMANENT, NOARP + # Future states will also be captured + neighbor.state = current + i += 1 + + # Check if it looks like a MAC address (contains colons) + elif ":" in current and not current.startswith("http"): + # Already handled by lladdr, but in case it appears standalone + if not neighbor.mac_address: + neighbor.mac_address = current + i += 1 + + # Check if it looks like an IP address (has dots or is IPv6) + elif "." in current or ("::" in current): + # Skip IP addresses that might appear (already captured as first element) + i += 1 + + # Anything else that's a simple lowercase word is likely a flag + elif current.isalpha() and current.islower(): + # Flags: router, proxy, extern_learn, offload, managed, etc. + # Captures both known and future flags + neighbor.flags.append(current) + i += 1 + + else: + # Unknown format, skip it + i += 1 + + neighbors.append(neighbor) + + return neighbors + + def collect_data( + self, + args=None, + ) -> tuple[TaskResult, Optional[NetworkDataModel]]: + """Collect network configuration from the system. + + Returns: + tuple[TaskResult, Optional[NetworkDataModel]]: tuple containing the task result + and an instance of NetworkDataModel or None if collection failed. + """ + interfaces = [] + routes = [] + rules = [] + neighbors = [] + + # Collect interface/address information + res_addr = self._run_sut_cmd(self.CMD_ADDR) + if res_addr.exit_code == 0: + interfaces = self._parse_ip_addr(res_addr.stdout) + self._log_event( + category=EventCategory.OS, + description=f"Collected {len(interfaces)} network interfaces", + priority=EventPriority.INFO, + ) + else: + self._log_event( + category=EventCategory.OS, + description="Error collecting network interfaces", + data={"command": res_addr.command, "exit_code": res_addr.exit_code}, + priority=EventPriority.ERROR, + console_log=True, + ) + + # Collect routing table + res_route = self._run_sut_cmd(self.CMD_ROUTE) + if res_route.exit_code == 0: + routes = self._parse_ip_route(res_route.stdout) + self._log_event( + category=EventCategory.OS, + description=f"Collected {len(routes)} routes", + priority=EventPriority.INFO, + ) + else: + self._log_event( + category=EventCategory.OS, + description="Error collecting routes", + data={"command": res_route.command, "exit_code": res_route.exit_code}, + priority=EventPriority.WARNING, + ) + + # Collect routing rules + res_rule = self._run_sut_cmd(self.CMD_RULE) + if res_rule.exit_code == 0: + rules = self._parse_ip_rule(res_rule.stdout) + self._log_event( + category=EventCategory.OS, + description=f"Collected {len(rules)} routing rules", + priority=EventPriority.INFO, + ) + else: + self._log_event( + category=EventCategory.OS, + description="Error collecting routing rules", + data={"command": res_rule.command, "exit_code": res_rule.exit_code}, + priority=EventPriority.WARNING, + ) + + # Collect neighbor table (ARP/NDP) + res_neighbor = self._run_sut_cmd(self.CMD_NEIGHBOR) + if res_neighbor.exit_code == 0: + neighbors = self._parse_ip_neighbor(res_neighbor.stdout) + self._log_event( + category=EventCategory.OS, + description=f"Collected {len(neighbors)} neighbor entries", + priority=EventPriority.INFO, + ) + else: + self._log_event( + category=EventCategory.OS, + description="Error collecting neighbor table", + data={"command": res_neighbor.command, "exit_code": res_neighbor.exit_code}, + priority=EventPriority.WARNING, + ) + + if interfaces or routes or rules or neighbors: + network_data = NetworkDataModel( + interfaces=interfaces, routes=routes, rules=rules, neighbors=neighbors + ) + self.result.message = ( + f"Collected network data: {len(interfaces)} interfaces, " + f"{len(routes)} routes, {len(rules)} rules, {len(neighbors)} neighbors" + ) + self.result.status = ExecutionStatus.OK + return self.result, network_data + else: + self.result.message = "Failed to collect network data" + self.result.status = ExecutionStatus.ERROR + return self.result, None diff --git a/nodescraper/plugins/inband/network/network_plugin.py b/nodescraper/plugins/inband/network/network_plugin.py new file mode 100644 index 00000000..04f71be7 --- /dev/null +++ b/nodescraper/plugins/inband/network/network_plugin.py @@ -0,0 +1,41 @@ +############################################################################### +# +# 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. +# +############################################################################### +from nodescraper.base import InBandDataPlugin + +from .network_collector import NetworkCollector +from .networkdata import NetworkDataModel + + +class NetworkPlugin(InBandDataPlugin[NetworkDataModel, None, None]): + """Plugin for collection of network configuration data""" + + DATA_MODEL = NetworkDataModel + + COLLECTOR = NetworkCollector + + ANALYZER = None + + ANALYZER_ARGS = None diff --git a/nodescraper/plugins/inband/network/networkdata.py b/nodescraper/plugins/inband/network/networkdata.py new file mode 100644 index 00000000..2a28a64c --- /dev/null +++ b/nodescraper/plugins/inband/network/networkdata.py @@ -0,0 +1,99 @@ +############################################################################### +# +# 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. +# +############################################################################### +from typing import Optional + +from pydantic import BaseModel, Field + +from nodescraper.models import DataModel + + +class IpAddress(BaseModel): + """Individual IP address on an interface""" + + address: str # "192.168.1.100" + prefix_len: Optional[int] = None # 24 + scope: Optional[str] = None # "global", "link", "host" + family: Optional[str] = None # "inet", "inet6" + label: Optional[str] = None # interface label/alias + broadcast: Optional[str] = None # broadcast address + + +class NetworkInterface(BaseModel): + """Network interface information""" + + name: str # "eth0", "lo", etc + index: Optional[int] = None # interface index + state: Optional[str] = None # "UP", "DOWN", "UNKNOWN" + mtu: Optional[int] = None # Maximum Transmission Unit + qdisc: Optional[str] = None # Queuing discipline + mac_address: Optional[str] = None # MAC/hardware address + flags: list[str] = Field(default_factory=list) # ["UP", "BROADCAST", "MULTICAST"] + addresses: list[IpAddress] = Field(default_factory=list) # IP addresses on this interface + + +class Route(BaseModel): + """Routing table entry""" + + destination: str # "default", "192.168.1.0/24", etc + gateway: Optional[str] = None # Gateway IP + device: Optional[str] = None # Network interface + protocol: Optional[str] = None # "kernel", "boot", "static", etc + scope: Optional[str] = None # "link", "global", "host" + metric: Optional[int] = None # Route metric/priority + source: Optional[str] = None # Preferred source address + table: Optional[str] = None # Routing table name/number + + +class RoutingRule(BaseModel): + """Routing policy rule""" + + priority: int # Rule priority + source: Optional[str] = None # Source address/network + destination: Optional[str] = None # Destination address/network + table: Optional[str] = None # Routing table to use + action: Optional[str] = None # "lookup", "unreachable", "prohibit", etc + iif: Optional[str] = None # Input interface + oif: Optional[str] = None # Output interface + fwmark: Optional[str] = None # Firewall mark + + +class Neighbor(BaseModel): + """ARP/Neighbor table entry""" + + ip_address: str # IP address of the neighbor + device: Optional[str] = None # Network interface + mac_address: Optional[str] = None # Link layer (MAC) address + state: Optional[str] = None # "REACHABLE", "STALE", "DELAY", "PROBE", "FAILED", "INCOMPLETE" + flags: list[str] = Field(default_factory=list) # Additional flags like "router", "proxy" + + +class NetworkDataModel(DataModel): + """Complete network configuration data""" + + interfaces: list[NetworkInterface] = Field(default_factory=list) + routes: list[Route] = Field(default_factory=list) + rules: list[RoutingRule] = Field(default_factory=list) + neighbors: list[Neighbor] = Field(default_factory=list) From d963fa2f3cc29c12468893f17fa4ff4ef801792a Mon Sep 17 00:00:00 2001 From: jaspals Date: Wed, 10 Dec 2025 22:21:03 +0000 Subject: [PATCH 2/5] added network plugin tests support --- .../inband/network/network_collector.py | 85 ++-- .../plugins/inband/network/networkdata.py | 16 +- test/functional/test_run_plugins.py | 1 + test/unit/plugin/test_network_collector.py | 418 ++++++++++++++++++ 4 files changed, 472 insertions(+), 48 deletions(-) create mode 100644 test/unit/plugin/test_network_collector.py diff --git a/nodescraper/plugins/inband/network/network_collector.py b/nodescraper/plugins/inband/network/network_collector.py index a179829e..660a6d9e 100644 --- a/nodescraper/plugins/inband/network/network_collector.py +++ b/nodescraper/plugins/inband/network/network_collector.py @@ -24,7 +24,7 @@ # ############################################################################### import re -from typing import Optional +from typing import List, Optional, Tuple from nodescraper.base import InBandDataCollector from nodescraper.enums import EventCategory, EventPriority, ExecutionStatus @@ -49,7 +49,7 @@ class NetworkCollector(InBandDataCollector[NetworkDataModel, None]): CMD_RULE = "ip rule show" CMD_NEIGHBOR = "ip neighbor show" - def _parse_ip_addr(self, output: str) -> list[NetworkInterface]: + def _parse_ip_addr(self, output: str) -> List[NetworkInterface]: """Parse 'ip addr show' output into NetworkInterface objects. Args: @@ -78,7 +78,7 @@ def _parse_ip_addr(self, output: str) -> list[NetworkInterface]: current_interface = ifname # Extract flags - flags: list[str] = [] + flags: List[str] = [] if "<" in line: flag_match = re.search(r"<([^>]+)>", line) if flag_match: @@ -89,16 +89,20 @@ def _parse_ip_addr(self, output: str) -> list[NetworkInterface]: qdisc = None state = None + # Known keyword-value pairs + keyword_value_pairs = ["mtu", "qdisc", "state"] + for i, part in enumerate(parts): - if part == "mtu" and i + 1 < len(parts): - try: - mtu = int(parts[i + 1]) - except ValueError: - pass - elif part == "qdisc" and i + 1 < len(parts): - qdisc = parts[i + 1] - elif part == "state" and i + 1 < len(parts): - state = parts[i + 1] + if part in keyword_value_pairs and i + 1 < len(parts): + if part == "mtu": + try: + mtu = int(parts[i + 1]) + except ValueError: + pass + elif part == "qdisc": + qdisc = parts[i + 1] + elif part == "state": + state = parts[i + 1] interfaces[ifname] = NetworkInterface( name=ifname, @@ -165,7 +169,7 @@ def _parse_ip_addr(self, output: str) -> list[NetworkInterface]: return list(interfaces.values()) - def _parse_ip_route(self, output: str) -> list[Route]: + def _parse_ip_route(self, output: str) -> List[Route]: """Parse 'ip route show' output into Route objects. Args: @@ -190,32 +194,33 @@ def _parse_ip_route(self, output: str) -> list[Route]: route = Route(destination=destination) + # Known keyword-value pairs + keyword_value_pairs = ["via", "dev", "proto", "scope", "metric", "src", "table"] + # Parse route attributes i = 1 while i < len(parts): - if parts[i] == "via" and i + 1 < len(parts): - route.gateway = parts[i + 1] - i += 2 - elif parts[i] == "dev" and i + 1 < len(parts): - route.device = parts[i + 1] - i += 2 - elif parts[i] == "proto" and i + 1 < len(parts): - route.protocol = parts[i + 1] - i += 2 - elif parts[i] == "scope" and i + 1 < len(parts): - route.scope = parts[i + 1] - i += 2 - elif parts[i] == "metric" and i + 1 < len(parts): - try: - route.metric = int(parts[i + 1]) - except ValueError: - pass - i += 2 - elif parts[i] == "src" and i + 1 < len(parts): - route.source = parts[i + 1] - i += 2 - elif parts[i] == "table" and i + 1 < len(parts): - route.table = parts[i + 1] + if parts[i] in keyword_value_pairs and i + 1 < len(parts): + keyword = parts[i] + value = parts[i + 1] + + if keyword == "via": + route.gateway = value + elif keyword == "dev": + route.device = value + elif keyword == "proto": + route.protocol = value + elif keyword == "scope": + route.scope = value + elif keyword == "metric": + try: + route.metric = int(value) + except ValueError: + pass + elif keyword == "src": + route.source = value + elif keyword == "table": + route.table = value i += 2 else: i += 1 @@ -224,7 +229,7 @@ def _parse_ip_route(self, output: str) -> list[Route]: return routes - def _parse_ip_rule(self, output: str) -> list[RoutingRule]: + def _parse_ip_rule(self, output: str) -> List[RoutingRule]: """Parse 'ip rule show' output into RoutingRule objects. Example ip rule: 200: from 172.16.0.0/12 to 8.8.8.8 iif wlan0 oif eth0 fwmark 0x20 table vpn_table @@ -289,7 +294,7 @@ def _parse_ip_rule(self, output: str) -> list[RoutingRule]: return rules - def _parse_ip_neighbor(self, output: str) -> list[Neighbor]: + def _parse_ip_neighbor(self, output: str) -> List[Neighbor]: """Parse 'ip neighbor show' output into Neighbor objects. Args: @@ -368,11 +373,11 @@ def _parse_ip_neighbor(self, output: str) -> list[Neighbor]: def collect_data( self, args=None, - ) -> tuple[TaskResult, Optional[NetworkDataModel]]: + ) -> Tuple[TaskResult, Optional[NetworkDataModel]]: """Collect network configuration from the system. Returns: - tuple[TaskResult, Optional[NetworkDataModel]]: tuple containing the task result + Tuple[TaskResult, Optional[NetworkDataModel]]: tuple containing the task result and an instance of NetworkDataModel or None if collection failed. """ interfaces = [] diff --git a/nodescraper/plugins/inband/network/networkdata.py b/nodescraper/plugins/inband/network/networkdata.py index 2a28a64c..8cccf4b7 100644 --- a/nodescraper/plugins/inband/network/networkdata.py +++ b/nodescraper/plugins/inband/network/networkdata.py @@ -23,7 +23,7 @@ # SOFTWARE. # ############################################################################### -from typing import Optional +from typing import List, Optional from pydantic import BaseModel, Field @@ -50,8 +50,8 @@ class NetworkInterface(BaseModel): mtu: Optional[int] = None # Maximum Transmission Unit qdisc: Optional[str] = None # Queuing discipline mac_address: Optional[str] = None # MAC/hardware address - flags: list[str] = Field(default_factory=list) # ["UP", "BROADCAST", "MULTICAST"] - addresses: list[IpAddress] = Field(default_factory=list) # IP addresses on this interface + flags: List[str] = Field(default_factory=list) # ["UP", "BROADCAST", "MULTICAST"] + addresses: List[IpAddress] = Field(default_factory=list) # IP addresses on this interface class Route(BaseModel): @@ -87,13 +87,13 @@ class Neighbor(BaseModel): device: Optional[str] = None # Network interface mac_address: Optional[str] = None # Link layer (MAC) address state: Optional[str] = None # "REACHABLE", "STALE", "DELAY", "PROBE", "FAILED", "INCOMPLETE" - flags: list[str] = Field(default_factory=list) # Additional flags like "router", "proxy" + flags: List[str] = Field(default_factory=list) # Additional flags like "router", "proxy" class NetworkDataModel(DataModel): """Complete network configuration data""" - interfaces: list[NetworkInterface] = Field(default_factory=list) - routes: list[Route] = Field(default_factory=list) - rules: list[RoutingRule] = Field(default_factory=list) - neighbors: list[Neighbor] = Field(default_factory=list) + interfaces: List[NetworkInterface] = Field(default_factory=list) + routes: List[Route] = Field(default_factory=list) + rules: List[RoutingRule] = Field(default_factory=list) + neighbors: List[Neighbor] = Field(default_factory=list) diff --git a/test/functional/test_run_plugins.py b/test/functional/test_run_plugins.py index 6cb34cb4..0253784e 100644 --- a/test/functional/test_run_plugins.py +++ b/test/functional/test_run_plugins.py @@ -54,6 +54,7 @@ def test_plugin_registry_has_plugins(all_plugins): "KernelPlugin", "KernelModulePlugin", "MemoryPlugin", + "NetworkPlugin", "NvmePlugin", "OsPlugin", "PackagePlugin", diff --git a/test/unit/plugin/test_network_collector.py b/test/unit/plugin/test_network_collector.py new file mode 100644 index 00000000..9615fa80 --- /dev/null +++ b/test/unit/plugin/test_network_collector.py @@ -0,0 +1,418 @@ +############################################################################### +# +# 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. +# +############################################################################### +from unittest.mock import MagicMock + +import pytest + +from nodescraper.enums.executionstatus import ExecutionStatus +from nodescraper.enums.systeminteraction import SystemInteractionLevel +from nodescraper.models.systeminfo import OSFamily +from nodescraper.plugins.inband.network.network_collector import NetworkCollector +from nodescraper.plugins.inband.network.networkdata import ( + IpAddress, + Neighbor, + NetworkDataModel, + NetworkInterface, + Route, + RoutingRule, +) + + +@pytest.fixture +def collector(system_info, conn_mock): + return NetworkCollector( + system_info=system_info, + system_interaction_level=SystemInteractionLevel.PASSIVE, + connection=conn_mock, + ) + + +# Sample command outputs for testing +IP_ADDR_OUTPUT = """1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 + link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 + inet 127.0.0.1/8 scope host lo + valid_lft forever preferred_lft forever + inet6 ::1/128 scope host + valid_lft forever preferred_lft forever +2: enp129s0: mtu 1500 qdisc mq state UP group default qlen 1000 + link/ether 00:40:a6:96:d7:5a brd ff:ff:ff:ff:ff:ff + inet 10.228.152.67/22 brd 10.228.155.255 scope global noprefixroute enp129s0 + valid_lft forever preferred_lft forever + inet6 fe80::240:a6ff:fe96:d75a/64 scope link + valid_lft forever preferred_lft forever""" + +IP_ROUTE_OUTPUT = """default via 10.228.152.1 dev enp129s0 proto static metric 100 +10.228.152.0/22 dev enp129s0 proto kernel scope link src 10.228.152.67 metric 100 +172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown""" + +IP_RULE_OUTPUT = """0: from all lookup local +32766: from all lookup main +32767: from all lookup default""" + +IP_NEIGHBOR_OUTPUT = """10.228.152.44 dev enp129s0 lladdr 00:50:56:b4:ed:cc STALE +10.228.152.1 dev enp129s0 lladdr 64:3a:ea:74:e6:bf REACHABLE""" + + +def test_parse_ip_addr_loopback(collector): + """Test parsing loopback interface from ip addr output""" + interfaces = collector._parse_ip_addr(IP_ADDR_OUTPUT) + + # Find loopback interface + lo = next((i for i in interfaces if i.name == "lo"), None) + assert lo is not None + assert lo.index == 1 + assert lo.state == "UNKNOWN" + assert lo.mtu == 65536 + assert lo.qdisc == "noqueue" + assert lo.mac_address == "00:00:00:00:00:00" + assert "LOOPBACK" in lo.flags + assert "UP" in lo.flags + + # Check addresses + assert len(lo.addresses) == 2 + ipv4 = next((a for a in lo.addresses if a.family == "inet"), None) + assert ipv4 is not None + assert ipv4.address == "127.0.0.1" + assert ipv4.prefix_len == 8 + assert ipv4.scope == "host" + + +def test_parse_ip_addr_ethernet(collector): + """Test parsing ethernet interface from ip addr output""" + interfaces = collector._parse_ip_addr(IP_ADDR_OUTPUT) + + # Find ethernet interface + eth = next((i for i in interfaces if i.name == "enp129s0"), None) + assert eth is not None + assert eth.index == 2 + assert eth.state == "UP" + assert eth.mtu == 1500 + assert eth.qdisc == "mq" + assert eth.mac_address == "00:40:a6:96:d7:5a" + assert "BROADCAST" in eth.flags + assert "MULTICAST" in eth.flags + + # Check IPv4 address + ipv4 = next((a for a in eth.addresses if a.family == "inet"), None) + assert ipv4 is not None + assert ipv4.address == "10.228.152.67" + assert ipv4.prefix_len == 22 + assert ipv4.broadcast == "10.228.155.255" + assert ipv4.scope == "global" + + +def test_parse_ip_route_default(collector): + """Test parsing default route""" + routes = collector._parse_ip_route(IP_ROUTE_OUTPUT) + + # Find default route + default_route = next((r for r in routes if r.destination == "default"), None) + assert default_route is not None + assert default_route.gateway == "10.228.152.1" + assert default_route.device == "enp129s0" + assert default_route.protocol == "static" + assert default_route.metric == 100 + + +def test_parse_ip_route_network(collector): + """Test parsing network route with source""" + routes = collector._parse_ip_route(IP_ROUTE_OUTPUT) + + # Find network route + net_route = next((r for r in routes if r.destination == "10.228.152.0/22"), None) + assert net_route is not None + assert net_route.gateway is None # Direct route, no gateway + assert net_route.device == "enp129s0" + assert net_route.protocol == "kernel" + assert net_route.scope == "link" + assert net_route.source == "10.228.152.67" + assert net_route.metric == 100 + + +def test_parse_ip_route_docker(collector): + """Test parsing docker bridge route""" + routes = collector._parse_ip_route(IP_ROUTE_OUTPUT) + + # Find docker route + docker_route = next((r for r in routes if r.destination == "172.17.0.0/16"), None) + assert docker_route is not None + assert docker_route.gateway is None + assert docker_route.device == "docker0" + assert docker_route.protocol == "kernel" + assert docker_route.scope == "link" + assert docker_route.source == "172.17.0.1" + + +def test_parse_ip_rule_basic(collector): + """Test parsing routing rules""" + rules = collector._parse_ip_rule(IP_RULE_OUTPUT) + + assert len(rules) == 3 + + # Check local rule + local_rule = next((r for r in rules if r.priority == 0), None) + assert local_rule is not None + assert local_rule.source is None # "from all" + assert local_rule.destination is None + assert local_rule.table == "local" + assert local_rule.action == "lookup" + + # Check main rule + main_rule = next((r for r in rules if r.priority == 32766), None) + assert main_rule is not None + assert main_rule.table == "main" + + # Check default rule + default_rule = next((r for r in rules if r.priority == 32767), None) + assert default_rule is not None + assert default_rule.table == "default" + + +def test_parse_ip_rule_complex(collector): + """Test parsing complex routing rule with all fields""" + complex_rule_output = ( + "100: from 192.168.1.0/24 to 10.0.0.0/8 iif eth0 oif eth1 fwmark 0x10 lookup custom_table" + ) + + rules = collector._parse_ip_rule(complex_rule_output) + + assert len(rules) == 1 + rule = rules[0] + assert rule.priority == 100 + assert rule.source == "192.168.1.0/24" + assert rule.destination == "10.0.0.0/8" + assert rule.iif == "eth0" + assert rule.oif == "eth1" + assert rule.fwmark == "0x10" + assert rule.table == "custom_table" + assert rule.action == "lookup" + + +def test_parse_ip_neighbor_reachable(collector): + """Test parsing neighbor entries""" + neighbors = collector._parse_ip_neighbor(IP_NEIGHBOR_OUTPUT) + + # Check REACHABLE neighbor + reachable = next((n for n in neighbors if n.state == "REACHABLE"), None) + assert reachable is not None + assert reachable.ip_address == "10.228.152.1" + assert reachable.device == "enp129s0" + assert reachable.mac_address == "64:3a:ea:74:e6:bf" + assert reachable.state == "REACHABLE" + + +def test_parse_ip_neighbor_stale(collector): + """Test parsing STALE neighbor entry""" + neighbors = collector._parse_ip_neighbor(IP_NEIGHBOR_OUTPUT) + + # Check STALE neighbor + stale = next((n for n in neighbors if n.state == "STALE"), None) + assert stale is not None + assert stale.ip_address == "10.228.152.44" + assert stale.device == "enp129s0" + assert stale.mac_address == "00:50:56:b4:ed:cc" + assert stale.state == "STALE" + + +def test_parse_ip_neighbor_with_flags(collector): + """Test parsing neighbor with flags""" + neighbor_with_flags = "10.0.0.1 dev eth0 lladdr aa:bb:cc:dd:ee:ff REACHABLE router proxy" + + neighbors = collector._parse_ip_neighbor(neighbor_with_flags) + + assert len(neighbors) == 1 + neighbor = neighbors[0] + assert neighbor.ip_address == "10.0.0.1" + assert neighbor.mac_address == "aa:bb:cc:dd:ee:ff" + assert neighbor.state == "REACHABLE" + assert "router" in neighbor.flags + assert "proxy" in neighbor.flags + + +def test_collect_data_success(collector, conn_mock): + """Test successful collection of all network data""" + collector.system_info.os_family = OSFamily.LINUX + + # Mock successful command execution + def run_sut_cmd_side_effect(cmd): + if "addr show" in cmd: + return MagicMock(exit_code=0, stdout=IP_ADDR_OUTPUT, command=cmd) + elif "route show" in cmd: + return MagicMock(exit_code=0, stdout=IP_ROUTE_OUTPUT, command=cmd) + elif "rule show" in 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) + 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() + + assert result.status == ExecutionStatus.OK + assert data is not None + assert isinstance(data, NetworkDataModel) + assert len(data.interfaces) == 2 + assert len(data.routes) == 3 + assert len(data.rules) == 3 + assert len(data.neighbors) == 2 + assert "2 interfaces" in result.message + assert "3 routes" in result.message + assert "3 rules" in result.message + assert "2 neighbors" in result.message + + +def test_collect_data_addr_failure(collector, conn_mock): + """Test collection when ip addr command fails""" + collector.system_info.os_family = OSFamily.LINUX + + # Mock failed addr command but successful others + def run_sut_cmd_side_effect(cmd): + if "addr show" in cmd: + return MagicMock(exit_code=1, stdout="", command=cmd) + elif "route show" in cmd: + return MagicMock(exit_code=0, stdout=IP_ROUTE_OUTPUT, command=cmd) + elif "rule show" in 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) + 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() + + # Should still return data from successful commands + assert result.status == ExecutionStatus.OK + assert data is not None + assert len(data.interfaces) == 0 # Failed + assert len(data.routes) == 3 # Success + assert len(data.rules) == 3 # Success + assert len(data.neighbors) == 2 # Success + assert len(result.events) > 0 + + +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")) + + result, data = collector.collect_data() + + assert result.status == ExecutionStatus.ERROR + assert data is None + assert len(result.events) > 0 + + +def test_parse_empty_output(collector): + """Test parsing empty command output""" + interfaces = collector._parse_ip_addr("") + routes = collector._parse_ip_route("") + rules = collector._parse_ip_rule("") + neighbors = collector._parse_ip_neighbor("") + + assert len(interfaces) == 0 + assert len(routes) == 0 + assert len(rules) == 0 + assert len(neighbors) == 0 + + +def test_parse_malformed_output(collector): + """Test parsing malformed output gracefully""" + malformed = "this is not valid ip output\nsome random text\n123 456" + + # Should not crash, just return empty or skip bad lines + interfaces = collector._parse_ip_addr(malformed) + routes = collector._parse_ip_route(malformed) + neighbors = collector._parse_ip_neighbor(malformed) + + # Parser should handle gracefully + assert isinstance(interfaces, list) + assert isinstance(routes, list) + assert isinstance(neighbors, list) + + +def test_parse_ip_addr_ipv6_only(collector): + """Test parsing interface with only IPv6 address""" + ipv6_only = """3: eth1: mtu 1500 qdisc pfifo_fast state UP qlen 1000 + link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff + inet6 fe80::a8bb:ccff:fedd:eeff/64 scope link + valid_lft forever preferred_lft forever""" + + interfaces = collector._parse_ip_addr(ipv6_only) + + assert len(interfaces) == 1 + eth1 = interfaces[0] + assert eth1.name == "eth1" + assert len(eth1.addresses) == 1 + assert eth1.addresses[0].family == "inet6" + assert eth1.addresses[0].address == "fe80::a8bb:ccff:fedd:eeff" + assert eth1.addresses[0].prefix_len == 64 + + +def test_parse_ip_rule_with_action(collector): + """Test parsing rule with unreachable action""" + rule_with_action = "200: from 10.0.0.5 unreachable" + + rules = collector._parse_ip_rule(rule_with_action) + + assert len(rules) == 1 + rule = rules[0] + assert rule.priority == 200 + assert rule.source == "10.0.0.5" + assert rule.action == "unreachable" + assert rule.table is None + + +def test_network_data_model_creation(collector): + """Test creating NetworkDataModel with all components""" + interface = NetworkInterface( + name="eth0", + index=1, + state="UP", + mtu=1500, + addresses=[IpAddress(address="192.168.1.100", prefix_len=24, family="inet")], + ) + + route = Route(destination="default", gateway="192.168.1.1", device="eth0") + + rule = RoutingRule(priority=100, source="192.168.1.0/24", table="main") + + neighbor = Neighbor( + ip_address="192.168.1.1", device="eth0", mac_address="11:22:33:44:55:66", state="REACHABLE" + ) + + data = NetworkDataModel( + interfaces=[interface], routes=[route], rules=[rule], neighbors=[neighbor] + ) + + 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" From 4fa736e52a9896afe0a82ba1e80fc5e8bd9fa233 Mon Sep 17 00:00:00 2001 From: jaspals Date: Fri, 12 Dec 2025 16:41:08 +0000 Subject: [PATCH 3/5] Update network collector tests with mock data values --- test/unit/plugin/test_network_collector.py | 80 +++++++++++----------- 1 file changed, 40 insertions(+), 40 deletions(-) diff --git a/test/unit/plugin/test_network_collector.py b/test/unit/plugin/test_network_collector.py index 9615fa80..e6e52e2a 100644 --- a/test/unit/plugin/test_network_collector.py +++ b/test/unit/plugin/test_network_collector.py @@ -50,30 +50,30 @@ def collector(system_info, conn_mock): ) -# Sample command outputs for testing -IP_ADDR_OUTPUT = """1: lo: mtu 65536 qdisc noqueue state UNKNOWN group default qlen 1000 +# Sample command outputs for testing (mock data) +IP_ADDR_OUTPUT = """1: lo: mtu 12345 qdisc noqueue state UNKNOWN group default qlen 1000 link/loopback 00:00:00:00:00:00 brd 00:00:00:00:00:00 inet 127.0.0.1/8 scope host lo valid_lft forever preferred_lft forever inet6 ::1/128 scope host valid_lft forever preferred_lft forever -2: enp129s0: mtu 1500 qdisc mq state UP group default qlen 1000 - link/ether 00:40:a6:96:d7:5a brd ff:ff:ff:ff:ff:ff - inet 10.228.152.67/22 brd 10.228.155.255 scope global noprefixroute enp129s0 +2: eth0: mtu 5678 qdisc mq state UP group default qlen 1000 + link/ether aa:bb:cc:dd:ee:ff brd ff:ff:ff:ff:ff:ff + inet 1.123.123.100/24 brd 1.123.123.255 scope global noprefixroute eth0 valid_lft forever preferred_lft forever - inet6 fe80::240:a6ff:fe96:d75a/64 scope link + inet6 fe80::aabb:ccff/64 scope link valid_lft forever preferred_lft forever""" -IP_ROUTE_OUTPUT = """default via 10.228.152.1 dev enp129s0 proto static metric 100 -10.228.152.0/22 dev enp129s0 proto kernel scope link src 10.228.152.67 metric 100 -172.17.0.0/16 dev docker0 proto kernel scope link src 172.17.0.1 linkdown""" +IP_ROUTE_OUTPUT = """default via 2.123.123.1 dev eth0 proto static metric 100 +2.123.123.0/24 dev eth0 proto kernel scope link src 2.123.123.100 metric 100 +7.8.0.0/16 dev docker0 proto kernel scope link src 7.8.0.1 linkdown""" IP_RULE_OUTPUT = """0: from all lookup local -32766: from all lookup main -32767: from all lookup default""" +89145: from all lookup main +56789: from all lookup default""" -IP_NEIGHBOR_OUTPUT = """10.228.152.44 dev enp129s0 lladdr 00:50:56:b4:ed:cc STALE -10.228.152.1 dev enp129s0 lladdr 64:3a:ea:74:e6:bf REACHABLE""" +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""" def test_parse_ip_addr_loopback(collector): @@ -85,7 +85,7 @@ def test_parse_ip_addr_loopback(collector): assert lo is not None assert lo.index == 1 assert lo.state == "UNKNOWN" - assert lo.mtu == 65536 + assert lo.mtu == 12345 assert lo.qdisc == "noqueue" assert lo.mac_address == "00:00:00:00:00:00" assert "LOOPBACK" in lo.flags @@ -105,22 +105,22 @@ def test_parse_ip_addr_ethernet(collector): interfaces = collector._parse_ip_addr(IP_ADDR_OUTPUT) # Find ethernet interface - eth = next((i for i in interfaces if i.name == "enp129s0"), None) + eth = next((i for i in interfaces if i.name == "eth0"), None) assert eth is not None assert eth.index == 2 assert eth.state == "UP" - assert eth.mtu == 1500 + assert eth.mtu == 5678 assert eth.qdisc == "mq" - assert eth.mac_address == "00:40:a6:96:d7:5a" + assert eth.mac_address == "aa:bb:cc:dd:ee:ff" assert "BROADCAST" in eth.flags assert "MULTICAST" in eth.flags # Check IPv4 address ipv4 = next((a for a in eth.addresses if a.family == "inet"), None) assert ipv4 is not None - assert ipv4.address == "10.228.152.67" - assert ipv4.prefix_len == 22 - assert ipv4.broadcast == "10.228.155.255" + assert ipv4.address == "1.123.123.100" + assert ipv4.prefix_len == 24 + assert ipv4.broadcast == "1.123.123.255" assert ipv4.scope == "global" @@ -131,8 +131,8 @@ def test_parse_ip_route_default(collector): # Find default route default_route = next((r for r in routes if r.destination == "default"), None) assert default_route is not None - assert default_route.gateway == "10.228.152.1" - assert default_route.device == "enp129s0" + assert default_route.gateway == "2.123.123.1" + assert default_route.device == "eth0" assert default_route.protocol == "static" assert default_route.metric == 100 @@ -142,13 +142,13 @@ def test_parse_ip_route_network(collector): routes = collector._parse_ip_route(IP_ROUTE_OUTPUT) # Find network route - net_route = next((r for r in routes if r.destination == "10.228.152.0/22"), None) + net_route = next((r for r in routes if r.destination == "2.123.123.0/24"), None) assert net_route is not None assert net_route.gateway is None # Direct route, no gateway - assert net_route.device == "enp129s0" + assert net_route.device == "eth0" assert net_route.protocol == "kernel" assert net_route.scope == "link" - assert net_route.source == "10.228.152.67" + assert net_route.source == "2.123.123.100" assert net_route.metric == 100 @@ -157,13 +157,13 @@ def test_parse_ip_route_docker(collector): routes = collector._parse_ip_route(IP_ROUTE_OUTPUT) # Find docker route - docker_route = next((r for r in routes if r.destination == "172.17.0.0/16"), None) + docker_route = next((r for r in routes if r.destination == "7.8.0.0/16"), None) assert docker_route is not None assert docker_route.gateway is None assert docker_route.device == "docker0" assert docker_route.protocol == "kernel" assert docker_route.scope == "link" - assert docker_route.source == "172.17.0.1" + assert docker_route.source == "7.8.0.1" def test_parse_ip_rule_basic(collector): @@ -181,12 +181,12 @@ def test_parse_ip_rule_basic(collector): assert local_rule.action == "lookup" # Check main rule - main_rule = next((r for r in rules if r.priority == 32766), None) + main_rule = next((r for r in rules if r.priority == 89145), None) assert main_rule is not None assert main_rule.table == "main" # Check default rule - default_rule = next((r for r in rules if r.priority == 32767), None) + default_rule = next((r for r in rules if r.priority == 56789), None) assert default_rule is not None assert default_rule.table == "default" @@ -218,9 +218,9 @@ def test_parse_ip_neighbor_reachable(collector): # Check REACHABLE neighbor reachable = next((n for n in neighbors if n.state == "REACHABLE"), None) assert reachable is not None - assert reachable.ip_address == "10.228.152.1" - assert reachable.device == "enp129s0" - assert reachable.mac_address == "64:3a:ea:74:e6:bf" + assert reachable.ip_address == "50.50.1.1" + assert reachable.device == "eth0" + assert reachable.mac_address == "99:88:77:66:55:44" assert reachable.state == "REACHABLE" @@ -231,9 +231,9 @@ def test_parse_ip_neighbor_stale(collector): # Check STALE neighbor stale = next((n for n in neighbors if n.state == "STALE"), None) assert stale is not None - assert stale.ip_address == "10.228.152.44" - assert stale.device == "enp129s0" - assert stale.mac_address == "00:50:56:b4:ed:cc" + assert stale.ip_address == "50.50.1.50" + assert stale.device == "eth0" + assert stale.mac_address == "11:22:33:44:55:66" assert stale.state == "STALE" @@ -395,16 +395,16 @@ def test_network_data_model_creation(collector): name="eth0", index=1, state="UP", - mtu=1500, - addresses=[IpAddress(address="192.168.1.100", prefix_len=24, family="inet")], + mtu=5678, + addresses=[IpAddress(address="1.123.123.100", prefix_len=24, family="inet")], ) - route = Route(destination="default", gateway="192.168.1.1", device="eth0") + route = Route(destination="default", gateway="2.123.123.1", device="eth0") - rule = RoutingRule(priority=100, source="192.168.1.0/24", table="main") + rule = RoutingRule(priority=100, source="1.123.123.0/24", table="main") neighbor = Neighbor( - ip_address="192.168.1.1", device="eth0", mac_address="11:22:33:44:55:66", state="REACHABLE" + ip_address="50.50.1.1", device="eth0", mac_address="11:22:33:44:55:66", state="REACHABLE" ) data = NetworkDataModel( From 4f38b5ff558c0f71e85b354b6418af7d11630b5b Mon Sep 17 00:00:00 2001 From: jaspals Date: Fri, 12 Dec 2025 20:12:10 +0000 Subject: [PATCH 4/5] removed analyzer args from network plugin --- nodescraper/plugins/inband/network/network_plugin.py | 4 ---- 1 file changed, 4 deletions(-) diff --git a/nodescraper/plugins/inband/network/network_plugin.py b/nodescraper/plugins/inband/network/network_plugin.py index 04f71be7..2735e705 100644 --- a/nodescraper/plugins/inband/network/network_plugin.py +++ b/nodescraper/plugins/inband/network/network_plugin.py @@ -35,7 +35,3 @@ class NetworkPlugin(InBandDataPlugin[NetworkDataModel, None, None]): DATA_MODEL = NetworkDataModel COLLECTOR = NetworkCollector - - ANALYZER = None - - ANALYZER_ARGS = None From e377b398d99d2ef3cc817181fd95bdef575320c3 Mon Sep 17 00:00:00 2001 From: jaspals Date: Mon, 15 Dec 2025 19:12:23 +0000 Subject: [PATCH 5/5] network plugin functional tests --- .../fixtures/network_plugin_config.json | 11 ++ test/functional/test_network_plugin.py | 106 ++++++++++++++++++ 2 files changed, 117 insertions(+) create mode 100644 test/functional/fixtures/network_plugin_config.json create mode 100644 test/functional/test_network_plugin.py diff --git a/test/functional/fixtures/network_plugin_config.json b/test/functional/fixtures/network_plugin_config.json new file mode 100644 index 00000000..aa4b6bc0 --- /dev/null +++ b/test/functional/fixtures/network_plugin_config.json @@ -0,0 +1,11 @@ +{ + "global_args": {}, + "plugins": { + "NetworkPlugin": { + "analysis_args": {} + } + }, + "result_collators": {}, + "name": "NetworkPlugin config", + "desc": "Config for testing NetworkPlugin" +} diff --git a/test/functional/test_network_plugin.py b/test/functional/test_network_plugin.py new file mode 100644 index 00000000..27776c8e --- /dev/null +++ b/test/functional/test_network_plugin.py @@ -0,0 +1,106 @@ +############################################################################### +# +# 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. +# +############################################################################### +"""Functional tests for NetworkPlugin with --plugin-configs.""" + +from pathlib import Path + +import pytest + + +@pytest.fixture +def fixtures_dir(): + """Return path to fixtures directory.""" + return Path(__file__).parent / "fixtures" + + +@pytest.fixture +def network_config_file(fixtures_dir): + """Return path to NetworkPlugin config file.""" + return fixtures_dir / "network_plugin_config.json" + + +def test_network_plugin_with_basic_config(run_cli_command, network_config_file, tmp_path): + """Test NetworkPlugin using basic config file.""" + assert network_config_file.exists(), f"Config file not found: {network_config_file}" + + log_path = str(tmp_path / "logs_network_basic") + result = run_cli_command( + ["--log-path", log_path, "--plugin-configs", str(network_config_file)], check=False + ) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0 + assert "networkplugin" in output.lower() or "network" in output.lower() + + +def test_network_plugin_with_run_plugins_subcommand(run_cli_command, tmp_path): + """Test NetworkPlugin using run-plugins subcommand.""" + log_path = str(tmp_path / "logs_network_subcommand") + result = run_cli_command(["--log-path", log_path, "run-plugins", "NetworkPlugin"], check=False) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0 + + +def test_network_plugin_with_passive_interaction(run_cli_command, network_config_file, tmp_path): + """Test NetworkPlugin with PASSIVE system interaction level.""" + log_path = str(tmp_path / "logs_network_passive") + result = run_cli_command( + [ + "--log-path", + log_path, + "--sys-interaction-level", + "PASSIVE", + "--plugin-configs", + str(network_config_file), + ], + check=False, + ) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0 + + +def test_network_plugin_skip_sudo(run_cli_command, network_config_file, tmp_path): + """Test NetworkPlugin with --skip-sudo flag.""" + log_path = str(tmp_path / "logs_network_no_sudo") + result = run_cli_command( + [ + "--log-path", + log_path, + "--skip-sudo", + "--plugin-configs", + str(network_config_file), + ], + check=False, + ) + + assert result.returncode in [0, 1, 2] + output = result.stdout + result.stderr + assert len(output) > 0