From mboxrd@z Thu Jan 1 00:00:00 1970 Return-Path: Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id C636242EAB; Tue, 18 Jul 2023 21:56:36 +0200 (CEST) Received: from mails.dpdk.org (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id 52C3C42D17; Tue, 18 Jul 2023 21:56:36 +0200 (CEST) Received: from mail-ot1-f49.google.com (mail-ot1-f49.google.com [209.85.210.49]) by mails.dpdk.org (Postfix) with ESMTP id 92010410D3 for ; Tue, 18 Jul 2023 21:56:34 +0200 (CEST) Received: by mail-ot1-f49.google.com with SMTP id 46e09a7af769-6b8b8de2c6bso4944031a34.0 for ; Tue, 18 Jul 2023 12:56:34 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=iol.unh.edu; s=unh-iol; t=1689710193; x=1692302193; h=cc:to:subject:message-id:date:from:in-reply-to:references :mime-version:from:to:cc:subject:date:message-id:reply-to; bh=J2vx7wJsu9yKngtrl8XhML+wtjsuMFJstX/JZ4VRZco=; b=UDJSSzIqNaY5T8vyCsp9uFqPi990I7eRkc8kgpK58zEpfnonw/CII5IXCiuRLgjBb7 i+VInd7DGx4U0OBQl5DV4+FibKzyTfFsb5m0iNLxvsR2eK9nlTGZP3JXUmkp3tsryTlg py/j3Ep3UjOaNYDO0cxUhVNE1L4MluL2QkT5Y= X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1689710193; x=1692302193; h=cc:to:subject:message-id:date:from:in-reply-to:references :mime-version:x-gm-message-state:from:to:cc:subject:date:message-id :reply-to; bh=J2vx7wJsu9yKngtrl8XhML+wtjsuMFJstX/JZ4VRZco=; b=AB3IcDXS/lcj67q1zMCV8Za2P0Fw5+MZ10u9dcJEqFcthk6ZPXMKe7sQx1+ul07W7h 4Rf1vi+9Zma16wpoYukO/0N34BD0ejy8vg/Eml3jGM/04OnDfGhyu1PtyUXsXPvS80bI CJNe3Nn+UR74KxWVQ0RGGwwkvrnv6yAARTRgyVtcr0Eyq90MYvUD5eIItF+7quMv0uuk QR7lA+5kpjOf0C1EubFGtXqJLcj743uTGuqVjAJORCs6SfYSWK+nWBRv90/xlRp6j/oh yWDGVsjQMcX+uW9+Ry/Wa2HtYap7FMaBaafn8aP4yNStR0PVK/omWbnV0pqJdSF141ni tepA== X-Gm-Message-State: ABy/qLZC//3acJ6+TDfA4+Kuh7aXOA5+u6MydLwru5LWMYJ/4FOOlMtG qzf/ENhRXdV/Ecah/Hq1ULmPd97bpaC7Vffg+mz9yQ== X-Google-Smtp-Source: APBJJlHp7atRvMr83GtQlwp/bDKbpLxHEw53C81AUB4LtkeVX7UsiuhhErENPbMFM8prcuJj5btElzR6wsXH62Nyu1M= X-Received: by 2002:a05:6358:c16:b0:134:c4dc:9e28 with SMTP id f22-20020a0563580c1600b00134c4dc9e28mr14321080rwj.17.1689710193500; Tue, 18 Jul 2023 12:56:33 -0700 (PDT) MIME-Version: 1.0 References: <20230420093109.594704-1-juraj.linkes@pantheon.tech> <20230717110709.39220-1-juraj.linkes@pantheon.tech> <20230717110709.39220-4-juraj.linkes@pantheon.tech> In-Reply-To: <20230717110709.39220-4-juraj.linkes@pantheon.tech> From: Jeremy Spewock Date: Tue, 18 Jul 2023 15:56:22 -0400 Message-ID: Subject: Re: [PATCH v2 3/6] dts: traffic generator abstractions To: =?UTF-8?Q?Juraj_Linke=C5=A1?= Cc: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com, probb@iol.unh.edu, dev@dpdk.org Content-Type: multipart/alternative; boundary="000000000000db7ae40600c84fc3" X-BeenThere: dev@dpdk.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: DPDK patches and discussions List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: dev-bounces@dpdk.org --000000000000db7ae40600c84fc3 Content-Type: text/plain; charset="UTF-8" Content-Transfer-Encoding: quoted-printable Hey Juraj, Just a couple of comments below, but very minor stuff. Just a few docstring that I commented on and one question about the factory for traffic generators that I was wondering what you thought about. More below: On Mon, Jul 17, 2023 at 7:07=E2=80=AFAM Juraj Linke=C5=A1 wrote: > There are traffic abstractions for all traffic generators and for > traffic generators that can capture (not just count) packets. > > There also related abstractions, such as TGNode where the traffic > generators reside and some related code. > > Signed-off-by: Juraj Linke=C5=A1 > --- > doc/guides/tools/dts.rst | 31 ++++ > dts/framework/dts.py | 61 ++++---- > dts/framework/remote_session/linux_session.py | 78 ++++++++++ > dts/framework/remote_session/os_session.py | 15 ++ > dts/framework/test_suite.py | 4 +- > dts/framework/testbed_model/__init__.py | 1 + > .../capturing_traffic_generator.py | 135 ++++++++++++++++++ > dts/framework/testbed_model/hw/port.py | 60 ++++++++ > dts/framework/testbed_model/node.py | 15 ++ > dts/framework/testbed_model/scapy.py | 74 ++++++++++ > dts/framework/testbed_model/tg_node.py | 99 +++++++++++++ > .../testbed_model/traffic_generator.py | 72 ++++++++++ > dts/framework/utils.py | 13 ++ > 13 files changed, 632 insertions(+), 26 deletions(-) > create mode 100644 > dts/framework/testbed_model/capturing_traffic_generator.py > create mode 100644 dts/framework/testbed_model/hw/port.py > create mode 100644 dts/framework/testbed_model/scapy.py > create mode 100644 dts/framework/testbed_model/tg_node.py > create mode 100644 dts/framework/testbed_model/traffic_generator.py > > diff --git a/doc/guides/tools/dts.rst b/doc/guides/tools/dts.rst > index c7b31623e4..2f97d1df6e 100644 > --- a/doc/guides/tools/dts.rst > +++ b/doc/guides/tools/dts.rst > @@ -153,6 +153,37 @@ There are two areas that need to be set up on a > System Under Test: > > sudo usermod -aG sudo > > + > +Setting up Traffic Generator Node > +--------------------------------- > + > +These need to be set up on a Traffic Generator Node: > + > +#. **Traffic generator dependencies** > + > + The traffic generator running on the traffic generator node must be > installed beforehand. > + For Scapy traffic generator, only a few Python libraries need to be > installed: > + > + .. code-block:: console > + > + sudo apt install python3-pip > + sudo pip install --upgrade pip > + sudo pip install scapy=3D=3D2.5.0 > + > +#. **Hardware dependencies** > + > + The traffic generators, like DPDK, need a proper driver and firmware. > + The Scapy traffic generator doesn't have strict requirements - the > drivers that come > + with most OS distributions will be satisfactory. > + > + > +#. **User with administrator privileges** > + > + Similarly to the System Under Test, traffic generators need > administrator privileges > + to be able to use the devices. > + Refer to the `System Under Test section ` for details= . > + > + > Running DTS > ----------- > > diff --git a/dts/framework/dts.py b/dts/framework/dts.py > index 372bc72787..265ed7fd5b 100644 > --- a/dts/framework/dts.py > +++ b/dts/framework/dts.py > @@ -15,7 +15,7 @@ > from .logger import DTSLOG, getLogger > from .test_result import BuildTargetResult, DTSResult, ExecutionResult, > Result > from .test_suite import get_test_suites > -from .testbed_model import SutNode > +from .testbed_model import SutNode, TGNode > from .utils import check_dts_python_version > > dts_logger: DTSLOG =3D getLogger("DTSRunner") > @@ -33,29 +33,31 @@ def run_all() -> None: > # check the python version of the server that run dts > check_dts_python_version() > > - nodes: dict[str, SutNode] =3D {} > + sut_nodes: dict[str, SutNode] =3D {} > + tg_nodes: dict[str, TGNode] =3D {} > try: > # for all Execution sections > for execution in CONFIGURATION.executions: > - sut_node =3D None > - if execution.system_under_test_node.name in nodes: > - # a Node with the same name already exists > - sut_node =3D nodes[execution.system_under_test_node.name= ] > - else: > - # the SUT has not been initialized yet > - try: > + sut_node =3D sut_nodes.get( > execution.system_under_test_node.name) > + tg_node =3D tg_nodes.get(execution.traffic_generator_node.na= me) > + > + try: > + if not sut_node: > sut_node =3D SutNode(execution.system_under_test_nod= e) > - result.update_setup(Result.PASS) > - except Exception as e: > - dts_logger.exception( > - f"Connection to node > {execution.system_under_test_node} failed." > - ) > - result.update_setup(Result.FAIL, e) > - else: > - nodes[sut_node.name] =3D sut_node > - > - if sut_node: > - _run_execution(sut_node, execution, result) > + sut_nodes[sut_node.name] =3D sut_node > + if not tg_node: > + tg_node =3D TGNode(execution.traffic_generator_node) > + tg_nodes[tg_node.name] =3D tg_node > + result.update_setup(Result.PASS) > + except Exception as e: > + failed_node =3D execution.system_under_test_node.name > + if sut_node: > + failed_node =3D execution.traffic_generator_node.nam= e > + dts_logger.exception(f"Creation of node {failed_node} > failed.") > + result.update_setup(Result.FAIL, e) > + > + else: > + _run_execution(sut_node, tg_node, execution, result) > > except Exception as e: > dts_logger.exception("An unexpected error has occurred.") > @@ -64,7 +66,7 @@ def run_all() -> None: > > finally: > try: > - for node in nodes.values(): > + for node in (sut_nodes | tg_nodes).values(): > node.close() > result.update_teardown(Result.PASS) > except Exception as e: > @@ -81,7 +83,10 @@ def run_all() -> None: > > > def _run_execution( > - sut_node: SutNode, execution: ExecutionConfiguration, result: > DTSResult > + sut_node: SutNode, > + tg_node: TGNode, > + execution: ExecutionConfiguration, > + result: DTSResult, > ) -> None: > """ > Run the given execution. This involves running the execution setup a= s > well as > @@ -101,7 +106,9 @@ def _run_execution( > > else: > for build_target in execution.build_targets: > - _run_build_target(sut_node, build_target, execution, > execution_result) > + _run_build_target( > + sut_node, tg_node, build_target, execution, > execution_result > + ) > > finally: > try: > @@ -114,6 +121,7 @@ def _run_execution( > > def _run_build_target( > sut_node: SutNode, > + tg_node: TGNode, > build_target: BuildTargetConfiguration, > execution: ExecutionConfiguration, > execution_result: ExecutionResult, > @@ -134,7 +142,7 @@ def _run_build_target( > build_target_result.update_setup(Result.FAIL, e) > > else: > - _run_all_suites(sut_node, execution, build_target_result) > + _run_all_suites(sut_node, tg_node, execution, build_target_resul= t) > > finally: > try: > @@ -147,6 +155,7 @@ def _run_build_target( > > def _run_all_suites( > sut_node: SutNode, > + tg_node: TGNode, > execution: ExecutionConfiguration, > build_target_result: BuildTargetResult, > ) -> None: > @@ -161,7 +170,7 @@ def _run_all_suites( > for test_suite_config in execution.test_suites: > try: > _run_single_suite( > - sut_node, execution, build_target_result, > test_suite_config > + sut_node, tg_node, execution, build_target_result, > test_suite_config > ) > except BlockingTestSuiteError as e: > dts_logger.exception( > @@ -177,6 +186,7 @@ def _run_all_suites( > > def _run_single_suite( > sut_node: SutNode, > + tg_node: TGNode, > execution: ExecutionConfiguration, > build_target_result: BuildTargetResult, > test_suite_config: TestSuiteConfig, > @@ -205,6 +215,7 @@ def _run_single_suite( > for test_suite_class in test_suite_classes: > test_suite =3D test_suite_class( > sut_node, > + tg_node, > test_suite_config.test_cases, > execution.func, > build_target_result, > diff --git a/dts/framework/remote_session/linux_session.py > b/dts/framework/remote_session/linux_session.py > index f13f399121..284c74795d 100644 > --- a/dts/framework/remote_session/linux_session.py > +++ b/dts/framework/remote_session/linux_session.py > @@ -2,13 +2,47 @@ > # Copyright(c) 2023 PANTHEON.tech s.r.o. > # Copyright(c) 2023 University of New Hampshire > > +import json > +from typing import TypedDict > + > +from typing_extensions import NotRequired > + > from framework.exception import RemoteCommandExecutionError > from framework.testbed_model import LogicalCore > +from framework.testbed_model.hw.port import Port > from framework.utils import expand_range > > from .posix_session import PosixSession > > > +class LshwConfigurationOutput(TypedDict): > + link: str > + > + > +class LshwOutput(TypedDict): > + """ > + A model of the relevant information from json lshw output, e.g.: > + { > + ... > + "businfo" : "pci@0000:08:00.0", > + "logicalname" : "enp8s0", > + "version" : "00", > + "serial" : "52:54:00:59:e1:ac", > + ... > + "configuration" : { > + ... > + "link" : "yes", > + ... > + }, > + ... > + """ > + > + businfo: str > + logicalname: NotRequired[str] > + serial: NotRequired[str] > + configuration: LshwConfigurationOutput > + > + > class LinuxSession(PosixSession): > """ > The implementation of non-Posix compliant parts of Linux remote > sessions. > @@ -102,3 +136,47 @@ def _configure_huge_pages( > self.send_command( > f"echo {amount} | tee {hugepage_config_path}", privileged=3D= True > ) > + > + def update_ports(self, ports: list[Port]) -> None: > + self._logger.debug("Gathering port info.") > + for port in ports: > + assert ( > + port.node =3D=3D self.name > + ), "Attempted to gather port info on the wrong node" > + > + port_info_list =3D self._get_lshw_info() > + for port in ports: > + for port_info in port_info_list: > + if f"pci@{port.pci}" =3D=3D port_info.get("businfo"): > + self._update_port_attr( > + port, port_info.get("logicalname"), "logical_nam= e" > + ) > + self._update_port_attr(port, port_info.get("serial")= , > "mac_address") > + port_info_list.remove(port_info) > + break > + else: > + self._logger.warning(f"No port at pci address {port.pci} > found.") > + > + def _get_lshw_info(self) -> list[LshwOutput]: > + output =3D self.send_command("lshw -quiet -json -C network", > verify=3DTrue) > + return json.loads(output.stdout) > + > + def _update_port_attr( > + self, port: Port, attr_value: str | None, attr_name: str > + ) -> None: > + if attr_value: > + setattr(port, attr_name, attr_value) > + self._logger.debug( > + f"Found '{attr_name}' of port {port.pci}: '{attr_value}'= ." > + ) > + else: > + self._logger.warning( > + f"Attempted to get '{attr_name}' of port {port.pci}, " > + f"but it doesn't exist." > + ) > + > + def configure_port_state(self, port: Port, enable: bool) -> None: > + state =3D "up" if enable else "down" > + self.send_command( > + f"ip link set dev {port.logical_name} {state}", > privileged=3DTrue > + ) > diff --git a/dts/framework/remote_session/os_session.py > b/dts/framework/remote_session/os_session.py > index cc13b02f16..633d06eb5d 100644 > --- a/dts/framework/remote_session/os_session.py > +++ b/dts/framework/remote_session/os_session.py > @@ -12,6 +12,7 @@ > from framework.remote_session.remote import InteractiveShell, TestPmdShe= ll > from framework.settings import SETTINGS > from framework.testbed_model import LogicalCore > +from framework.testbed_model.hw.port import Port > from framework.utils import MesonArgs > > from .remote import ( > @@ -255,3 +256,17 @@ def get_node_info(self) -> NodeInfo: > """ > Collect information about the node > """ > + > + @abstractmethod > + def update_ports(self, ports: list[Port]) -> None: > + """ > + Get additional information about ports: > + Logical name (e.g. enp7s0) if applicable > + Mac address > + """ > + > + @abstractmethod > + def configure_port_state(self, port: Port, enable: bool) -> None: > + """ > + Enable/disable port. > + """ > diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py > index de94c9332d..056460dd05 100644 > --- a/dts/framework/test_suite.py > +++ b/dts/framework/test_suite.py > @@ -20,7 +20,7 @@ > from .logger import DTSLOG, getLogger > from .settings import SETTINGS > from .test_result import BuildTargetResult, Result, TestCaseResult, > TestSuiteResult > -from .testbed_model import SutNode > +from .testbed_model import SutNode, TGNode > > > class TestSuite(object): > @@ -51,11 +51,13 @@ class TestSuite(object): > def __init__( > self, > sut_node: SutNode, > + tg_node: TGNode, > test_cases: list[str], > func: bool, > build_target_result: BuildTargetResult, > ): > self.sut_node =3D sut_node > + self.tg_node =3D tg_node > self._logger =3D getLogger(self.__class__.__name__) > self._test_cases_to_run =3D test_cases > self._test_cases_to_run.extend(SETTINGS.test_cases) > diff --git a/dts/framework/testbed_model/__init__.py > b/dts/framework/testbed_model/__init__.py > index f54a947051..5cbb859e47 100644 > --- a/dts/framework/testbed_model/__init__.py > +++ b/dts/framework/testbed_model/__init__.py > @@ -20,3 +20,4 @@ > ) > from .node import Node > from .sut_node import SutNode > +from .tg_node import TGNode > diff --git a/dts/framework/testbed_model/capturing_traffic_generator.py > b/dts/framework/testbed_model/capturing_traffic_generator.py > new file mode 100644 > index 0000000000..1130d87f1e > --- /dev/null > +++ b/dts/framework/testbed_model/capturing_traffic_generator.py > @@ -0,0 +1,135 @@ > +# SPDX-License-Identifier: BSD-3-Clause > +# Copyright(c) 2022 University of New Hampshire > +# Copyright(c) 2023 PANTHEON.tech s.r.o. > + > +"""Traffic generator that can capture packets. > + > +In functional testing, we need to interrogate received packets to check > their validity. > +Here we define the interface common to all > +traffic generators capable of capturing traffic. > Is there a reason for the line break here? Just to keep things consistent I think it might make sense to extend this line to be the same length as the one above. > +""" > + > +import uuid > +from abc import abstractmethod > + > +import scapy.utils # type: ignore[import] > +from scapy.packet import Packet # type: ignore[import] > + > +from framework.settings import SETTINGS > +from framework.utils import get_packet_summaries > + > +from .hw.port import Port > +from .traffic_generator import TrafficGenerator > + > + > +def _get_default_capture_name() -> str: > + """ > + This is the function used for the default implementation of capture > names. > + """ > + return str(uuid.uuid4()) > + > + > +class CapturingTrafficGenerator(TrafficGenerator): > + """ > + A mixin interface which enables a packet generator to declare that > it can capture > + packets and return them to the user. > This is missing the one line summary at the top of the comment. Obviously this is not a big issue, but we likely would want this to be uniform with the rest of the module which does have the summary at the top. > + > + The methods of capturing traffic generators obey the following > workflow: > + 1. send packets > + 2. capture packets > + 3. write the capture to a .pcap file > + 4. return the received packets > + """ > + > + @property > + def is_capturing(self) -> bool: > + return True > + > + def send_packet_and_capture( > + self, > + packet: Packet, > + send_port: Port, > + receive_port: Port, > + duration: float, > + capture_name: str =3D _get_default_capture_name(), > + ) -> list[Packet]: > + """Send a packet, return received traffic. > + > + Send a packet on the send_port and then return all traffic > captured > + on the receive_port for the given duration. Also record the > captured traffic > + in a pcap file. > + > + Args: > + packet: The packet to send. > + send_port: The egress port on the TG node. > + receive_port: The ingress port in the TG node. > + duration: Capture traffic for this amount of time after > sending the packet. > + capture_name: The name of the .pcap file where to store the > capture. > + > + Returns: > + A list of received packets. May be empty if no packets are > captured. > + """ > + return self.send_packets_and_capture( > + [packet], send_port, receive_port, duration, capture_name > + ) > + > + def send_packets_and_capture( > + self, > + packets: list[Packet], > + send_port: Port, > + receive_port: Port, > + duration: float, > + capture_name: str =3D _get_default_capture_name(), > + ) -> list[Packet]: > + """Send packets, return received traffic. > + > + Send packets on the send_port and then return all traffic captur= ed > + on the receive_port for the given duration. Also record the > captured traffic > + in a pcap file. > + > + Args: > + packets: The packets to send. > + send_port: The egress port on the TG node. > + receive_port: The ingress port in the TG node. > + duration: Capture traffic for this amount of time after > sending the packets. > + capture_name: The name of the .pcap file where to store the > capture. > + > + Returns: > + A list of received packets. May be empty if no packets are > captured. > + """ > + self._logger.debug(get_packet_summaries(packets)) > + self._logger.debug( > + f"Sending packet on {send_port.logical_name}, " > + f"receiving on {receive_port.logical_name}." > + ) > + received_packets =3D self._send_packets_and_capture( > + packets, > + send_port, > + receive_port, > + duration, > + ) > + > + self._logger.debug( > + f"Received packets: {get_packet_summaries(received_packets)}= " > + ) > + self._write_capture_from_packets(capture_name, received_packets) > + return received_packets > + > + @abstractmethod > + def _send_packets_and_capture( > + self, > + packets: list[Packet], > + send_port: Port, > + receive_port: Port, > + duration: float, > + ) -> list[Packet]: > + """ > + The extended classes must implement this method which > + sends packets on send_port and receives packets on the > receive_port > + for the specified duration. It must be able to handle no receive= d > packets. > + """ > + > + def _write_capture_from_packets(self, capture_name: str, packets: > list[Packet]): > + file_name =3D f"{SETTINGS.output_dir}/{capture_name}.pcap" > + self._logger.debug(f"Writing packets to {file_name}.") > + scapy.utils.wrpcap(file_name, packets) > diff --git a/dts/framework/testbed_model/hw/port.py > b/dts/framework/testbed_model/hw/port.py > new file mode 100644 > index 0000000000..680c29bfe3 > --- /dev/null > +++ b/dts/framework/testbed_model/hw/port.py > @@ -0,0 +1,60 @@ > +# SPDX-License-Identifier: BSD-3-Clause > +# Copyright(c) 2022 University of New Hampshire > +# Copyright(c) 2023 PANTHEON.tech s.r.o. > + > +from dataclasses import dataclass > + > +from framework.config import PortConfig > + > + > +@dataclass(slots=3DTrue, frozen=3DTrue) > +class PortIdentifier: > + node: str > + pci: str > + > + > +@dataclass(slots=3DTrue) > +class Port: > + """ > + identifier: The PCI address of the port on a node. > + > + os_driver: The driver used by this port when the OS is controlling i= t. > + Example: i40e > + os_driver_for_dpdk: The driver the device must be bound to for DPDK > to use it, > + Example: vfio-pci. > + > + Note: os_driver and os_driver_for_dpdk may be the same thing. > + Example: mlx5_core > + > + peer: The identifier of a port this port is connected with. > + """ > + > + identifier: PortIdentifier > + os_driver: str > + os_driver_for_dpdk: str > + peer: PortIdentifier > + mac_address: str =3D "" > + logical_name: str =3D "" > + > + def __init__(self, node_name: str, config: PortConfig): > + self.identifier =3D PortIdentifier( > + node=3Dnode_name, > + pci=3Dconfig.pci, > + ) > + self.os_driver =3D config.os_driver > + self.os_driver_for_dpdk =3D config.os_driver_for_dpdk > + self.peer =3D PortIdentifier(node=3Dconfig.peer_node, > pci=3Dconfig.peer_pci) > + > + @property > + def node(self) -> str: > + return self.identifier.node > + > + @property > + def pci(self) -> str: > + return self.identifier.pci > + > + > +@dataclass(slots=3DTrue, frozen=3DTrue) > +class PortLink: > + sut_port: Port > + tg_port: Port > diff --git a/dts/framework/testbed_model/node.py > b/dts/framework/testbed_model/node.py > index d2d55d904e..e09931cedf 100644 > --- a/dts/framework/testbed_model/node.py > +++ b/dts/framework/testbed_model/node.py > @@ -25,6 +25,7 @@ > LogicalCoreListFilter, > lcore_filter, > ) > +from .hw.port import Port > > > class Node(object): > @@ -38,6 +39,7 @@ class Node(object): > config: NodeConfiguration > name: str > lcores: list[LogicalCore] > + ports: list[Port] > _logger: DTSLOG > _other_sessions: list[OSSession] > _execution_config: ExecutionConfiguration > @@ -57,6 +59,13 @@ def __init__(self, node_config: NodeConfiguration): > ).filter() > > self._other_sessions =3D [] > + self._init_ports() > + > + def _init_ports(self) -> None: > + self.ports =3D [Port(self.name, port_config) for port_config in > self.config.ports] > + self.main_session.update_ports(self.ports) > + for port in self.ports: > + self.configure_port_state(port) > > def set_up_execution(self, execution_config: ExecutionConfiguration) > -> None: > """ > @@ -168,6 +177,12 @@ def _setup_hugepages(self): > self.config.hugepages.amount, > self.config.hugepages.force_first_numa > ) > > + def configure_port_state(self, port: Port, enable: bool =3D True) -> > None: > + """ > + Enable/disable port. > + """ > + self.main_session.configure_port_state(port, enable) > + > def close(self) -> None: > """ > Close all connections and free other resources. > diff --git a/dts/framework/testbed_model/scapy.py > b/dts/framework/testbed_model/scapy.py > new file mode 100644 > index 0000000000..1a23dc9fa3 > --- /dev/null > +++ b/dts/framework/testbed_model/scapy.py > @@ -0,0 +1,74 @@ > +# SPDX-License-Identifier: BSD-3-Clause > +# Copyright(c) 2022 University of New Hampshire > +# Copyright(c) 2023 PANTHEON.tech s.r.o. > + > +"""Scapy traffic generator. > + > +Traffic generator used for functional testing, implemented using the > Scapy library. > +The traffic generator uses an XML-RPC server to run Scapy on the remote > TG node. > + > +The XML-RPC server runs in an interactive remote SSH session running > Python console, > +where we start the server. The communication with the server is > facilitated with > +a local server proxy. > +""" > + > +from scapy.packet import Packet # type: ignore[import] > + > +from framework.config import OS, ScapyTrafficGeneratorConfig > +from framework.logger import getLogger > + > +from .capturing_traffic_generator import ( > + CapturingTrafficGenerator, > + _get_default_capture_name, > +) > +from .hw.port import Port > +from .tg_node import TGNode > + > + > +class ScapyTrafficGenerator(CapturingTrafficGenerator): > + """Provides access to scapy functions via an RPC interface. > + > + The traffic generator first starts an XML-RPC on the remote TG node. > + Then it populates the server with functions which use the Scapy > library > + to send/receive traffic. > + > + Any packets sent to the remote server are first converted to bytes. > + They are received as xmlrpc.client.Binary objects on the server side= . > + When the server sends the packets back, they are also received as > + xmlrpc.client.Binary object on the client side, are converted back t= o > Scapy > + packets and only then returned from the methods. > + > + Arguments: > + tg_node: The node where the traffic generator resides. > + config: The user configuration of the traffic generator. > + """ > + > + _config: ScapyTrafficGeneratorConfig > + _tg_node: TGNode > + > + def __init__(self, tg_node: TGNode, config: > ScapyTrafficGeneratorConfig): > + self._config =3D config > + self._tg_node =3D tg_node > + self._logger =3D getLogger( > + f"{self._tg_node.name} {self._config.traffic_generator_type}= " > + ) > + > + assert ( > + self._tg_node.config.os =3D=3D OS.linux > + ), "Linux is the only supported OS for scapy traffic generation" > + > + def _send_packets(self, packets: list[Packet], port: Port) -> None: > + raise NotImplementedError() > + > + def _send_packets_and_capture( > + self, > + packets: list[Packet], > + send_port: Port, > + receive_port: Port, > + duration: float, > + capture_name: str =3D _get_default_capture_name(), > + ) -> list[Packet]: > + raise NotImplementedError() > + > + def close(self): > + pass > diff --git a/dts/framework/testbed_model/tg_node.py > b/dts/framework/testbed_model/tg_node.py > new file mode 100644 > index 0000000000..27025cfa31 > --- /dev/null > +++ b/dts/framework/testbed_model/tg_node.py > @@ -0,0 +1,99 @@ > +# SPDX-License-Identifier: BSD-3-Clause > +# Copyright(c) 2010-2014 Intel Corporation > +# Copyright(c) 2022 University of New Hampshire > +# Copyright(c) 2023 PANTHEON.tech s.r.o. > + > +"""Traffic generator node. > + > +This is the node where the traffic generator resides. > +The distinction between a node and a traffic generator is as follows: > +A node is a host that DTS connects to. It could be a baremetal server, > +a VM or a container. > +A traffic generator is software running on the node. > +A traffic generator node is a node running a traffic generator. > +A node can be a traffic generator node as well as system under test node= . > +""" > + > +from scapy.packet import Packet # type: ignore[import] > + > +from framework.config import ( > + ScapyTrafficGeneratorConfig, > + TGNodeConfiguration, > + TrafficGeneratorType, > +) > +from framework.exception import ConfigurationError > + > +from .capturing_traffic_generator import CapturingTrafficGenerator > +from .hw.port import Port > +from .node import Node > + > + > +class TGNode(Node): > + """Manage connections to a node with a traffic generator. > + > + Apart from basic node management capabilities, the Traffic Generator > node has > + specialized methods for handling the traffic generator running on it= . > + > + Arguments: > + node_config: The user configuration of the traffic generator nod= e. > + > + Attributes: > + traffic_generator: The traffic generator running on the node. > + """ > + > + traffic_generator: CapturingTrafficGenerator > + > + def __init__(self, node_config: TGNodeConfiguration): > + super(TGNode, self).__init__(node_config) > + self.traffic_generator =3D create_traffic_generator( > + self, node_config.traffic_generator > + ) > + self._logger.info(f"Created node: {self.name}") > + > + def send_packet_and_capture( > + self, > + packet: Packet, > + send_port: Port, > + receive_port: Port, > + duration: float =3D 1, > + ) -> list[Packet]: > + """Send a packet, return received traffic. > + > + Send a packet on the send_port and then return all traffic > captured > + on the receive_port for the given duration. Also record the > captured traffic > + in a pcap file. > + > + Args: > + packet: The packet to send. > + send_port: The egress port on the TG node. > + receive_port: The ingress port in the TG node. > + duration: Capture traffic for this amount of time after > sending the packet. > + > + Returns: > + A list of received packets. May be empty if no packets are > captured. > + """ > + return self.traffic_generator.send_packet_and_capture( > + packet, send_port, receive_port, duration > + ) > + > + def close(self) -> None: > + """Free all resources used by the node""" > + self.traffic_generator.close() > + super(TGNode, self).close() > + > + > +def create_traffic_generator( > + tg_node: TGNode, traffic_generator_config: ScapyTrafficGeneratorConf= ig > +) -> CapturingTrafficGenerator: > + """A factory function for creating traffic generator object from use= r > config.""" > + > + from .scapy import ScapyTrafficGenerator > + > + match traffic_generator_config.traffic_generator_type: > + case TrafficGeneratorType.SCAPY: > + return ScapyTrafficGenerator(tg_node, > traffic_generator_config) > + case _: > + raise ConfigurationError( > + "Unknown traffic generator: " > + f"{traffic_generator_config.traffic_generator_type}" > + ) Would it be possible here to do something like what we did in create_interactive_shell with a TypeVar where we can initialize it directly? It would change from using the enum to setting the traffic_generator_config.traffic_generator_type to a specific class in the config (in this case, ScapyTrafficGenerator), but I think it would be possible to change in the from_dict method where we could set this type to the class directly instead of the enum (or maybe had the enum relate it's values to the classes themselves). I think this would make some things slightly more complicated (like how we would map from conf.yaml to one of the classes and all of those needing to be imported in config/__init__.py) but it would save developers in the future from having to add to two different places (the enum in config/__init__.py and this match statement) and save this list from being arbitrarily long. I think this is fine for this patch but maybe when we expand the traffic generator or the scapy generator it could be worth thinking about. Do you think it would make sense to change it in this way or would that be somewhat unnecessary in your eyes? > diff --git a/dts/framework/testbed_model/traffic_generator.py > b/dts/framework/testbed_model/traffic_generator.py > new file mode 100644 > index 0000000000..28c35d3ce4 > --- /dev/null > +++ b/dts/framework/testbed_model/traffic_generator.py > @@ -0,0 +1,72 @@ > +# SPDX-License-Identifier: BSD-3-Clause > +# Copyright(c) 2022 University of New Hampshire > +# Copyright(c) 2023 PANTHEON.tech s.r.o. > + > +"""The base traffic generator. > + > +These traffic generators can't capture received traffic, > +only count the number of received packets. > +""" > + > +from abc import ABC, abstractmethod > + > +from scapy.packet import Packet # type: ignore[import] > + > +from framework.logger import DTSLOG > +from framework.utils import get_packet_summaries > + > +from .hw.port import Port > + > + > +class TrafficGenerator(ABC): > + """The base traffic generator. > + > + Defines the few basic methods that each traffic generator must > implement. > + """ > + > + _logger: DTSLOG > + > + def send_packet(self, packet: Packet, port: Port) -> None: > + """Send a packet and block until it is fully sent. > + > + What fully sent means is defined by the traffic generator. > + > + Args: > + packet: The packet to send. > + port: The egress port on the TG node. > + """ > + self.send_packets([packet], port) > + > + def send_packets(self, packets: list[Packet], port: Port) -> None: > + """Send packets and block until they are fully sent. > + > + What fully sent means is defined by the traffic generator. > + > + Args: > + packets: The packets to send. > + port: The egress port on the TG node. > + """ > + self._logger.info(f"Sending packet{'s' if len(packets) > 1 else > ''}.") > + self._logger.debug(get_packet_summaries(packets)) > + self._send_packets(packets, port) > + > + @abstractmethod > + def _send_packets(self, packets: list[Packet], port: Port) -> None: > + """ > + The extended classes must implement this method which > + sends packets on send_port. The method should block until all > packets > + are fully sent. > + """ > + > + @property > + def is_capturing(self) -> bool: > + """Whether this traffic generator can capture traffic. > + > + Returns: > + True if the traffic generator can capture traffic, False > otherwise. > + """ > + return False > + > + @abstractmethod > + def close(self) -> None: > + """Free all resources used by the traffic generator.""" > diff --git a/dts/framework/utils.py b/dts/framework/utils.py > index 60abe46edf..d27c2c5b5f 100644 > --- a/dts/framework/utils.py > +++ b/dts/framework/utils.py > @@ -4,6 +4,7 @@ > # Copyright(c) 2022-2023 University of New Hampshire > > import atexit > +import json > import os > import subprocess > import sys > @@ -11,6 +12,8 @@ > from pathlib import Path > from subprocess import SubprocessError > > +from scapy.packet import Packet # type: ignore[import] > + > from .exception import ConfigurationError > > > @@ -64,6 +67,16 @@ def expand_range(range_str: str) -> list[int]: > return expanded_range > > > +def get_packet_summaries(packets: list[Packet]): > + if len(packets) =3D=3D 1: > + packet_summaries =3D packets[0].summary() > + else: > + packet_summaries =3D json.dumps( > + list(map(lambda pkt: pkt.summary(), packets)), indent=3D4 > + ) > + return f"Packet contents: \n{packet_summaries}" > + > + > def RED(text: str) -> str: > return f"\u001B[31;1m{str(text)}\u001B[0m" > > -- > 2.34.1 > > --000000000000db7ae40600c84fc3 Content-Type: text/html; charset="UTF-8" Content-Transfer-Encoding: quoted-printable

Hey Juraj,

Just a couple of comments b= elow, but very minor stuff. Just a few docstring that I commented on and on= e question about the factory for traffic generators that I was wondering wh= at you thought about. More below:

On Mon, Jul 17, 2023 at 7:07=E2=80=AFAM Ju= raj Linke=C5=A1 <juraj.linkes@pantheon.tech> wrote:
There are traffic abstractions fo= r all traffic generators and for
traffic generators that can capture (not just count) packets.

There also related abstractions, such as TGNode where the traffic
generators reside and some related code.

Signed-off-by: Juraj Linke=C5=A1 <juraj.linkes@pantheon.tech>
---
=C2=A0doc/guides/tools/dts.rst=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 |=C2=A0 31 ++++
=C2=A0dts/framework/dts.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 |=C2=A0 61 ++++----
=C2=A0dts/framework/remote_session/linux_session.py |=C2=A0 78 ++++++++++ =C2=A0dts/framework/remote_session/os_session.py=C2=A0 =C2=A0 |=C2=A0 15 ++=
=C2=A0dts/framework/test_suite.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 =C2=A0 =C2=A0 =C2=A0|=C2=A0 =C2=A04 +-
=C2=A0dts/framework/testbed_model/__init__.py=C2=A0 =C2=A0 =C2=A0 =C2=A0|= =C2=A0 =C2=A01 +
=C2=A0.../capturing_traffic_generator.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 = =C2=A0 | 135 ++++++++++++++++++
=C2=A0dts/framework/testbed_model/hw/port.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 |= =C2=A0 60 ++++++++
=C2=A0dts/framework/testbed_model/node.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0= =C2=A0|=C2=A0 15 ++
=C2=A0dts/framework/testbed_model/scapy.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 |=C2=A0 74 ++++++++++
=C2=A0dts/framework/testbed_model/tg_node.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 |= =C2=A0 99 +++++++++++++
=C2=A0.../testbed_model/traffic_generator.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 |= =C2=A0 72 ++++++++++
=C2=A0dts/framework/utils.py=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 |=C2=A0 13 ++
=C2=A013 files changed, 632 insertions(+), 26 deletions(-)
=C2=A0create mode 100644 dts/framework/testbed_model/capturing_traffic_gene= rator.py
=C2=A0create mode 100644 dts/framework/testbed_model/hw/port.py
=C2=A0create mode 100644 dts/framework/testbed_model/scapy.py
=C2=A0create mode 100644 dts/framework/testbed_model/tg_node.py
=C2=A0create mode 100644 dts/framework/testbed_model/traffic_generator.py
diff --git a/doc/guides/tools/dts.rst b/doc/guides/tools/dts.rst
index c7b31623e4..2f97d1df6e 100644
--- a/doc/guides/tools/dts.rst
+++ b/doc/guides/tools/dts.rst
@@ -153,6 +153,37 @@ There are two areas that need to be set up on a System= Under Test:

=C2=A0 =C2=A0 =C2=A0 =C2=A0sudo usermod -aG sudo <sut_user>

+
+Setting up Traffic Generator Node
+---------------------------------
+
+These need to be set up on a Traffic Generator Node:
+
+#. **Traffic generator dependencies**
+
+=C2=A0 =C2=A0The traffic generator running on the traffic generator node m= ust be installed beforehand.
+=C2=A0 =C2=A0For Scapy traffic generator, only a few Python libraries need= to be installed:
+
+=C2=A0 =C2=A0.. code-block:: console
+
+=C2=A0 =C2=A0 =C2=A0 sudo apt install python3-pip
+=C2=A0 =C2=A0 =C2=A0 sudo pip install --upgrade pip
+=C2=A0 =C2=A0 =C2=A0 sudo pip install scapy=3D=3D2.5.0
+
+#. **Hardware dependencies**
+
+=C2=A0 =C2=A0The traffic generators, like DPDK, need a proper driver and f= irmware.
+=C2=A0 =C2=A0The Scapy traffic generator doesn't have strict requireme= nts - the drivers that come
+=C2=A0 =C2=A0with most OS distributions will be satisfactory.
+
+
+#. **User with administrator privileges**
+
+=C2=A0 =C2=A0Similarly to the System Under Test, traffic generators need a= dministrator privileges
+=C2=A0 =C2=A0to be able to use the devices.
+=C2=A0 =C2=A0Refer to the `System Under Test section <sut_admin_user>= ;` for details.
+
+
=C2=A0Running DTS
=C2=A0-----------

diff --git a/dts/framework/dts.py b/dts/framework/dts.py
index 372bc72787..265ed7fd5b 100644
--- a/dts/framework/dts.py
+++ b/dts/framework/dts.py
@@ -15,7 +15,7 @@
=C2=A0from .logger import DTSLOG, getLogger
=C2=A0from .test_result import BuildTargetResult, DTSResult, ExecutionResul= t, Result
=C2=A0from .test_suite import get_test_suites
-from .testbed_model import SutNode
+from .testbed_model import SutNode, TGNode
=C2=A0from .utils import check_dts_python_version

=C2=A0dts_logger: DTSLOG =3D getLogger("DTSRunner")
@@ -33,29 +33,31 @@ def run_all() -> None:
=C2=A0 =C2=A0 =C2=A0# check the python version of the server that run dts =C2=A0 =C2=A0 =C2=A0check_dts_python_version()

-=C2=A0 =C2=A0 nodes: dict[str, SutNode] =3D {}
+=C2=A0 =C2=A0 sut_nodes: dict[str, SutNode] =3D {}
+=C2=A0 =C2=A0 tg_nodes: dict[str, TGNode] =3D {}
=C2=A0 =C2=A0 =C2=A0try:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0# for all Execution sections
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0for execution in CONFIGURATION.executions= :
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 sut_node =3D None
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if execution.= system_under_test_node.name in nodes:
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 # a Node with the = same name already exists
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 sut_node =3D nodes= [execution.system_under_test_node.name]
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 else:
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 # the SUT has not = been initialized yet
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 try:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 sut_node =3D sut_nodes.get(execution.system_under_test_node.name)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 tg_node =3D tg_nodes.get(execution.traffic_generator_node.name)
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 try:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if not sut_node: =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0sut_node =3D SutNode(execution.system_under_test_node)
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 resu= lt.update_setup(Result.PASS)
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 except Exception a= s e:
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 dts_= logger.exception(
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 f"Connection to node {execution.system_under_test_node} fai= led."
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 ) -=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 resu= lt.update_setup(Result.FAIL, e)
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 else:
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 node= s[sut= _node.name] =3D sut_node
-
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if sut_node:
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 _run_execution(sut= _node, execution, result)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 sut_= nodes[sut_node.name] =3D sut_node
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if not tg_node: +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 tg_n= ode =3D TGNode(execution.traffic_generator_node)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 tg_n= odes[t= g_node.name] =3D tg_node
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 result.update_setu= p(Result.PASS)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 except Exception as e:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 failed_node =3D execution.system_under_test_node.name
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if sut_node:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 fail= ed_node =3D execution.traffic_generator_node.name +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 dts_logger.excepti= on(f"Creation of node {failed_node} failed.")
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 result.update_setu= p(Result.FAIL, e)
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 else:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 _run_execution(sut= _node, tg_node, execution, result)

=C2=A0 =C2=A0 =C2=A0except Exception as e:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0dts_logger.exception("An unexpected = error has occurred.")
@@ -64,7 +66,7 @@ def run_all() -> None:

=C2=A0 =C2=A0 =C2=A0finally:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0try:
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 for node in nodes.values():
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 for node in (sut_nodes | tg_node= s).values():
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0node.close()<= br> =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0result.update_teardown(Resu= lt.PASS)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0except Exception as e:
@@ -81,7 +83,10 @@ def run_all() -> None:


=C2=A0def _run_execution(
-=C2=A0 =C2=A0 sut_node: SutNode, execution: ExecutionConfiguration, result= : DTSResult
+=C2=A0 =C2=A0 sut_node: SutNode,
+=C2=A0 =C2=A0 tg_node: TGNode,
+=C2=A0 =C2=A0 execution: ExecutionConfiguration,
+=C2=A0 =C2=A0 result: DTSResult,
=C2=A0) -> None:
=C2=A0 =C2=A0 =C2=A0"""
=C2=A0 =C2=A0 =C2=A0Run the given execution. This involves running the exec= ution setup as well as
@@ -101,7 +106,9 @@ def _run_execution(

=C2=A0 =C2=A0 =C2=A0else:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0for build_target in execution.build_targe= ts:
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 _run_build_target(sut_node, buil= d_target, execution, execution_result)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 _run_build_target(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 sut_node, tg_node,= build_target, execution, execution_result
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 )

=C2=A0 =C2=A0 =C2=A0finally:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0try:
@@ -114,6 +121,7 @@ def _run_execution(

=C2=A0def _run_build_target(
=C2=A0 =C2=A0 =C2=A0sut_node: SutNode,
+=C2=A0 =C2=A0 tg_node: TGNode,
=C2=A0 =C2=A0 =C2=A0build_target: BuildTargetConfiguration,
=C2=A0 =C2=A0 =C2=A0execution: ExecutionConfiguration,
=C2=A0 =C2=A0 =C2=A0execution_result: ExecutionResult,
@@ -134,7 +142,7 @@ def _run_build_target(
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0build_target_result.update_setup(Result.F= AIL, e)

=C2=A0 =C2=A0 =C2=A0else:
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 _run_all_suites(sut_node, execution, build_tar= get_result)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 _run_all_suites(sut_node, tg_node, execution, = build_target_result)

=C2=A0 =C2=A0 =C2=A0finally:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0try:
@@ -147,6 +155,7 @@ def _run_build_target(

=C2=A0def _run_all_suites(
=C2=A0 =C2=A0 =C2=A0sut_node: SutNode,
+=C2=A0 =C2=A0 tg_node: TGNode,
=C2=A0 =C2=A0 =C2=A0execution: ExecutionConfiguration,
=C2=A0 =C2=A0 =C2=A0build_target_result: BuildTargetResult,
=C2=A0) -> None:
@@ -161,7 +170,7 @@ def _run_all_suites(
=C2=A0 =C2=A0 =C2=A0for test_suite_config in execution.test_suites:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0try:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0_run_single_suite(
-=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 sut_node, executio= n, build_target_result, test_suite_config
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 sut_node, tg_node,= execution, build_target_result, test_suite_config
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0except BlockingTestSuiteError as e:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0dts_logger.exception(
@@ -177,6 +186,7 @@ def _run_all_suites(

=C2=A0def _run_single_suite(
=C2=A0 =C2=A0 =C2=A0sut_node: SutNode,
+=C2=A0 =C2=A0 tg_node: TGNode,
=C2=A0 =C2=A0 =C2=A0execution: ExecutionConfiguration,
=C2=A0 =C2=A0 =C2=A0build_target_result: BuildTargetResult,
=C2=A0 =C2=A0 =C2=A0test_suite_config: TestSuiteConfig,
@@ -205,6 +215,7 @@ def _run_single_suite(
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0for test_suite_class in test_suite_classe= s:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0test_suite =3D test_suite_c= lass(
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0sut_node,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 tg_node,
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0test_suite_co= nfig.test_cases,
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0execution.fun= c,
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0build_target_= result,
diff --git a/dts/framework/remote_session/linux_session.py b/dts/framework/= remote_session/linux_session.py
index f13f399121..284c74795d 100644
--- a/dts/framework/remote_session/linux_session.py
+++ b/dts/framework/remote_session/linux_session.py
@@ -2,13 +2,47 @@
=C2=A0# Copyright(c) 2023 PANTHEON.tech s.r.o.
=C2=A0# Copyright(c) 2023 University of New Hampshire

+import json
+from typing import TypedDict
+
+from typing_extensions import NotRequired
+
=C2=A0from framework.exception import RemoteCommandExecutionError
=C2=A0from framework.testbed_model import LogicalCore
+from framework.testbed_model.hw.port import Port
=C2=A0from framework.utils import expand_range

=C2=A0from .posix_session import PosixSession


+class LshwConfigurationOutput(TypedDict):
+=C2=A0 =C2=A0 link: str
+
+
+class LshwOutput(TypedDict):
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 A model of the relevant information from json lshw output, e= .g.:
+=C2=A0 =C2=A0 {
+=C2=A0 =C2=A0 ...
+=C2=A0 =C2=A0 "businfo" : "pci@0000:08:00.0",
+=C2=A0 =C2=A0 "logicalname" : "enp8s0",
+=C2=A0 =C2=A0 "version" : "00",
+=C2=A0 =C2=A0 "serial" : "52:54:00:59:e1:ac",
+=C2=A0 =C2=A0 ...
+=C2=A0 =C2=A0 "configuration" : {
+=C2=A0 =C2=A0 =C2=A0 ...
+=C2=A0 =C2=A0 =C2=A0 "link" : "yes",
+=C2=A0 =C2=A0 =C2=A0 ...
+=C2=A0 =C2=A0 },
+=C2=A0 =C2=A0 ...
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 businfo: str
+=C2=A0 =C2=A0 logicalname: NotRequired[str]
+=C2=A0 =C2=A0 serial: NotRequired[str]
+=C2=A0 =C2=A0 configuration: LshwConfigurationOutput
+
+
=C2=A0class LinuxSession(PosixSession):
=C2=A0 =C2=A0 =C2=A0"""
=C2=A0 =C2=A0 =C2=A0The implementation of non-Posix compliant parts of Linu= x remote sessions.
@@ -102,3 +136,47 @@ def _configure_huge_pages(
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0self.send_command(
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0f"echo {amount} | tee = {hugepage_config_path}", privileged=3DTrue
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0)
+
+=C2=A0 =C2=A0 def update_ports(self, ports: list[Port]) -> None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.debug("Gathering port info.&= quot;)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 for port in ports:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 assert (
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 port.node =3D=3D <= a href=3D"http://self.name" rel=3D"noreferrer" target=3D"_blank">self.name<= /a>
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 ), "Attempted to gather por= t info on the wrong node"
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 port_info_list =3D self._get_lshw_info()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 for port in ports:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 for port_info in port_info_list:=
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 if f"pci@{por= t.pci}" =3D=3D port_info.get("businfo"):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self= ._update_port_attr(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2= =A0 =C2=A0 port, port_info.get("logicalname"), "logical_name= "
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 ) +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self= ._update_port_attr(port, port_info.get("serial"), "mac_addre= ss")
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 port= _info_list.remove(port_info)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 brea= k
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 else:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.warni= ng(f"No port at pci address {port.pci} found.")
+
+=C2=A0 =C2=A0 def _get_lshw_info(self) -> list[LshwOutput]:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 output =3D self.send_command("lshw -quiet= -json -C network", verify=3DTrue)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return json.loads(output.stdout)
+
+=C2=A0 =C2=A0 def _update_port_attr(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self, port: Port, attr_value: str | None, attr= _name: str
+=C2=A0 =C2=A0 ) -> None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 if attr_value:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 setattr(port, attr_name, attr_va= lue)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.debug(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"Found '= {attr_name}' of port {port.pci}: '{attr_value}'."
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 else:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.warning(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"Attempted t= o get '{attr_name}' of port {port.pci}, "
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"but it does= n't exist."
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+
+=C2=A0 =C2=A0 def configure_port_state(self, port: Port, enable: bool) -&g= t; None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 state =3D "up" if enable else "= down"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.send_command(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"ip link set dev {port.log= ical_name} {state}", privileged=3DTrue
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
diff --git a/dts/framework/remote_session/os_session.py b/dts/framework/rem= ote_session/os_session.py
index cc13b02f16..633d06eb5d 100644
--- a/dts/framework/remote_session/os_session.py
+++ b/dts/framework/remote_session/os_session.py
@@ -12,6 +12,7 @@
=C2=A0from framework.remote_session.remote import InteractiveShell, TestPmd= Shell
=C2=A0from framework.settings import SETTINGS
=C2=A0from framework.testbed_model import LogicalCore
+from framework.testbed_model.hw.port import Port
=C2=A0from framework.utils import MesonArgs

=C2=A0from .remote import (
@@ -255,3 +256,17 @@ def get_node_info(self) -> NodeInfo:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"""
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0Collect information about the node
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"""
+
+=C2=A0 =C2=A0 @abstractmethod
+=C2=A0 =C2=A0 def update_ports(self, ports: list[Port]) -> None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Get additional information about ports:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 Logical name (e.g. enp7s0) if ap= plicable
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 Mac address
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 @abstractmethod
+=C2=A0 =C2=A0 def configure_port_state(self, port: Port, enable: bool) -&g= t; None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Enable/disable port.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py
index de94c9332d..056460dd05 100644
--- a/dts/framework/test_suite.py
+++ b/dts/framework/test_suite.py
@@ -20,7 +20,7 @@
=C2=A0from .logger import DTSLOG, getLogger
=C2=A0from .settings import SETTINGS
=C2=A0from .test_result import BuildTargetResult, Result, TestCaseResult, T= estSuiteResult
-from .testbed_model import SutNode
+from .testbed_model import SutNode, TGNode


=C2=A0class TestSuite(object):
@@ -51,11 +51,13 @@ class TestSuite(object):
=C2=A0 =C2=A0 =C2=A0def __init__(
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0self,
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0sut_node: SutNode,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 tg_node: TGNode,
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0test_cases: list[str],
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0func: bool,
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0build_target_result: BuildTargetResult, =C2=A0 =C2=A0 =C2=A0):
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0self.sut_node =3D sut_node
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.tg_node =3D tg_node
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0self._logger =3D getLogger(self.__class__= .__name__)
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0self._test_cases_to_run =3D test_cases =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0self._test_cases_to_run.extend(SETTINGS.t= est_cases)
diff --git a/dts/framework/testbed_model/__init__.py b/dts/framework/testbe= d_model/__init__.py
index f54a947051..5cbb859e47 100644
--- a/dts/framework/testbed_model/__init__.py
+++ b/dts/framework/testbed_model/__init__.py
@@ -20,3 +20,4 @@
=C2=A0)
=C2=A0from .node import Node
=C2=A0from .sut_node import SutNode
+from .tg_node import TGNode
diff --git a/dts/framework/testbed_model/capturing_traffic_generator.py b/d= ts/framework/testbed_model/capturing_traffic_generator.py
new file mode 100644
index 0000000000..1130d87f1e
--- /dev/null
+++ b/dts/framework/testbed_model/capturing_traffic_generator.py
@@ -0,0 +1,135 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 University of New Hampshire
+# Copyright(c) 2023 PANTHEON.tech s.r.o.
+
+"""Traffic generator that can capture packets.
+
+In functional testing, we need to interrogate received packets to check th= eir validity.
+Here we define the interface common to all
+traffic generators capable of capturing traffic.

=
Is there a reason for the line break here? Just to keep things consiste= nt I think it might make sense to extend this line to be the same length as= the one above.
=C2=A0
+"""
+
+import uuid
+from abc import abstractmethod
+
+import scapy.utils=C2=A0 # type: ignore[import]
+from scapy.packet import Packet=C2=A0 # type: ignore[import]
+
+from framework.settings import SETTINGS
+from framework.utils import get_packet_summaries
+
+from .hw.port import Port
+from .traffic_generator import TrafficGenerator
+
+
+def _get_default_capture_name() -> str:
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 This is the function used for the default implementation of = capture names.
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 return str(uuid.uuid4())
+
+
+class CapturingTrafficGenerator(TrafficGenerator):
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 A mixin interface which enables a packet generator to decla= re that it can capture
+=C2=A0 =C2=A0 packets and return them to the user.
This is missing the one line summary at the top of the comment. Obvio= usly this is not a big issue, but we likely would want this to be uniform w= ith the rest of the module which does have the summary at the top.
=C2=A0
+
+=C2=A0 =C2=A0 The methods of capturing traffic generators obey the followi= ng workflow:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 1. send packets
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 2. capture packets
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 3. write the capture to a .pcap file
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 4. return the received packets
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 @property
+=C2=A0 =C2=A0 def is_capturing(self) -> bool:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return True
+
+=C2=A0 =C2=A0 def send_packet_and_capture(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 packet: Packet,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 send_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 receive_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 duration: float,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 capture_name: str =3D _get_default_capture_nam= e(),
+=C2=A0 =C2=A0 ) -> list[Packet]:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """Send a packet, return receiv= ed traffic.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Send a packet on the send_port and then return= all traffic captured
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 on the receive_port for the given duration. Al= so record the captured traffic
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 in a pcap file.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Args:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 packet: The packet to send.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 send_port: The egress port on th= e TG node.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 receive_port: The ingress port i= n the TG node.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 duration: Capture traffic for th= is amount of time after sending the packet.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 capture_name: The name of the .p= cap file where to store the capture.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Returns:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0A list of received packets= . May be empty if no packets are captured.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return self.send_packets_and_capture(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 [packet], send_port, receive_por= t, duration, capture_name
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+
+=C2=A0 =C2=A0 def send_packets_and_capture(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 packets: list[Packet],
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 send_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 receive_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 duration: float,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 capture_name: str =3D _get_default_capture_nam= e(),
+=C2=A0 =C2=A0 ) -> list[Packet]:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """Send packets, return receive= d traffic.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Send packets on the send_port and then return = all traffic captured
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 on the receive_port for the given duration. Al= so record the captured traffic
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 in a pcap file.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Args:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 packets: The packets to send. +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 send_port: The egress port on th= e TG node.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 receive_port: The ingress port i= n the TG node.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 duration: Capture traffic for th= is amount of time after sending the packets.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 capture_name: The name of the .p= cap file where to store the capture.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Returns:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0A list of received packets= . May be empty if no packets are captured.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.debug(get_packet_summaries(packet= s))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.debug(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"Sending packet on {send_p= ort.logical_name}, "
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"receiving on {receive_por= t.logical_name}."
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 received_packets =3D self._send_packets_and_ca= pture(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 packets,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 send_port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 receive_port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 duration,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.debug(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"Received packets: {get_pa= cket_summaries(received_packets)}"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._write_capture_from_packets(capture_name,= received_packets)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return received_packets
+
+=C2=A0 =C2=A0 @abstractmethod
+=C2=A0 =C2=A0 def _send_packets_and_capture(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 packets: list[Packet],
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 send_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 receive_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 duration: float,
+=C2=A0 =C2=A0 ) -> list[Packet]:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 The extended classes must implement this metho= d which
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 sends packets on send_port and receives packet= s on the receive_port
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 for the specified duration. It must be able to= handle no received packets.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 def _write_capture_from_packets(self, capture_name: str, pac= kets: list[Packet]):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 file_name =3D f"{SETTINGS.output_dir}/{ca= pture_name}.pcap"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.debug(f"Writing packets to {= file_name}.")
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 scapy.utils.wrpcap(file_name, packets)
diff --git a/dts/framework/testbed_model/hw/port.py b/dts/framework/testbed= _model/hw/port.py
new file mode 100644
index 0000000000..680c29bfe3
--- /dev/null
+++ b/dts/framework/testbed_model/hw/port.py
@@ -0,0 +1,60 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 University of New Hampshire
+# Copyright(c) 2023 PANTHEON.tech s.r.o.
+
+from dataclasses import dataclass
+
+from framework.config import PortConfig
+
+
+@dataclass(slots=3DTrue, frozen=3DTrue)
+class PortIdentifier:
+=C2=A0 =C2=A0 node: str
+=C2=A0 =C2=A0 pci: str
+
+
+@dataclass(slots=3DTrue)
+class Port:
+=C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 identifier: The PCI address of the port on a node.
+
+=C2=A0 =C2=A0 os_driver: The driver used by this port when the OS is contr= olling it.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Example: i40e
+=C2=A0 =C2=A0 os_driver_for_dpdk: The driver the device must be bound to f= or DPDK to use it,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Example: vfio-pci.
+
+=C2=A0 =C2=A0 Note: os_driver and os_driver_for_dpdk may be the same thing= .
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Example: mlx5_core
+
+=C2=A0 =C2=A0 peer: The identifier of a port this port is connected with.<= br> +=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 identifier: PortIdentifier
+=C2=A0 =C2=A0 os_driver: str
+=C2=A0 =C2=A0 os_driver_for_dpdk: str
+=C2=A0 =C2=A0 peer: PortIdentifier
+=C2=A0 =C2=A0 mac_address: str =3D ""
+=C2=A0 =C2=A0 logical_name: str =3D ""
+
+=C2=A0 =C2=A0 def __init__(self, node_name: str, config: PortConfig):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.identifier =3D PortIdentifier(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 node=3Dnode_name,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 pci=3Dconfig.pci,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.os_driver =3D config.os_driver
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.os_driver_for_dpdk =3D config.os_driver_f= or_dpdk
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.peer =3D PortIdentifier(node=3Dconfig.pee= r_node, pci=3Dconfig.peer_pci)
+
+=C2=A0 =C2=A0 @property
+=C2=A0 =C2=A0 def node(self) -> str:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return self.identifier.node
+
+=C2=A0 =C2=A0 @property
+=C2=A0 =C2=A0 def pci(self) -> str:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return self.identifier.pci
+
+
+@dataclass(slots=3DTrue, frozen=3DTrue)
+class PortLink:
+=C2=A0 =C2=A0 sut_port: Port
+=C2=A0 =C2=A0 tg_port: Port
diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_mo= del/node.py
index d2d55d904e..e09931cedf 100644
--- a/dts/framework/testbed_model/node.py
+++ b/dts/framework/testbed_model/node.py
@@ -25,6 +25,7 @@
=C2=A0 =C2=A0 =C2=A0LogicalCoreListFilter,
=C2=A0 =C2=A0 =C2=A0lcore_filter,
=C2=A0)
+from .hw.port import Port


=C2=A0class Node(object):
@@ -38,6 +39,7 @@ class Node(object):
=C2=A0 =C2=A0 =C2=A0config: NodeConfiguration
=C2=A0 =C2=A0 =C2=A0name: str
=C2=A0 =C2=A0 =C2=A0lcores: list[LogicalCore]
+=C2=A0 =C2=A0 ports: list[Port]
=C2=A0 =C2=A0 =C2=A0_logger: DTSLOG
=C2=A0 =C2=A0 =C2=A0_other_sessions: list[OSSession]
=C2=A0 =C2=A0 =C2=A0_execution_config: ExecutionConfiguration
@@ -57,6 +59,13 @@ def __init__(self, node_config: NodeConfiguration):
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0).filter()

=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0self._other_sessions =3D []
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._init_ports()
+
+=C2=A0 =C2=A0 def _init_ports(self) -> None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.ports =3D [Port(self.name, port_config) for po= rt_config in self.config.ports]
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.main_session.update_ports(self.ports)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 for port in self.ports:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self.configure_port_state(port)<= br>
=C2=A0 =C2=A0 =C2=A0def set_up_execution(self, execution_config: ExecutionC= onfiguration) -> None:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"""
@@ -168,6 +177,12 @@ def _setup_hugepages(self):
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0self.config.h= ugepages.amount, self.config.hugepages.force_first_numa
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0)

+=C2=A0 =C2=A0 def configure_port_state(self, port: Port, enable: bool =3D = True) -> None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Enable/disable port.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.main_session.configure_port_state(port, e= nable)
+
=C2=A0 =C2=A0 =C2=A0def close(self) -> None:
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0"""
=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0Close all connections and free other reso= urces.
diff --git a/dts/framework/testbed_model/scapy.py b/dts/framework/testbed_m= odel/scapy.py
new file mode 100644
index 0000000000..1a23dc9fa3
--- /dev/null
+++ b/dts/framework/testbed_model/scapy.py
@@ -0,0 +1,74 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 University of New Hampshire
+# Copyright(c) 2023 PANTHEON.tech s.r.o.
+
+"""Scapy traffic generator.
+
+Traffic generator used for functional testing, implemented using the Scapy= library.
+The traffic generator uses an XML-RPC server to run Scapy on the remote TG= node.
+
+The XML-RPC server runs in an interactive remote SSH session running Pytho= n console,
+where we start the server. The communication with the server is facilitate= d with
+a local server proxy.
+"""
+
+from scapy.packet import Packet=C2=A0 # type: ignore[import]
+
+from framework.config import OS, ScapyTrafficGeneratorConfig
+from framework.logger import getLogger
+
+from .capturing_traffic_generator import (
+=C2=A0 =C2=A0 CapturingTrafficGenerator,
+=C2=A0 =C2=A0 _get_default_capture_name,
+)
+from .hw.port import Port
+from .tg_node import TGNode
+
+
+class ScapyTrafficGenerator(CapturingTrafficGenerator):
+=C2=A0 =C2=A0 """Provides access to scapy functions via an = RPC interface.
+
+=C2=A0 =C2=A0 The traffic generator first starts an XML-RPC on the remote = TG node.
+=C2=A0 =C2=A0 Then it populates the server with functions which use the Sc= apy library
+=C2=A0 =C2=A0 to send/receive traffic.
+
+=C2=A0 =C2=A0 Any packets sent to the remote server are first converted to= bytes.
+=C2=A0 =C2=A0 They are received as xmlrpc.client.Binary objects on the ser= ver side.
+=C2=A0 =C2=A0 When the server sends the packets back, they are also receiv= ed as
+=C2=A0 =C2=A0 xmlrpc.client.Binary object on the client side, are converte= d back to Scapy
+=C2=A0 =C2=A0 packets and only then returned from the methods.
+
+=C2=A0 =C2=A0 Arguments:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 tg_node: The node where the traffic generator = resides.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 config: The user configuration of the traffic = generator.
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 _config: ScapyTrafficGeneratorConfig
+=C2=A0 =C2=A0 _tg_node: TGNode
+
+=C2=A0 =C2=A0 def __init__(self, tg_node: TGNode, config: ScapyTrafficGene= ratorConfig):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._config =3D config
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._tg_node =3D tg_node
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger =3D getLogger(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"{self._tg_node.name} {self.= _config.traffic_generator_type}"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 assert (
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self._tg_node.config.os =3D=3D O= S.linux
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 ), "Linux is the only supported OS for sc= apy traffic generation"
+
+=C2=A0 =C2=A0 def _send_packets(self, packets: list[Packet], port: Port) -= > None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 raise NotImplementedError()
+
+=C2=A0 =C2=A0 def _send_packets_and_capture(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 packets: list[Packet],
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 send_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 receive_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 duration: float,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 capture_name: str =3D _get_default_capture_nam= e(),
+=C2=A0 =C2=A0 ) -> list[Packet]:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 raise NotImplementedError()
+
+=C2=A0 =C2=A0 def close(self):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 pass
diff --git a/dts/framework/testbed_model/tg_node.py b/dts/framework/testbed= _model/tg_node.py
new file mode 100644
index 0000000000..27025cfa31
--- /dev/null
+++ b/dts/framework/testbed_model/tg_node.py
@@ -0,0 +1,99 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2010-2014 Intel Corporation
+# Copyright(c) 2022 University of New Hampshire
+# Copyright(c) 2023 PANTHEON.tech s.r.o.
+
+"""Traffic generator node.
+
+This is the node where the traffic generator resides.
+The distinction between a node and a traffic generator is as follows:
+A node is a host that DTS connects to. It could be a baremetal server,
+a VM or a container.
+A traffic generator is software running on the node.
+A traffic generator node is a node running a traffic generator.
+A node can be a traffic generator node as well as system under test node.<= br> +"""
+
+from scapy.packet import Packet=C2=A0 # type: ignore[import]
+
+from framework.config import (
+=C2=A0 =C2=A0 ScapyTrafficGeneratorConfig,
+=C2=A0 =C2=A0 TGNodeConfiguration,
+=C2=A0 =C2=A0 TrafficGeneratorType,
+)
+from framework.exception import ConfigurationError
+
+from .capturing_traffic_generator import CapturingTrafficGenerator
+from .hw.port import Port
+from .node import Node
+
+
+class TGNode(Node):
+=C2=A0 =C2=A0 """Manage connections to a node with a traffi= c generator.
+
+=C2=A0 =C2=A0 Apart from basic node management capabilities, the Traffic G= enerator node has
+=C2=A0 =C2=A0 specialized methods for handling the traffic generator runni= ng on it.
+
+=C2=A0 =C2=A0 Arguments:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 node_config: The user configuration of the tra= ffic generator node.
+
+=C2=A0 =C2=A0 Attributes:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 traffic_generator: The traffic generator runni= ng on the node.
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 traffic_generator: CapturingTrafficGenerator
+
+=C2=A0 =C2=A0 def __init__(self, node_config: TGNodeConfiguration):
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 super(TGNode, self).__init__(node_config)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.traffic_generator =3D create_traffic_gene= rator(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 self, node_config.traffic_genera= tor
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.info(f"Created node: {self.name}&q= uot;)
+
+=C2=A0 =C2=A0 def send_packet_and_capture(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 packet: Packet,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 send_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 receive_port: Port,
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 duration: float =3D 1,
+=C2=A0 =C2=A0 ) -> list[Packet]:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """Send a packet, return receiv= ed traffic.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Send a packet on the send_port and then return= all traffic captured
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 on the receive_port for the given duration. Al= so record the captured traffic
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 in a pcap file.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Args:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 packet: The packet to send.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 send_port: The egress port on th= e TG node.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 receive_port: The ingress port i= n the TG node.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 duration: Capture traffic for th= is amount of time after sending the packet.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Returns:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0A list of received packets= . May be empty if no packets are captured.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return self.traffic_generator.send_packet_and_= capture(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 packet, send_port, receive_port,= duration
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+
+=C2=A0 =C2=A0 def close(self) -> None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """Free all resources used by t= he node"""
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.traffic_generator.close()
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 super(TGNode, self).close()
+
+
+def create_traffic_generator(
+=C2=A0 =C2=A0 tg_node: TGNode, traffic_generator_config: ScapyTrafficGener= atorConfig
+) -> CapturingTrafficGenerator:
+=C2=A0 =C2=A0 """A factory function for creating traffic ge= nerator object from user config."""
+
+=C2=A0 =C2=A0 from .scapy import ScapyTrafficGenerator
+
+=C2=A0 =C2=A0 match traffic_generator_config.traffic_generator_type:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 case TrafficGeneratorType.SCAPY:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 return ScapyTrafficGenerator(tg_= node, traffic_generator_config)
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 case _:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 raise ConfigurationError(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 "Unknown traf= fic generator: "
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 f"{traffic_ge= nerator_config.traffic_generator_type}"
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 )

Would= it be possible here to do something like what we did in create_interactive= _shell with a TypeVar where we can initialize it directly? It would change = from using the enum to setting the traffic_generator_config.traffic_generat= or_type to a specific class in the config (in this case, ScapyTrafficGenera= tor), but I think it would be possible to change in the from_dict method wh= ere we could set this type to the class directly instead of the enum (or ma= ybe had the enum relate it's values to the classes themselves).

I thin= k this would make some things slightly more complicated (like how we would = map from conf.yaml to one of the classes and all of those needing to be imp= orted in config/__init__.py) but it would save developers in the future fro= m having to add to two different places=C2=A0 (the enum in=C2=A0 config/__i= nit__.py and this match statement) and save this list from being arbitraril= y long. I think this is fine for this patch but maybe when we expand the tr= affic generator or the scapy generator it could be worth thinking about.

=
D= o you think it would make sense to change it in this way or would that be s= omewhat unnecessary in your eyes?
=C2=A0
diff --git a/dts/framework/testbed_model/traffic_generator.py b/dts/framewo= rk/testbed_model/traffic_generator.py
new file mode 100644
index 0000000000..28c35d3ce4
--- /dev/null
+++ b/dts/framework/testbed_model/traffic_generator.py
@@ -0,0 +1,72 @@
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 University of New Hampshire
+# Copyright(c) 2023 PANTHEON.tech s.r.o.
+
+"""The base traffic generator.
+
+These traffic generators can't capture received traffic,
+only count the number of received packets.
+"""
+
+from abc import ABC, abstractmethod
+
+from scapy.packet import Packet=C2=A0 # type: ignore[import]
+
+from framework.logger import DTSLOG
+from framework.utils import get_packet_summaries
+
+from .hw.port import Port
+
+
+class TrafficGenerator(ABC):
+=C2=A0 =C2=A0 """The base traffic generator.
+
+=C2=A0 =C2=A0 Defines the few basic methods that each traffic generator mu= st implement.
+=C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 _logger: DTSLOG
+
+=C2=A0 =C2=A0 def send_packet(self, packet: Packet, port: Port) -> None= :
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """Send a packet and block unti= l it is fully sent.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 What fully sent means is defined by the traffi= c generator.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Args:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 packet: The packet to send.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 port: The egress port on the TG = node.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self.send_packets([packet], port)
+
+=C2=A0 =C2=A0 def send_packets(self, packets: list[Packet], port: Port) -&= gt; None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """Send packets and block until= they are fully sent.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 What fully sent means is defined by the traffi= c generator.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Args:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 packets: The packets to send. +=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 port: The egress port on the TG = node.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.info(f"Sending packet{'s= 9; if len(packets) > 1 else ''}.")
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._logger.debug(get_packet_summaries(packet= s))
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 self._send_packets(packets, port)
+
+=C2=A0 =C2=A0 @abstractmethod
+=C2=A0 =C2=A0 def _send_packets(self, packets: list[Packet], port: Port) -= > None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 The extended classes must implement this metho= d which
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 sends packets on send_port. The method should = block until all packets
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 are fully sent.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+
+=C2=A0 =C2=A0 @property
+=C2=A0 =C2=A0 def is_capturing(self) -> bool:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """Whether this traffic generat= or can capture traffic.
+
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 Returns:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 True if the traffic generator ca= n capture traffic, False otherwise.
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 return False
+
+=C2=A0 =C2=A0 @abstractmethod
+=C2=A0 =C2=A0 def close(self) -> None:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 """Free all resources used by t= he traffic generator."""
diff --git a/dts/framework/utils.py b/dts/framework/utils.py
index 60abe46edf..d27c2c5b5f 100644
--- a/dts/framework/utils.py
+++ b/dts/framework/utils.py
@@ -4,6 +4,7 @@
=C2=A0# Copyright(c) 2022-2023 University of New Hampshire

=C2=A0import atexit
+import json
=C2=A0import os
=C2=A0import subprocess
=C2=A0import sys
@@ -11,6 +12,8 @@
=C2=A0from pathlib import Path
=C2=A0from subprocess import SubprocessError

+from scapy.packet import Packet=C2=A0 # type: ignore[import]
+
=C2=A0from .exception import ConfigurationError


@@ -64,6 +67,16 @@ def expand_range(range_str: str) -> list[int]:
=C2=A0 =C2=A0 =C2=A0return expanded_range


+def get_packet_summaries(packets: list[Packet]):
+=C2=A0 =C2=A0 if len(packets) =3D=3D 1:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 packet_summaries =3D packets[0].summary()
+=C2=A0 =C2=A0 else:
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 packet_summaries =3D json.dumps(
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 =C2=A0 list(map(lambda pkt: pkt.summary= (), packets)), indent=3D4
+=C2=A0 =C2=A0 =C2=A0 =C2=A0 )
+=C2=A0 =C2=A0 return f"Packet contents: \n{packet_summaries}" +
+
=C2=A0def RED(text: str) -> str:
=C2=A0 =C2=A0 =C2=A0return f"\u001B[31;1m{str(text)}\u001B[0m"
--
2.34.1

--000000000000db7ae40600c84fc3--