From patchwork Mon Jul 17 11:07:04 2023 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: =?utf-8?q?Juraj_Linke=C5=A1?= X-Patchwork-Id: 129572 X-Patchwork-Delegate: thomas@monjalon.net Return-Path: X-Original-To: patchwork@inbox.dpdk.org Delivered-To: patchwork@inbox.dpdk.org Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id 54FA042E9B; Mon, 17 Jul 2023 13:07:20 +0200 (CEST) Received: from mails.dpdk.org (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id 2F9D242D31; Mon, 17 Jul 2023 13:07:15 +0200 (CEST) Received: from mail-lj1-f173.google.com (mail-lj1-f173.google.com [209.85.208.173]) by mails.dpdk.org (Postfix) with ESMTP id A1EE940698 for ; Mon, 17 Jul 2023 13:07:13 +0200 (CEST) Received: by mail-lj1-f173.google.com with SMTP id 38308e7fff4ca-2b70404a5a0so69011991fa.2 for ; Mon, 17 Jul 2023 04:07:13 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=pantheon.tech; s=google; t=1689592033; x=1692184033; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to; bh=azSSDMeHYQKYS5dSNFwXPRhScxzp840mhdCDnJCfUNg=; b=XBlxpcauhLy4KKIWcffqhQko9FCN9LT4hU2kRtapSHnWNh9A+0ipD4HL8ewErBXlQu V/9MNjEPqNjImp6i+yxJ+zwmxmlUO5BgRhuQ49asK9rEZhq+pygKIFJWhFleDoIoF+e3 Pown2brXPRu8u+KPPzHqo/NgURlPTpmivYrKy2myYR9FVJJtObxCr9v3lUa/xLrI+WWs OSYflX/kG4fYMaAqXPiTy55uhjhgg+yoWaOWQcAmPV6yHG1OxiuEimgz7jqLK4jYFQvi Zuwc3xT3hjCzpdzazKWIlzUEEvXZ3qadmjolfiTdcBiH/QDB8l+9PHbE6yRVjQkSJxuR DTaA== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1689592033; x=1692184033; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-message-state:from:to:cc :subject:date:message-id:reply-to; bh=azSSDMeHYQKYS5dSNFwXPRhScxzp840mhdCDnJCfUNg=; b=kuhUkJnuADCOjjCHHVk6oQ6dKEGs4+nc37Auv3R0V21V0Y0A9nDaePwE3H1ZN716Ws L3oKN+EobRNLxiW5/nXdU9Y0ovZFS4WFYBSKbdLXG4tyQUUCZlBeft1tq9ZhILItrXP+ w1eWSzx9fVnpjcTYXe/NXk40Ct0M9b7K/S4sr2v7sk9Ou33RFREGPSP1sbygD09jKjMy 0dFC+fubNHlpQj4BqRq/KFhqvlv3mj+Es25QbSMopapzTLv8EGc90Crk7OPxMJ2wtPsd vyLoVIaPcCT7DlpSkBkVV+yWlWil44h8/ucByAq9dfGgyaFg0i3xS0ZHFRm0KL+HxAG5 12wA== X-Gm-Message-State: ABy/qLZGkVkUGDlMQtrgv10iPdg9nZxh8PDOUkOb1spT5H6Qua51XgI3 hNLMw4SGkQUdyEojB9gsWIv8DA== X-Google-Smtp-Source: APBJJlFJ3jcxUiXoYGzK3zkCy57wbmnJGS+bvgllbhFLSc2YX+Uectu7DMinselU9fOEWAdjuiw1Ng== X-Received: by 2002:a2e:7405:0:b0:2b6:d13a:8e34 with SMTP id p5-20020a2e7405000000b002b6d13a8e34mr10384937ljc.46.1689592032960; Mon, 17 Jul 2023 04:07:12 -0700 (PDT) Received: from jlinkes-PT-Latitude-5530.. (ip-46.34.239.87.o2inet.sk. [46.34.239.87]) by smtp.gmail.com with ESMTPSA id s21-20020a170906355500b0098de7d28c34sm8995051eja.193.2023.07.17.04.07.11 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 17 Jul 2023 04:07:12 -0700 (PDT) From: =?utf-8?q?Juraj_Linke=C5=A1?= To: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com, jspewock@iol.unh.edu, probb@iol.unh.edu Cc: dev@dpdk.org, =?utf-8?q?Juraj_Linke=C5=A1?= Subject: [PATCH v2 1/6] dts: add scapy dependency Date: Mon, 17 Jul 2023 13:07:04 +0200 Message-Id: <20230717110709.39220-2-juraj.linkes@pantheon.tech> X-Mailer: git-send-email 2.34.1 In-Reply-To: <20230717110709.39220-1-juraj.linkes@pantheon.tech> References: <20230420093109.594704-1-juraj.linkes@pantheon.tech> <20230717110709.39220-1-juraj.linkes@pantheon.tech> MIME-Version: 1.0 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 Required for scapy traffic generator. Signed-off-by: Juraj Linkeš --- dts/poetry.lock | 21 ++++++++++++++++----- dts/pyproject.toml | 1 + 2 files changed, 17 insertions(+), 5 deletions(-) diff --git a/dts/poetry.lock b/dts/poetry.lock index 2438f337cd..8cb9920ec7 100644 --- a/dts/poetry.lock +++ b/dts/poetry.lock @@ -346,6 +346,19 @@ category = "main" optional = false python-versions = ">=3.6" +[[package]] +name = "scapy" +version = "2.5.0" +description = "Scapy: interactive packet manipulation tool" +category = "main" +optional = false +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*, <4" + +[package.extras] +basic = ["ipython"] +complete = ["ipython", "pyx", "cryptography (>=2.0)", "matplotlib"] +docs = ["sphinx (>=3.0.0)", "sphinx_rtd_theme (>=0.4.3)", "tox (>=3.0.0)"] + [[package]] name = "six" version = "1.16.0" @@ -409,7 +422,7 @@ jsonschema = ">=4,<5" [metadata] lock-version = "1.1" python-versions = "^3.10" -content-hash = "719c43bcaa5d181921debda884f8f714063df0b2336d61e9f64ecab034e8b139" +content-hash = "907bf4ae92b05bbdb7cf2f37fc63e530702f1fff9990afa1f8e6c369b97ba592" [metadata.files] attrs = [] @@ -431,10 +444,7 @@ mypy-extensions = [] paramiko = [] pathlib2 = [] pathspec = [] -platformdirs = [ - {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"}, - {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"}, -] +platformdirs = [] pycodestyle = [] pycparser = [] pydocstyle = [] @@ -443,6 +453,7 @@ pylama = [] pynacl = [] pyrsistent = [] pyyaml = [] +scapy = [] six = [] snowballstemmer = [] toml = [] diff --git a/dts/pyproject.toml b/dts/pyproject.toml index 50bcdb327a..bd7591f7fb 100644 --- a/dts/pyproject.toml +++ b/dts/pyproject.toml @@ -13,6 +13,7 @@ warlock = "^2.0.1" PyYAML = "^6.0" types-PyYAML = "^6.0.8" fabric = "^2.7.1" +scapy = "^2.5.0" [tool.poetry.dev-dependencies] mypy = "^0.961" From patchwork Mon Jul 17 11:07:05 2023 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: =?utf-8?q?Juraj_Linke=C5=A1?= X-Patchwork-Id: 129573 X-Patchwork-Delegate: thomas@monjalon.net Return-Path: X-Original-To: patchwork@inbox.dpdk.org Delivered-To: patchwork@inbox.dpdk.org Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id 16AF242E9B; Mon, 17 Jul 2023 13:07:28 +0200 (CEST) Received: from mails.dpdk.org (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id 672F742D30; Mon, 17 Jul 2023 13:07:18 +0200 (CEST) Received: from mail-ej1-f42.google.com (mail-ej1-f42.google.com [209.85.218.42]) by mails.dpdk.org (Postfix) with ESMTP id BC12A42D2F for ; Mon, 17 Jul 2023 13:07:14 +0200 (CEST) Received: by mail-ej1-f42.google.com with SMTP id a640c23a62f3a-98e39784a85so1171736566b.1 for ; Mon, 17 Jul 2023 04:07:14 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=pantheon.tech; s=google; t=1689592034; x=1692184034; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to; bh=LYQ5fSud0HdHTdvHxZGBV6h/Mog2ynXXqUbRmQO64Po=; b=mn1t7z86xHswi16TL0zcZKda9i9tF0XE4p06QMpspcwyWFIU+GddKQYOKTKEXaLkMi Rk3CRuTFAUE+FZZluvWORkCWLnvfEpGuWcpBO9x150hZFmg7PIE8KLK35Cd612LDj4Qa A/95toP68NOjc8dgQUG8niy5fs5dbzg5tBKDMjluVjalBHXcPpMOBpGVfEmMOesyL/VF mjNnlk/rwlUb//5+jmhfUoW6rdOWKMGb8q06JIf/KE0tfPhnVzeWn6noExDetCFJPri7 1Sx6hYSftg5I7g9AMnXPv2QkkymX4FgLX7YgGWmzQlO4wFbU3i1Pjhs6Q3MuponjuOKH n9Iw== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1689592034; x=1692184034; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-message-state:from:to:cc :subject:date:message-id:reply-to; bh=LYQ5fSud0HdHTdvHxZGBV6h/Mog2ynXXqUbRmQO64Po=; b=WYM20JnH9E8567cjOt9RsxgHl7DkgJJUprTT8/iUkh9hno+O1R1fAnpMvBRtLixaGA 29xCXRRdLkP3ogOh8wH/eJqRQJ1OvogSFvRwzwizb/PzQdLVBGs37LM3zXyoixvUg7SO 1swCfYOcg2WZPwoKp7AQ0RFH77ERIJ/tApsKuBU99DheT/EcPLUyBL6sUyYHHLLWT2Lz N/NIG60MeeTB3jxsVjGFwHkAFUMRwk7flCOjZo3CR5oMwjK6S/cknPfxeWLw81XN2gOa Lnq7OL9bPnehfa+v5rzpC/KHW0+iOg8LRrnG2vwbTYfl1E2LnuDORSBXMPUh6EyCjCn9 mULw== X-Gm-Message-State: ABy/qLZPLYs4rvipCgVqvhadmHct8GByK2K+A7h6FfFhW2266/Mcjy2D 12kwVR0AuDgPbj4KHBgnPoQqEA== X-Google-Smtp-Source: APBJJlHF4aEqhuPRglvKSOxZcBvSLul/4jJX6ny/hSrtOoTOsTKK8JbEjaOODv3TWp/4qsjj2CYSdg== X-Received: by 2002:a17:907:9809:b0:98e:1729:aeee with SMTP id ji9-20020a170907980900b0098e1729aeeemr10746829ejc.2.1689592034351; Mon, 17 Jul 2023 04:07:14 -0700 (PDT) Received: from jlinkes-PT-Latitude-5530.. (ip-46.34.239.87.o2inet.sk. [46.34.239.87]) by smtp.gmail.com with ESMTPSA id s21-20020a170906355500b0098de7d28c34sm8995051eja.193.2023.07.17.04.07.13 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 17 Jul 2023 04:07:14 -0700 (PDT) From: =?utf-8?q?Juraj_Linke=C5=A1?= To: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com, jspewock@iol.unh.edu, probb@iol.unh.edu Cc: dev@dpdk.org, =?utf-8?q?Juraj_Linke=C5=A1?= Subject: [PATCH v2 2/6] dts: add traffic generator config Date: Mon, 17 Jul 2023 13:07:05 +0200 Message-Id: <20230717110709.39220-3-juraj.linkes@pantheon.tech> X-Mailer: git-send-email 2.34.1 In-Reply-To: <20230717110709.39220-1-juraj.linkes@pantheon.tech> References: <20230420093109.594704-1-juraj.linkes@pantheon.tech> <20230717110709.39220-1-juraj.linkes@pantheon.tech> MIME-Version: 1.0 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 Node configuration - where to connect, what ports to use and what TG to use. Signed-off-by: Juraj Linkeš --- dts/conf.yaml | 26 ++++++- dts/framework/config/__init__.py | 87 +++++++++++++++++++--- dts/framework/config/conf_yaml_schema.json | 29 +++++++- dts/framework/dts.py | 12 +-- dts/framework/testbed_model/node.py | 4 +- dts/framework/testbed_model/sut_node.py | 6 +- 6 files changed, 141 insertions(+), 23 deletions(-) diff --git a/dts/conf.yaml b/dts/conf.yaml index 3a5d87cb49..7f089022ba 100644 --- a/dts/conf.yaml +++ b/dts/conf.yaml @@ -13,10 +13,11 @@ executions: skip_smoke_tests: false # optional flag that allow you to skip smoke tests test_suites: - hello_world - system_under_test: + system_under_test_node: node_name: "SUT 1" vdevs: # optional; if removed, vdevs won't be used in the execution - "crypto_openssl" + traffic_generator_node: "TG 1" nodes: - name: "SUT 1" hostname: sut1.change.me.localhost @@ -40,3 +41,26 @@ nodes: os_driver: i40e peer_node: "TG 1" peer_pci: "0000:00:08.1" + - name: "TG 1" + hostname: tg1.change.me.localhost + user: dtsuser + arch: x86_64 + os: linux + lcores: "" + ports: + - pci: "0000:00:08.0" + os_driver_for_dpdk: rdma + os_driver: rdma + peer_node: "SUT 1" + peer_pci: "0000:00:08.0" + - pci: "0000:00:08.1" + os_driver_for_dpdk: rdma + os_driver: rdma + peer_node: "SUT 1" + peer_pci: "0000:00:08.1" + use_first_core: false + hugepages: # optional; if removed, will use system hugepage configuration + amount: 256 + force_first_numa: false + traffic_generator: + type: SCAPY diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py index fad56cc520..72aa021b97 100644 --- a/dts/framework/config/__init__.py +++ b/dts/framework/config/__init__.py @@ -13,7 +13,7 @@ from dataclasses import dataclass from enum import Enum, auto, unique from pathlib import PurePath -from typing import Any, TypedDict +from typing import Any, TypedDict, Union import warlock # type: ignore import yaml @@ -55,6 +55,11 @@ class Compiler(StrEnum): msvc = auto() +@unique +class TrafficGeneratorType(StrEnum): + SCAPY = auto() + + # Slots enables some optimizations, by pre-allocating space for the defined # attributes in the underlying data structure. # @@ -79,6 +84,29 @@ class PortConfig: def from_dict(node: str, d: dict) -> "PortConfig": return PortConfig(node=node, **d) + def __str__(self) -> str: + return f"Port {self.pci} on node {self.node}" + + +@dataclass(slots=True, frozen=True) +class TrafficGeneratorConfig: + traffic_generator_type: TrafficGeneratorType + + @staticmethod + def from_dict(d: dict): + # This looks useless now, but is designed to allow expansion to traffic + # generators that require more configuration later. + match TrafficGeneratorType(d["type"]): + case TrafficGeneratorType.SCAPY: + return ScapyTrafficGeneratorConfig( + traffic_generator_type=TrafficGeneratorType.SCAPY + ) + + +@dataclass(slots=True, frozen=True) +class ScapyTrafficGeneratorConfig(TrafficGeneratorConfig): + pass + @dataclass(slots=True, frozen=True) class NodeConfiguration: @@ -90,17 +118,17 @@ class NodeConfiguration: os: OS lcores: str use_first_core: bool - memory_channels: int hugepages: HugepageConfiguration | None ports: list[PortConfig] @staticmethod - def from_dict(d: dict) -> "NodeConfiguration": + def from_dict(d: dict) -> Union["SutNodeConfiguration", "TGNodeConfiguration"]: hugepage_config = d.get("hugepages") if hugepage_config: if "force_first_numa" not in hugepage_config: hugepage_config["force_first_numa"] = False hugepage_config = HugepageConfiguration(**hugepage_config) + common_config = { "name": d["name"], "hostname": d["hostname"], @@ -110,12 +138,31 @@ def from_dict(d: dict) -> "NodeConfiguration": "os": OS(d["os"]), "lcores": d.get("lcores", "1"), "use_first_core": d.get("use_first_core", False), - "memory_channels": d.get("memory_channels", 1), "hugepages": hugepage_config, "ports": [PortConfig.from_dict(d["name"], port) for port in d["ports"]], } - return NodeConfiguration(**common_config) + if "traffic_generator" in d: + return TGNodeConfiguration( + traffic_generator=TrafficGeneratorConfig.from_dict( + d["traffic_generator"] + ), + **common_config, + ) + else: + return SutNodeConfiguration( + memory_channels=d.get("memory_channels", 1), **common_config + ) + + +@dataclass(slots=True, frozen=True) +class SutNodeConfiguration(NodeConfiguration): + memory_channels: int + + +@dataclass(slots=True, frozen=True) +class TGNodeConfiguration(NodeConfiguration): + traffic_generator: ScapyTrafficGeneratorConfig @dataclass(slots=True, frozen=True) @@ -194,23 +241,40 @@ class ExecutionConfiguration: perf: bool func: bool test_suites: list[TestSuiteConfig] - system_under_test: NodeConfiguration + system_under_test_node: SutNodeConfiguration + traffic_generator_node: TGNodeConfiguration vdevs: list[str] skip_smoke_tests: bool @staticmethod - def from_dict(d: dict, node_map: dict) -> "ExecutionConfiguration": + def from_dict( + d: dict, node_map: dict[str, Union[SutNodeConfiguration | TGNodeConfiguration]] + ) -> "ExecutionConfiguration": build_targets: list[BuildTargetConfiguration] = list( map(BuildTargetConfiguration.from_dict, d["build_targets"]) ) test_suites: list[TestSuiteConfig] = list( map(TestSuiteConfig.from_dict, d["test_suites"]) ) - sut_name = d["system_under_test"]["node_name"] + sut_name = d["system_under_test_node"]["node_name"] skip_smoke_tests = d.get("skip_smoke_tests", False) assert sut_name in node_map, f"Unknown SUT {sut_name} in execution {d}" + system_under_test_node = node_map[sut_name] + assert isinstance( + system_under_test_node, SutNodeConfiguration + ), f"Invalid SUT configuration {system_under_test_node}" + + tg_name = d["traffic_generator_node"] + assert tg_name in node_map, f"Unknown TG {tg_name} in execution {d}" + traffic_generator_node = node_map[tg_name] + assert isinstance( + traffic_generator_node, TGNodeConfiguration + ), f"Invalid TG configuration {traffic_generator_node}" + vdevs = ( - d["system_under_test"]["vdevs"] if "vdevs" in d["system_under_test"] else [] + d["system_under_test_node"]["vdevs"] + if "vdevs" in d["system_under_test_node"] + else [] ) return ExecutionConfiguration( build_targets=build_targets, @@ -218,7 +282,8 @@ def from_dict(d: dict, node_map: dict) -> "ExecutionConfiguration": func=d["func"], skip_smoke_tests=skip_smoke_tests, test_suites=test_suites, - system_under_test=node_map[sut_name], + system_under_test_node=system_under_test_node, + traffic_generator_node=traffic_generator_node, vdevs=vdevs, ) @@ -229,7 +294,7 @@ class Configuration: @staticmethod def from_dict(d: dict) -> "Configuration": - nodes: list[NodeConfiguration] = list( + nodes: list[Union[SutNodeConfiguration | TGNodeConfiguration]] = list( map(NodeConfiguration.from_dict, d["nodes"]) ) assert len(nodes) > 0, "There must be a node to test" diff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json index 61f52b4365..76df84840a 100644 --- a/dts/framework/config/conf_yaml_schema.json +++ b/dts/framework/config/conf_yaml_schema.json @@ -164,6 +164,11 @@ "amount" ] }, + "mac_address": { + "type": "string", + "description": "A MAC address", + "pattern": "^([0-9A-Fa-f]{2}[:-]){5}([0-9A-Fa-f]{2})$" + }, "pci_address": { "type": "string", "pattern": "^[\\da-fA-F]{4}:[\\da-fA-F]{2}:[\\da-fA-F]{2}.\\d:?\\w*$" @@ -286,6 +291,22 @@ ] }, "minimum": 1 + }, + "traffic_generator": { + "oneOf": [ + { + "type": "object", + "description": "Scapy traffic generator. Used for functional testing.", + "properties": { + "type": { + "type": "string", + "enum": [ + "SCAPY" + ] + } + } + } + ] } }, "additionalProperties": false, @@ -336,7 +357,7 @@ "description": "Optional field that allows you to skip smoke testing", "type": "boolean" }, - "system_under_test": { + "system_under_test_node": { "type":"object", "properties": { "node_name": { @@ -353,6 +374,9 @@ "required": [ "node_name" ] + }, + "traffic_generator_node": { + "$ref": "#/definitions/node_name" } }, "additionalProperties": false, @@ -361,7 +385,8 @@ "perf", "func", "test_suites", - "system_under_test" + "system_under_test_node", + "traffic_generator_node" ] }, "minimum": 1 diff --git a/dts/framework/dts.py b/dts/framework/dts.py index 7b09d8fba8..372bc72787 100644 --- a/dts/framework/dts.py +++ b/dts/framework/dts.py @@ -38,17 +38,17 @@ def run_all() -> None: # for all Execution sections for execution in CONFIGURATION.executions: sut_node = None - if execution.system_under_test.name in nodes: + if execution.system_under_test_node.name in nodes: # a Node with the same name already exists - sut_node = nodes[execution.system_under_test.name] + sut_node = nodes[execution.system_under_test_node.name] else: # the SUT has not been initialized yet try: - sut_node = SutNode(execution.system_under_test) + sut_node = SutNode(execution.system_under_test_node) result.update_setup(Result.PASS) except Exception as e: dts_logger.exception( - f"Connection to node {execution.system_under_test} failed." + f"Connection to node {execution.system_under_test_node} failed." ) result.update_setup(Result.FAIL, e) else: @@ -87,7 +87,9 @@ def _run_execution( Run the given execution. This involves running the execution setup as well as running all build targets in the given execution. """ - dts_logger.info(f"Running execution with SUT '{execution.system_under_test.name}'.") + dts_logger.info( + f"Running execution with SUT '{execution.system_under_test_node.name}'." + ) execution_result = result.add_execution(sut_node.config, sut_node.node_info) try: diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py index c5147e0ee6..d2d55d904e 100644 --- a/dts/framework/testbed_model/node.py +++ b/dts/framework/testbed_model/node.py @@ -48,6 +48,8 @@ def __init__(self, node_config: NodeConfiguration): self._logger = getLogger(self.name) self.main_session = create_session(self.config, self.name, self._logger) + self._logger.info(f"Connected to node: {self.name}") + self._get_remote_cpus() # filter the node lcores according to user config self.lcores = LogicalCoreListFilter( @@ -56,8 +58,6 @@ def __init__(self, node_config: NodeConfiguration): self._other_sessions = [] - self._logger.info(f"Created node: {self.name}") - def set_up_execution(self, execution_config: ExecutionConfiguration) -> None: """ Perform the execution setup that will be done for each execution diff --git a/dts/framework/testbed_model/sut_node.py b/dts/framework/testbed_model/sut_node.py index 53953718a1..bcad364435 100644 --- a/dts/framework/testbed_model/sut_node.py +++ b/dts/framework/testbed_model/sut_node.py @@ -13,8 +13,8 @@ BuildTargetConfiguration, BuildTargetInfo, InteractiveApp, - NodeConfiguration, NodeInfo, + SutNodeConfiguration, ) from framework.remote_session import ( CommandResult, @@ -83,6 +83,7 @@ class SutNode(Node): Another key capability is building DPDK according to given build target. """ + config: SutNodeConfiguration _dpdk_prefix_list: list[str] _dpdk_timestamp: str _build_target_config: BuildTargetConfiguration | None @@ -95,7 +96,7 @@ class SutNode(Node): _node_info: NodeInfo | None _compiler_version: str | None - def __init__(self, node_config: NodeConfiguration): + def __init__(self, node_config: SutNodeConfiguration): super(SutNode, self).__init__(node_config) self._dpdk_prefix_list = [] self._build_target_config = None @@ -110,6 +111,7 @@ def __init__(self, node_config: NodeConfiguration): self._dpdk_version = None self._node_info = None self._compiler_version = None + self._logger.info(f"Created node: {self.name}") @property def _remote_dpdk_dir(self) -> PurePath: From patchwork Mon Jul 17 11:07:06 2023 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: =?utf-8?q?Juraj_Linke=C5=A1?= X-Patchwork-Id: 129574 X-Patchwork-Delegate: thomas@monjalon.net Return-Path: X-Original-To: patchwork@inbox.dpdk.org Delivered-To: patchwork@inbox.dpdk.org Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id B792542E9B; Mon, 17 Jul 2023 13:07:35 +0200 (CEST) Received: from mails.dpdk.org (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id 9CF0742D3B; Mon, 17 Jul 2023 13:07:19 +0200 (CEST) Received: from mail-ej1-f51.google.com (mail-ej1-f51.google.com [209.85.218.51]) by mails.dpdk.org (Postfix) with ESMTP id 9DCCD42D37 for ; Mon, 17 Jul 2023 13:07:16 +0200 (CEST) Received: by mail-ej1-f51.google.com with SMTP id a640c23a62f3a-98377c5d53eso599096466b.0 for ; Mon, 17 Jul 2023 04:07:16 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=pantheon.tech; s=google; t=1689592036; x=1692184036; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to; bh=m2aaolLjNpNhk1GbWfphbZGtbSvpOE4I5vgdlLixASU=; b=lOyNiJjxCNAEwsVpyrO577H9vQpptMhRSh4qz0Og5utjQI3nXg1M8todsnvQq6AyE3 ouknWMefbyvyO9PTpj4oR3t5LQd8zGgCeFOJLdTnYHO05wX2D6na8FpEKUiDLh2AQrYu wcROpXV4RgrVuP5yE9Qyi+emd2ZKNk1zVzt8k41p9MgqnKc8zzfbZ9M4yECdgHQxdd4G M1e6tHJI2ZtQItXf3fFVAkS16x2l5lRGMnXnXsft6itxI5BrCPldXWePHJCdKsE3CN/J 2WfSYLEBd3MrhWdzdoHfvcWOPKN3G/X/d4S15pE2pT1resrEdqw8rFnp7nZ+E8RahQ88 /9YQ== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1689592036; x=1692184036; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-message-state:from:to:cc :subject:date:message-id:reply-to; bh=m2aaolLjNpNhk1GbWfphbZGtbSvpOE4I5vgdlLixASU=; b=GGxoYZrv90U3K5wxzGxNLGbL6y2R9eNZgU2602GexCkzICRvVE6TqEC2NUGYNLtU2c P63ri1L3NZx/xOAb68cqB/QByTMcb/2jQR7eVF5hnAsc2VdZmlFg/dYB9evPjprYpcMW x/lNFbPvDtORhy4Ot1ABL1UFuU48EF6vIGZbJzBivGAQIGvYALFni+lvnHtDlABnkxvR DtVizHMcFQgoL+aBCqxAMbY2uNkd7oGYPxX+2vkAWpK/SlC1QKx56BhEy8D8T6ZvlGTt 9LkBYw+SUHAH3A630zxa+qWZXs38IHarJct+Ba45gCFkYc0tAnj0eOFsCZWp9WmOKzSc H1xQ== X-Gm-Message-State: ABy/qLYQBKcgtz3VdhkWNwYhz+btGYk+4aAf8F/u6SlrGV2VlOmm2U2R AlpY5MSRvie7a2GoIjjOrzd9Yn2lXMOtMjWyNIGqMg== X-Google-Smtp-Source: APBJJlGQLq+P8j63lZw+daB0ICh//tJ3yZQpASgaGAEk8/HkzLb3xE/ghdTsNAzUKhOnbY65Iswh1Q== X-Received: by 2002:a17:906:9e25:b0:993:f8b2:d6fa with SMTP id fp37-20020a1709069e2500b00993f8b2d6famr9494895ejc.21.1689592036082; Mon, 17 Jul 2023 04:07:16 -0700 (PDT) Received: from jlinkes-PT-Latitude-5530.. (ip-46.34.239.87.o2inet.sk. [46.34.239.87]) by smtp.gmail.com with ESMTPSA id s21-20020a170906355500b0098de7d28c34sm8995051eja.193.2023.07.17.04.07.14 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 17 Jul 2023 04:07:15 -0700 (PDT) From: =?utf-8?q?Juraj_Linke=C5=A1?= To: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com, jspewock@iol.unh.edu, probb@iol.unh.edu Cc: dev@dpdk.org, =?utf-8?q?Juraj_Linke=C5=A1?= Subject: [PATCH v2 3/6] dts: traffic generator abstractions Date: Mon, 17 Jul 2023 13:07:06 +0200 Message-Id: <20230717110709.39220-4-juraj.linkes@pantheon.tech> X-Mailer: git-send-email 2.34.1 In-Reply-To: <20230717110709.39220-1-juraj.linkes@pantheon.tech> References: <20230420093109.594704-1-juraj.linkes@pantheon.tech> <20230717110709.39220-1-juraj.linkes@pantheon.tech> MIME-Version: 1.0 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 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š --- 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==2.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 = 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] = {} + sut_nodes: dict[str, SutNode] = {} + tg_nodes: dict[str, TGNode] = {} try: # for all Execution sections for execution in CONFIGURATION.executions: - sut_node = None - if execution.system_under_test_node.name in nodes: - # a Node with the same name already exists - sut_node = nodes[execution.system_under_test_node.name] - else: - # the SUT has not been initialized yet - try: + sut_node = sut_nodes.get(execution.system_under_test_node.name) + tg_node = tg_nodes.get(execution.traffic_generator_node.name) + + try: + if not sut_node: sut_node = SutNode(execution.system_under_test_node) - 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] = sut_node - - if sut_node: - _run_execution(sut_node, execution, result) + sut_nodes[sut_node.name] = sut_node + if not tg_node: + tg_node = TGNode(execution.traffic_generator_node) + tg_nodes[tg_node.name] = tg_node + result.update_setup(Result.PASS) + except Exception as e: + failed_node = execution.system_under_test_node.name + if sut_node: + failed_node = execution.traffic_generator_node.name + 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 as 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_result) 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 = 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=True ) + + def update_ports(self, ports: list[Port]) -> None: + self._logger.debug("Gathering port info.") + for port in ports: + assert ( + port.node == self.name + ), "Attempted to gather port info on the wrong node" + + port_info_list = self._get_lshw_info() + for port in ports: + for port_info in port_info_list: + if f"pci@{port.pci}" == port_info.get("businfo"): + self._update_port_attr( + port, port_info.get("logicalname"), "logical_name" + ) + 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 = self.send_command("lshw -quiet -json -C network", verify=True) + 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 = "up" if enable else "down" + self.send_command( + f"ip link set dev {port.logical_name} {state}", privileged=True + ) 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, TestPmdShell 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 = sut_node + self.tg_node = tg_node self._logger = getLogger(self.__class__.__name__) self._test_cases_to_run = 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. +""" + +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. + + 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 = _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 = _get_default_capture_name(), + ) -> list[Packet]: + """Send packets, return received traffic. + + Send packets 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: + 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 = 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 received packets. + """ + + def _write_capture_from_packets(self, capture_name: str, packets: list[Packet]): + file_name = 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=True, frozen=True) +class PortIdentifier: + node: str + pci: str + + +@dataclass(slots=True) +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 it. + 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 = "" + logical_name: str = "" + + def __init__(self, node_name: str, config: PortConfig): + self.identifier = PortIdentifier( + node=node_name, + pci=config.pci, + ) + self.os_driver = config.os_driver + self.os_driver_for_dpdk = config.os_driver_for_dpdk + self.peer = PortIdentifier(node=config.peer_node, pci=config.peer_pci) + + @property + def node(self) -> str: + return self.identifier.node + + @property + def pci(self) -> str: + return self.identifier.pci + + +@dataclass(slots=True, frozen=True) +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 = [] + self._init_ports() + + def _init_ports(self) -> None: + self.ports = [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 = 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 to 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 = config + self._tg_node = tg_node + self._logger = getLogger( + f"{self._tg_node.name} {self._config.traffic_generator_type}" + ) + + assert ( + self._tg_node.config.os == 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 = _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 node. + + 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 = 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 = 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: ScapyTrafficGeneratorConfig +) -> CapturingTrafficGenerator: + """A factory function for creating traffic generator object from user 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}" + ) 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) == 1: + packet_summaries = packets[0].summary() + else: + packet_summaries = json.dumps( + list(map(lambda pkt: pkt.summary(), packets)), indent=4 + ) + return f"Packet contents: \n{packet_summaries}" + + def RED(text: str) -> str: return f"\u001B[31;1m{str(text)}\u001B[0m" From patchwork Mon Jul 17 11:07:07 2023 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: =?utf-8?q?Juraj_Linke=C5=A1?= X-Patchwork-Id: 129575 X-Patchwork-Delegate: thomas@monjalon.net Return-Path: X-Original-To: patchwork@inbox.dpdk.org Delivered-To: patchwork@inbox.dpdk.org Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id 1AB0342E9B; Mon, 17 Jul 2023 13:07:50 +0200 (CEST) Received: from mails.dpdk.org (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id A6A5742D52; Mon, 17 Jul 2023 13:07:21 +0200 (CEST) Received: from mail-ej1-f44.google.com (mail-ej1-f44.google.com [209.85.218.44]) by mails.dpdk.org (Postfix) with ESMTP id 7780942D38 for ; Mon, 17 Jul 2023 13:07:18 +0200 (CEST) Received: by mail-ej1-f44.google.com with SMTP id a640c23a62f3a-9939fbb7191so897994066b.0 for ; Mon, 17 Jul 2023 04:07:18 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=pantheon.tech; s=google; t=1689592038; x=1692184038; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to; bh=WurjEEDhKXmw0OmdqHjUU44DZFn2qClO/dDEcstQhXI=; b=qKoGbUsV3NeQd2sh3cnZDdVWIaSOB2pzVGNtClAXoLQBDeBtUMtXWOvygK46rmTnkF GjNsE6IpYExXqp0/6PxDLfsELhSPYgpzCaQkTig7FEkxrpnPakQj2HSbawLO4bc3Wj8G SKgmU6RgjxZ1GRNPKiLaHG9ygy93XKyhdwqmXs5jxi0mHkXvPDoXC5HF1vO87agzQ58a Jphnt9p5B1V1e4mS52/BDSjhFcThzb9b84wtTzvQyTVV/zBkOjnUDe8P/wur47zKmPHq 93WwBvinGf+dByhoz0DCGt/4zsd6hcYmTl5aibtEEvl6LAB5O9qfWX9m0C3973CsDgxT mbBQ== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1689592038; x=1692184038; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-message-state:from:to:cc :subject:date:message-id:reply-to; bh=WurjEEDhKXmw0OmdqHjUU44DZFn2qClO/dDEcstQhXI=; b=F5cUK50XaUTbfJzoHp3u80LYXUNmeslGbVAhiNmf0niZs6tY0NViArmv1f0dR+I4ft MkUktpYBju8rimhsqvhjece69Fw/Fp739ACEEVb/u4OWC2N7mfesaiHpitjWkMpqX0nK XsE5rG5U6iAFf15ggcPgHDhSui0wIgZlfZ7cEFwVv7MouuVg2D6xmMgvijEKgnoLX6Nk xckeFJYro1eqmfC8JrTWrXV80FdCFhIvigmp00YhwWzl3DkqDq4A+kw8LTaqjzvhMo9K 7QfA98lEHdIgmb+xVo4ElRru/CUHW+PG3Hv4cHcjAWKWOECf15svrs/N2Jg4vv8ihoRX gdJw== X-Gm-Message-State: ABy/qLYfb3ueT+sPTeigUmdJKl9yt/QhhWCCiE/ETEhB4BF7/mMheQBk M7E75yfaSaSVxjoNp74UcO0lRg== X-Google-Smtp-Source: APBJJlHGH4Jl+GwKNenLsV0RwFWq8pYiw4wCRD6W9QL8hZNe9ukxM7tOgjd6Y9PqWtljyRqCGJbOJA== X-Received: by 2002:a17:906:73d1:b0:992:1005:928d with SMTP id n17-20020a17090673d100b009921005928dmr10654069ejl.8.1689592038090; Mon, 17 Jul 2023 04:07:18 -0700 (PDT) Received: from jlinkes-PT-Latitude-5530.. (ip-46.34.239.87.o2inet.sk. [46.34.239.87]) by smtp.gmail.com with ESMTPSA id s21-20020a170906355500b0098de7d28c34sm8995051eja.193.2023.07.17.04.07.16 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 17 Jul 2023 04:07:17 -0700 (PDT) From: =?utf-8?q?Juraj_Linke=C5=A1?= To: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com, jspewock@iol.unh.edu, probb@iol.unh.edu Cc: dev@dpdk.org, =?utf-8?q?Juraj_Linke=C5=A1?= Subject: [PATCH v2 4/6] dts: add python remote interactive shell Date: Mon, 17 Jul 2023 13:07:07 +0200 Message-Id: <20230717110709.39220-5-juraj.linkes@pantheon.tech> X-Mailer: git-send-email 2.34.1 In-Reply-To: <20230717110709.39220-1-juraj.linkes@pantheon.tech> References: <20230420093109.594704-1-juraj.linkes@pantheon.tech> <20230717110709.39220-1-juraj.linkes@pantheon.tech> MIME-Version: 1.0 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 The shell can be used to remotely run any Python code interactively. Signed-off-by: Juraj Linkeš --- dts/framework/config/__init__.py | 28 +----------- dts/framework/remote_session/__init__.py | 2 +- dts/framework/remote_session/os_session.py | 42 +++++++++--------- .../remote/interactive_shell.py | 18 +++++--- .../remote_session/remote/python_shell.py | 24 +++++++++++ .../remote_session/remote/testpmd_shell.py | 33 +++----------- dts/framework/testbed_model/node.py | 35 ++++++++++++++- dts/framework/testbed_model/sut_node.py | 43 ++++++++----------- dts/tests/TestSuite_smoke_tests.py | 6 +-- 9 files changed, 119 insertions(+), 112 deletions(-) create mode 100644 dts/framework/remote_session/remote/python_shell.py diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py index 72aa021b97..b5830f6301 100644 --- a/dts/framework/config/__init__.py +++ b/dts/framework/config/__init__.py @@ -11,8 +11,7 @@ import os.path import pathlib from dataclasses import dataclass -from enum import Enum, auto, unique -from pathlib import PurePath +from enum import auto, unique from typing import Any, TypedDict, Union import warlock # type: ignore @@ -331,28 +330,3 @@ def load_config() -> Configuration: CONFIGURATION = load_config() - - -@unique -class InteractiveApp(Enum): - """An enum that represents different supported interactive applications. - - The values in this enum must all be set to objects that have a key called - "default_path" where "default_path" represents a PurePath object for the path - to the application. This default path will be passed into the handler class - for the application so that it can start the application. - """ - - testpmd = {"default_path": PurePath("app", "dpdk-testpmd")} - - @property - def path(self) -> PurePath: - """Default path of the application. - - For DPDK apps, this will be appended to the DPDK build directory. - """ - return self.value["default_path"] - - @path.setter - def path(self, path: PurePath) -> None: - self.value["default_path"] = path diff --git a/dts/framework/remote_session/__init__.py b/dts/framework/remote_session/__init__.py index 2c408c2557..1155dd8318 100644 --- a/dts/framework/remote_session/__init__.py +++ b/dts/framework/remote_session/__init__.py @@ -17,7 +17,7 @@ from framework.logger import DTSLOG from .linux_session import LinuxSession -from .os_session import OSSession +from .os_session import InteractiveShellType, OSSession from .remote import ( CommandResult, InteractiveRemoteSession, diff --git a/dts/framework/remote_session/os_session.py b/dts/framework/remote_session/os_session.py index 633d06eb5d..c17a17a267 100644 --- a/dts/framework/remote_session/os_session.py +++ b/dts/framework/remote_session/os_session.py @@ -5,11 +5,11 @@ from abc import ABC, abstractmethod from collections.abc import Iterable from pathlib import PurePath -from typing import Union +from typing import Type, TypeVar -from framework.config import Architecture, InteractiveApp, NodeConfiguration, NodeInfo +from framework.config import Architecture, NodeConfiguration, NodeInfo from framework.logger import DTSLOG -from framework.remote_session.remote import InteractiveShell, TestPmdShell +from framework.remote_session.remote import InteractiveShell from framework.settings import SETTINGS from framework.testbed_model import LogicalCore from framework.testbed_model.hw.port import Port @@ -23,6 +23,8 @@ create_remote_session, ) +InteractiveShellType = TypeVar("InteractiveShellType", bound=InteractiveShell) + class OSSession(ABC): """ @@ -81,30 +83,26 @@ def send_command( def create_interactive_shell( self, - shell_type: InteractiveApp, - path_to_app: PurePath, + shell_cls: Type[InteractiveShellType], eal_parameters: str, timeout: float, - ) -> Union[InteractiveShell, TestPmdShell]: + privileged: bool, + ) -> InteractiveShellType: """ See "create_interactive_shell" in SutNode """ - match (shell_type): - case InteractiveApp.testpmd: - return TestPmdShell( - self.interactive_session.session, - self._logger, - path_to_app, - timeout=timeout, - eal_flags=eal_parameters, - ) - case _: - self._logger.info( - f"Unhandled app type {shell_type.name}, defaulting to shell." - ) - return InteractiveShell( - self.interactive_session.session, self._logger, path_to_app, timeout - ) + app_command = ( + self._get_privileged_command(str(shell_cls.path)) + if privileged + else str(shell_cls.path) + ) + return shell_cls( + self.interactive_session.session, + self._logger, + app_command, + eal_parameters, + timeout, + ) @abstractmethod def _get_privileged_command(self, command: str) -> str: diff --git a/dts/framework/remote_session/remote/interactive_shell.py b/dts/framework/remote_session/remote/interactive_shell.py index 2cabe9edca..1211d91aa9 100644 --- a/dts/framework/remote_session/remote/interactive_shell.py +++ b/dts/framework/remote_session/remote/interactive_shell.py @@ -17,13 +17,18 @@ class InteractiveShell: _ssh_channel: Channel _logger: DTSLOG _timeout: float - _path_to_app: PurePath + _startup_command: str + _app_args: str + _default_prompt: str = "" + path: PurePath + dpdk_app: bool = False def __init__( self, interactive_session: SSHClient, logger: DTSLOG, - path_to_app: PurePath, + startup_command: str, + app_args: str = "", timeout: float = SETTINGS.timeout, ) -> None: self._interactive_session = interactive_session @@ -34,16 +39,19 @@ def __init__( self._ssh_channel.set_combine_stderr(True) # combines stdout and stderr streams self._logger = logger self._timeout = timeout - self._path_to_app = path_to_app + self._startup_command = startup_command + self._app_args = app_args self._start_application() def _start_application(self) -> None: - """Starts a new interactive application based on _path_to_app. + """Starts a new interactive application based on _startup_command. This method is often overridden by subclasses as their process for starting may look different. """ - self.send_command_get_output(f"{self._path_to_app}", "") + self.send_command_get_output( + f"{self._startup_command} {self._app_args}", self._default_prompt + ) def send_command_get_output(self, command: str, prompt: str) -> str: """Send a command and get all output before the expected ending string. diff --git a/dts/framework/remote_session/remote/python_shell.py b/dts/framework/remote_session/remote/python_shell.py new file mode 100644 index 0000000000..66d5787c86 --- /dev/null +++ b/dts/framework/remote_session/remote/python_shell.py @@ -0,0 +1,24 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright(c) 2023 PANTHEON.tech s.r.o. + +from pathlib import PurePath + +from .interactive_shell import InteractiveShell + + +class PythonShell(InteractiveShell): + _startup_command: str + _default_prompt: str = ">>>" + path: PurePath = PurePath("python3") + + def _start_application(self) -> None: + self._startup_command = f"{self._startup_command}\n" + super()._start_application() + + def send_command(self, command: str, prompt: str = _default_prompt) -> str: + """Specific way of handling the command for python + + An extra newline character is consumed in order to force the current line into + the stdout buffer. + """ + return self.send_command_get_output(f"{command}\n", prompt) diff --git a/dts/framework/remote_session/remote/testpmd_shell.py b/dts/framework/remote_session/remote/testpmd_shell.py index c0261c00f6..1288cfd10c 100644 --- a/dts/framework/remote_session/remote/testpmd_shell.py +++ b/dts/framework/remote_session/remote/testpmd_shell.py @@ -3,11 +3,6 @@ from pathlib import PurePath -from paramiko import SSHClient # type: ignore - -from framework.logger import DTSLOG -from framework.settings import SETTINGS - from .interactive_shell import InteractiveShell @@ -22,34 +17,18 @@ def __str__(self) -> str: class TestPmdShell(InteractiveShell): - expected_prompt: str = "testpmd>" + path: PurePath = PurePath("app", "dpdk-testpmd") + dpdk_app: bool = True + _default_prompt: str = "testpmd>" _eal_flags: str - def __init__( - self, - interactive_session: SSHClient, - logger: DTSLOG, - path_to_testpmd: PurePath, - eal_flags: str, - timeout: float = SETTINGS.timeout, - ) -> None: - """Initializes an interactive testpmd session using specified parameters.""" - self._eal_flags = eal_flags - - super(TestPmdShell, self).__init__( - interactive_session, - logger=logger, - path_to_app=path_to_testpmd, - timeout=timeout, - ) - def _start_application(self) -> None: - """Starts a new interactive testpmd shell using _path_to_app.""" + """Starts a new interactive testpmd shell using _startup_command.""" self.send_command( - f"{self._path_to_app} {self._eal_flags} -- -i", + f"{self._startup_command} {self._app_args} -- -i", ) - def send_command(self, command: str, prompt: str = expected_prompt) -> str: + def send_command(self, command: str, prompt: str = _default_prompt) -> str: """Specific way of handling the command for testpmd An extra newline character is consumed in order to force the current line into diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py index e09931cedf..f70e4d5ce6 100644 --- a/dts/framework/testbed_model/node.py +++ b/dts/framework/testbed_model/node.py @@ -7,7 +7,7 @@ A node is a generic host that DTS connects to and manages. """ -from typing import Any, Callable +from typing import Any, Callable, Type from framework.config import ( BuildTargetConfiguration, @@ -15,7 +15,7 @@ NodeConfiguration, ) from framework.logger import DTSLOG, getLogger -from framework.remote_session import OSSession, create_session +from framework.remote_session import InteractiveShellType, OSSession, create_session from framework.settings import SETTINGS from .hw import ( @@ -138,6 +138,37 @@ def create_session(self, name: str) -> OSSession: self._other_sessions.append(connection) return connection + def create_interactive_shell( + self, + shell_cls: Type[InteractiveShellType], + timeout: float = SETTINGS.timeout, + privileged: bool = False, + app_args: str = "", + ) -> InteractiveShellType: + """Create a handler for an interactive session. + + Instantiate shell_cls according to the remote OS specifics. + + Args: + shell_cls: The class of the shell. + timeout: Timeout for reading output from the SSH channel. If you are + reading from the buffer and don't receive any data within the timeout + it will throw an error. + privileged: Whether to run the shell with administrative privileges. + app_args: The arguments to be passed to the application. + Returns: + Instance of the desired interactive application. + """ + if not shell_cls.dpdk_app: + shell_cls.path = self.main_session.join_remote_path(shell_cls.path) + + return self.main_session.create_interactive_shell( + shell_cls, + app_args, + timeout, + privileged, + ) + def filter_lcores( self, filter_specifier: LogicalCoreCount | LogicalCoreList, diff --git a/dts/framework/testbed_model/sut_node.py b/dts/framework/testbed_model/sut_node.py index bcad364435..f0b017a383 100644 --- a/dts/framework/testbed_model/sut_node.py +++ b/dts/framework/testbed_model/sut_node.py @@ -7,21 +7,15 @@ import tarfile import time from pathlib import PurePath -from typing import Union +from typing import Type from framework.config import ( BuildTargetConfiguration, BuildTargetInfo, - InteractiveApp, NodeInfo, SutNodeConfiguration, ) -from framework.remote_session import ( - CommandResult, - InteractiveShell, - OSSession, - TestPmdShell, -) +from framework.remote_session import CommandResult, InteractiveShellType, OSSession from framework.settings import SETTINGS from framework.utils import MesonArgs @@ -359,23 +353,24 @@ def run_dpdk_app( def create_interactive_shell( self, - shell_type: InteractiveApp, + shell_cls: Type[InteractiveShellType], timeout: float = SETTINGS.timeout, - eal_parameters: EalParameters | None = None, - ) -> Union[InteractiveShell, TestPmdShell]: - """Create a handler for an interactive session. + privileged: bool = False, + eal_parameters: EalParameters | str | None = None, + ) -> InteractiveShellType: + """Factory method for creating a handler for an interactive session. - This method is a factory that calls a method in OSSession to create shells for - different DPDK applications. + Instantiate shell_cls according to the remote OS specifics. Args: - shell_type: Enum value representing the desired application. + shell_cls: The class of the shell. timeout: Timeout for reading output from the SSH channel. If you are reading from the buffer and don't receive any data within the timeout it will throw an error. + privileged: Whether to run the shell with administrative privileges. eal_parameters: List of EAL parameters to use to launch the app. If this - isn't provided, it will default to calling create_eal_parameters(). - This is ignored for base "shell" types. + isn't provided or an empty string is passed, it will default to calling + create_eal_parameters(). Returns: Instance of the desired interactive application. """ @@ -383,11 +378,11 @@ def create_interactive_shell( eal_parameters = self.create_eal_parameters() # We need to append the build directory for DPDK apps - shell_type.path = self.remote_dpdk_build_dir.joinpath(shell_type.path) - default_path = self.main_session.join_remote_path(shell_type.path) - return self.main_session.create_interactive_shell( - shell_type, - default_path, - str(eal_parameters), - timeout, + if shell_cls.dpdk_app: + shell_cls.path = self.main_session.join_remote_path( + self.remote_dpdk_build_dir, shell_cls.path + ) + + return super().create_interactive_shell( + shell_cls, timeout, privileged, str(eal_parameters) ) diff --git a/dts/tests/TestSuite_smoke_tests.py b/dts/tests/TestSuite_smoke_tests.py index 9cf547205f..e73d015bc7 100644 --- a/dts/tests/TestSuite_smoke_tests.py +++ b/dts/tests/TestSuite_smoke_tests.py @@ -3,7 +3,7 @@ import re -from framework.config import InteractiveApp, PortConfig +from framework.config import PortConfig from framework.remote_session import TestPmdDevice, TestPmdShell from framework.settings import SETTINGS from framework.test_suite import TestSuite @@ -67,9 +67,7 @@ def test_devices_listed_in_testpmd(self) -> None: Test: Uses testpmd driver to verify that devices have been found by testpmd. """ - testpmd_driver = self.sut_node.create_interactive_shell(InteractiveApp.testpmd) - # We know it should always be a TestPmdShell but mypy doesn't - assert isinstance(testpmd_driver, TestPmdShell) + testpmd_driver = self.sut_node.create_interactive_shell(TestPmdShell) dev_list: list[TestPmdDevice] = testpmd_driver.get_devices() for nic in self.nics_in_node: self.verify( From patchwork Mon Jul 17 11:07:08 2023 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: =?utf-8?q?Juraj_Linke=C5=A1?= X-Patchwork-Id: 129576 X-Patchwork-Delegate: thomas@monjalon.net Return-Path: X-Original-To: patchwork@inbox.dpdk.org Delivered-To: patchwork@inbox.dpdk.org Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id CF5B942E9B; Mon, 17 Jul 2023 13:07:56 +0200 (CEST) Received: from mails.dpdk.org (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id E8E1D42D43; Mon, 17 Jul 2023 13:07:27 +0200 (CEST) Received: from mail-ej1-f43.google.com (mail-ej1-f43.google.com [209.85.218.43]) by mails.dpdk.org (Postfix) with ESMTP id A450040698 for ; Mon, 17 Jul 2023 13:07:25 +0200 (CEST) Received: by mail-ej1-f43.google.com with SMTP id a640c23a62f3a-992af8b3b1bso617254766b.1 for ; Mon, 17 Jul 2023 04:07:25 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=pantheon.tech; s=google; t=1689592045; x=1692184045; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to; bh=zi96yCSIZ5WtpQpQtuNhnvhJw5DyhS9QIGoChHCG2Q4=; b=LfETNG/PlUiof6K2FB8eKBTwo1W0J0rkfiamp9u3qIIkz+ZzjR8vKOZ/MrdFpNbB80 3i/NERHDTZIl1WUG0MGqakdl+Lt6HIleiKsg+6MzxJiwdgT15eKkbD6cdN+n5bAi/SQM mUMJ0VgoURzvsVnNi+upZQ6wVfUVlBdOkYEoxmD5ncMiXE2k2R597JO5fARx1ZoRtawq 55lk+1kVIBItPhhn3+LzTkljjSZ6RkPCty2jcdMLK9VG75ysCAt3NSA4PMZrkbAHu082 HVRmZxwd0IMKoU7DSTDTx1YDnpqbRHb9kGiBzFBgFrpkgKCKFFeTrL+Inc1HgkY3nVNK lZ7A== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1689592045; x=1692184045; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-message-state:from:to:cc :subject:date:message-id:reply-to; bh=zi96yCSIZ5WtpQpQtuNhnvhJw5DyhS9QIGoChHCG2Q4=; b=ZIp3KcnUWC5dbGq+rUWfyv2/+NCc4kmvXiRyZq/OKY87GrE1RAtCKK14cUsHaoBDuR xBHfiuEK7l5sXflxJLgkOHwgGkzyYUBK7fCOB12OlQ1Jo8QtUzD8E2FLgJbB/xGIvQ00 NTUHRLdmADKcm6X6s2Y/nPBK6g7GO2DO3Q9gyAuaJ+wb1FVn/oan0pvdYReOgTXpvQ// sV11xGQXrKD5oXT4uMw64pAs7l8zX5sGnZDWrnhqjgSTFesR0ADlZVzKU/upR3pFS56y oQ3CDB0t1bJUMS6bGBF7oIyVrwJ7MQVVFXbkdTPWG3wiMmWRe/f3HaumvfBGtz8unUbN EVaA== X-Gm-Message-State: ABy/qLYUGBKEP7ZbU+eM3dIeArf7b1+kaFRRbCKXDNmNjNSdeitH+znC KYPz6G1figdJXLWJSt0wohx3pw== X-Google-Smtp-Source: APBJJlG9T+AkvF2RukeVfft9y7z0PowOuq+PhP0VJc1vH6ZDfLL2VlqOS6LmNt3V3TP7PNmCIhA2tA== X-Received: by 2002:a17:906:28d9:b0:991:f383:d5c3 with SMTP id p25-20020a17090628d900b00991f383d5c3mr9961280ejd.74.1689592045293; Mon, 17 Jul 2023 04:07:25 -0700 (PDT) Received: from jlinkes-PT-Latitude-5530.. (ip-46.34.239.87.o2inet.sk. [46.34.239.87]) by smtp.gmail.com with ESMTPSA id s21-20020a170906355500b0098de7d28c34sm8995051eja.193.2023.07.17.04.07.18 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 17 Jul 2023 04:07:25 -0700 (PDT) From: =?utf-8?q?Juraj_Linke=C5=A1?= To: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com, jspewock@iol.unh.edu, probb@iol.unh.edu Cc: dev@dpdk.org, =?utf-8?q?Juraj_Linke=C5=A1?= Subject: [PATCH v2 5/6] dts: scapy traffic generator implementation Date: Mon, 17 Jul 2023 13:07:08 +0200 Message-Id: <20230717110709.39220-6-juraj.linkes@pantheon.tech> X-Mailer: git-send-email 2.34.1 In-Reply-To: <20230717110709.39220-1-juraj.linkes@pantheon.tech> References: <20230420093109.594704-1-juraj.linkes@pantheon.tech> <20230717110709.39220-1-juraj.linkes@pantheon.tech> MIME-Version: 1.0 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 Scapy is a traffic generator capable of sending and receiving traffic. Since it's a software traffic generator, it's not suitable for performance testing, but it is suitable for functional testing. Signed-off-by: Juraj Linkeš --- dts/framework/remote_session/__init__.py | 1 + .../remote_session/remote/__init__.py | 1 + dts/framework/testbed_model/scapy.py | 224 +++++++++++++++++- 3 files changed, 222 insertions(+), 4 deletions(-) diff --git a/dts/framework/remote_session/__init__.py b/dts/framework/remote_session/__init__.py index 1155dd8318..00b6d1f03a 100644 --- a/dts/framework/remote_session/__init__.py +++ b/dts/framework/remote_session/__init__.py @@ -22,6 +22,7 @@ CommandResult, InteractiveRemoteSession, InteractiveShell, + PythonShell, RemoteSession, SSHSession, TestPmdDevice, diff --git a/dts/framework/remote_session/remote/__init__.py b/dts/framework/remote_session/remote/__init__.py index 03fd309f2b..075f52b646 100644 --- a/dts/framework/remote_session/remote/__init__.py +++ b/dts/framework/remote_session/remote/__init__.py @@ -9,6 +9,7 @@ from .interactive_remote_session import InteractiveRemoteSession from .interactive_shell import InteractiveShell +from .python_shell import PythonShell from .remote_session import CommandResult, RemoteSession from .ssh_session import SSHSession from .testpmd_shell import TestPmdDevice, TestPmdShell diff --git a/dts/framework/testbed_model/scapy.py b/dts/framework/testbed_model/scapy.py index 1a23dc9fa3..af0d4dbb25 100644 --- a/dts/framework/testbed_model/scapy.py +++ b/dts/framework/testbed_model/scapy.py @@ -12,10 +12,21 @@ a local server proxy. """ +import inspect +import marshal +import time +import types +import xmlrpc.client +from xmlrpc.server import SimpleXMLRPCServer + +import scapy.all # type: ignore[import] +from scapy.layers.l2 import Ether # type: ignore[import] from scapy.packet import Packet # type: ignore[import] from framework.config import OS, ScapyTrafficGeneratorConfig -from framework.logger import getLogger +from framework.logger import DTSLOG, getLogger +from framework.remote_session import PythonShell +from framework.settings import SETTINGS from .capturing_traffic_generator import ( CapturingTrafficGenerator, @@ -24,6 +35,134 @@ from .hw.port import Port from .tg_node import TGNode +""" +========= BEGIN RPC FUNCTIONS ========= + +All of the functions in this section are intended to be exported to a python +shell which runs a scapy RPC server. These functions are made available via that +RPC server to the packet generator. To add a new function to the RPC server, +first write the function in this section. Then, if you need any imports, make sure to +add them to SCAPY_RPC_SERVER_IMPORTS as well. After that, add the function to the list +in EXPORTED_FUNCTIONS. Note that kwargs (keyword arguments) do not work via xmlrpc, +so you may need to construct wrapper functions around many scapy types. +""" + +""" +Add the line needed to import something in a normal python environment +as an entry to this array. It will be imported before any functions are +sent to the server. +""" +SCAPY_RPC_SERVER_IMPORTS = [ + "from scapy.all import *", + "import xmlrpc", + "import sys", + "from xmlrpc.server import SimpleXMLRPCServer", + "import marshal", + "import pickle", + "import types", + "import time", +] + + +def scapy_send_packets_and_capture( + xmlrpc_packets: list[xmlrpc.client.Binary], + send_iface: str, + recv_iface: str, + duration: float, +) -> list[bytes]: + """RPC function to send and capture packets. + + The function is meant to be executed on the remote TG node. + + Args: + xmlrpc_packets: The packets to send. These need to be converted to + xmlrpc.client.Binary before sending to the remote server. + send_iface: The logical name of the egress interface. + recv_iface: The logical name of the ingress interface. + duration: Capture for this amount of time, in seconds. + + Returns: + A list of bytes. Each item in the list represents one packet, which needs + to be converted back upon transfer from the remote node. + """ + scapy_packets = [scapy.all.Packet(packet.data) for packet in xmlrpc_packets] + sniffer = scapy.all.AsyncSniffer( + iface=recv_iface, + store=True, + started_callback=lambda *args: scapy.all.sendp(scapy_packets, iface=send_iface), + ) + sniffer.start() + time.sleep(duration) + return [scapy_packet.build() for scapy_packet in sniffer.stop(join=True)] + + +def scapy_send_packets( + xmlrpc_packets: list[xmlrpc.client.Binary], send_iface: str +) -> None: + """RPC function to send packets. + + The function is meant to be executed on the remote TG node. + It doesn't return anything, only sends packets. + + Args: + xmlrpc_packets: The packets to send. These need to be converted to + xmlrpc.client.Binary before sending to the remote server. + send_iface: The logical name of the egress interface. + + Returns: + A list of bytes. Each item in the list represents one packet, which needs + to be converted back upon transfer from the remote node. + """ + scapy_packets = [scapy.all.Packet(packet.data) for packet in xmlrpc_packets] + scapy.all.sendp(scapy_packets, iface=send_iface, realtime=True, verbose=True) + + +""" +Functions to be exposed by the scapy RPC server. +""" +RPC_FUNCTIONS = [ + scapy_send_packets, + scapy_send_packets_and_capture, +] + +""" +========= END RPC FUNCTIONS ========= +""" + + +class QuittableXMLRPCServer(SimpleXMLRPCServer): + """Basic XML-RPC server that may be extended + by functions serializable by the marshal module. + """ + + def __init__(self, *args, **kwargs): + kwargs["allow_none"] = True + super().__init__(*args, **kwargs) + self.register_introspection_functions() + self.register_function(self.quit) + self.register_function(self.add_rpc_function) + + def quit(self) -> None: + self._BaseServer__shutdown_request = True + return None + + def add_rpc_function(self, name: str, function_bytes: xmlrpc.client.Binary): + """Add a function to the server. + + This is meant to be executed remotely. + + Args: + name: The name of the function. + function_bytes: The code of the function. + """ + function_code = marshal.loads(function_bytes.data) + function = types.FunctionType(function_code, globals(), name) + self.register_function(function) + + def serve_forever(self, poll_interval: float = 0.5) -> None: + print("XMLRPC OK") + super().serve_forever(poll_interval) + class ScapyTrafficGenerator(CapturingTrafficGenerator): """Provides access to scapy functions via an RPC interface. @@ -41,10 +180,19 @@ class ScapyTrafficGenerator(CapturingTrafficGenerator): Arguments: tg_node: The node where the traffic generator resides. config: The user configuration of the traffic generator. + + Attributes: + session: The exclusive interactive remote session created by the Scapy + traffic generator where the XML-RPC server runs. + rpc_server_proxy: The object used by clients to execute functions + on the XML-RPC server. """ + session: PythonShell + rpc_server_proxy: xmlrpc.client.ServerProxy _config: ScapyTrafficGeneratorConfig _tg_node: TGNode + _logger: DTSLOG def __init__(self, tg_node: TGNode, config: ScapyTrafficGeneratorConfig): self._config = config @@ -57,8 +205,58 @@ def __init__(self, tg_node: TGNode, config: ScapyTrafficGeneratorConfig): self._tg_node.config.os == OS.linux ), "Linux is the only supported OS for scapy traffic generation" + self.session = self._tg_node.create_interactive_shell( + PythonShell, timeout=5, privileged=True + ) + + # import libs in remote python console + for import_statement in SCAPY_RPC_SERVER_IMPORTS: + self.session.send_command(import_statement) + + # start the server + xmlrpc_server_listen_port = 8000 + self._start_xmlrpc_server_in_remote_python(xmlrpc_server_listen_port) + + # connect to the server + server_url = ( + f"http://{self._tg_node.config.hostname}:{xmlrpc_server_listen_port}" + ) + self.rpc_server_proxy = xmlrpc.client.ServerProxy( + server_url, allow_none=True, verbose=SETTINGS.verbose + ) + + # add functions to the server + for function in RPC_FUNCTIONS: + # A slightly hacky way to move a function to the remote server. + # It is constructed from the name and code on the other side. + # Pickle cannot handle functions, nor can any of the other serialization + # frameworks aside from the libraries used to generate pyc files, which + # are even more messy to work with. + function_bytes = marshal.dumps(function.__code__) + self.rpc_server_proxy.add_rpc_function(function.__name__, function_bytes) + + def _start_xmlrpc_server_in_remote_python(self, listen_port: int): + # load the source of the function + src = inspect.getsource(QuittableXMLRPCServer) + # Lines with only whitespace break the repl if in the middle of a function + # or class, so strip all lines containing only whitespace + src = "\n".join( + [line for line in src.splitlines() if not line.isspace() and line != ""] + ) + + spacing = "\n" * 4 + + # execute it in the python terminal + self.session.send_command(spacing + src + spacing) + self.session.send_command( + f"server = QuittableXMLRPCServer(('0.0.0.0', {listen_port}));" + f"server.serve_forever()", + "XMLRPC OK", + ) + def _send_packets(self, packets: list[Packet], port: Port) -> None: - raise NotImplementedError() + packets = [packet.build() for packet in packets] + self.rpc_server_proxy.scapy_send_packets(packets, port.logical_name) def _send_packets_and_capture( self, @@ -68,7 +266,25 @@ def _send_packets_and_capture( duration: float, capture_name: str = _get_default_capture_name(), ) -> list[Packet]: - raise NotImplementedError() + binary_packets = [packet.build() for packet in packets] + + xmlrpc_packets: list[ + xmlrpc.client.Binary + ] = self.rpc_server_proxy.scapy_send_packets_and_capture( + binary_packets, + send_port.logical_name, + receive_port.logical_name, + duration, + ) # type: ignore[assignment] + + scapy_packets = [Ether(packet.data) for packet in xmlrpc_packets] + return scapy_packets def close(self): - pass + try: + self.rpc_server_proxy.quit() + except ConnectionRefusedError: + # Because the python instance closes, we get no RPC response. + # Thus, this error is expected + pass + self.session.close() From patchwork Mon Jul 17 11:07:09 2023 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: =?utf-8?q?Juraj_Linke=C5=A1?= X-Patchwork-Id: 129577 X-Patchwork-Delegate: thomas@monjalon.net Return-Path: X-Original-To: patchwork@inbox.dpdk.org Delivered-To: patchwork@inbox.dpdk.org Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id 492C842E9B; Mon, 17 Jul 2023 13:08:06 +0200 (CEST) Received: from mails.dpdk.org (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id 605D542D49; Mon, 17 Jul 2023 13:07:34 +0200 (CEST) Received: from mail-ej1-f41.google.com (mail-ej1-f41.google.com [209.85.218.41]) by mails.dpdk.org (Postfix) with ESMTP id 93B6A42D3A for ; Mon, 17 Jul 2023 13:07:33 +0200 (CEST) Received: by mail-ej1-f41.google.com with SMTP id a640c23a62f3a-9939fbb7191so898040966b.0 for ; Mon, 17 Jul 2023 04:07:33 -0700 (PDT) DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=pantheon.tech; s=google; t=1689592053; x=1692184053; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:from:to:cc:subject:date :message-id:reply-to; bh=sm/BXZyWAhbN7/nPm4gAqE08LXKLS9m5rQQa5Pga/wk=; b=lFBWDbxPMz2tSbgGYim0pckRF0VslSvWc5zJD/iufMLpEkFeYSucGDu8JhkGvlGXBI OWVQ07bhZHxefGqOScb9V9Vjo7HBLtRp2iZ0bm+DVbic3PctA9QB4TQCcX9z2ugIFPm2 /LdazX+vXWvgtoCt9Fkjzp9I+TK6r+SOD3RyT4sA9PJaYLJP3+dzXscVmFpTWUXN7m4A r7foieVwEm2P/NV0UVZJJxd5SgY8D8PhY4Yi2F76qluYo0TLLv46T36np6W/yEt+/Ryb xWCWo1wdrw6AFuBt20T8YM8i5wFzWrKQzXixsyqsaX4Nuyg8KeooTAgCusrtR6ehrA4I ZtNg== X-Google-DKIM-Signature: v=1; a=rsa-sha256; c=relaxed/relaxed; d=1e100.net; s=20221208; t=1689592053; x=1692184053; h=content-transfer-encoding:mime-version:references:in-reply-to :message-id:date:subject:cc:to:from:x-gm-message-state:from:to:cc :subject:date:message-id:reply-to; bh=sm/BXZyWAhbN7/nPm4gAqE08LXKLS9m5rQQa5Pga/wk=; b=Mz+5vPuU48HxOBHWXcNom8w+6z2+v3VCEfN6yvCC/RE2iXp5yICKzwanNu7rmCjL0Y rKtScWZNyqdq4BuWeuF5EARwjBcq4BU2elTtEOq9SVse52Zl3JqT6ff8IOgwzp8c3Exm Mmpxtpf+iRlpwRSumhSXYfN8l2VkpIfDSTaR2lAmIXd587wYtQ3NhUqMJiD8QQeWjnAz 9JU8cvmOste3S7XCK3ORoKWzummm/8kJT/la5BrYqrPgBXBFgLutoYXZn8+/Cx4fC7Bu HfKHOSE3N12frs1stykWC6BNp/O+F2IAHVBIjW160Ie3zgg+jPDrBVldx+Lf5Zrs/jXz N56w== X-Gm-Message-State: ABy/qLaSi4kLPaeHhyxj1eHoAtqZdaEiixhnrVGyStVtlBFx1CSm+2Uz ZqYp+uF7i14mPP76PHqLYM0uzQ== X-Google-Smtp-Source: APBJJlEHxatjMJ3SuUyRepKYFO1LKd0J/cmIwroJTwWyTjSYrDdv/0Mjs/xGcNcnl0o2cTXnm3Y/qg== X-Received: by 2002:a17:906:1da:b0:994:54d1:ff57 with SMTP id 26-20020a17090601da00b0099454d1ff57mr8867203ejj.4.1689592053174; Mon, 17 Jul 2023 04:07:33 -0700 (PDT) Received: from jlinkes-PT-Latitude-5530.. (ip-46.34.239.87.o2inet.sk. [46.34.239.87]) by smtp.gmail.com with ESMTPSA id s21-20020a170906355500b0098de7d28c34sm8995051eja.193.2023.07.17.04.07.27 (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256); Mon, 17 Jul 2023 04:07:32 -0700 (PDT) From: =?utf-8?q?Juraj_Linke=C5=A1?= To: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com, jspewock@iol.unh.edu, probb@iol.unh.edu Cc: dev@dpdk.org, =?utf-8?q?Juraj_Linke=C5=A1?= Subject: [PATCH v2 6/6] dts: add basic UDP test case Date: Mon, 17 Jul 2023 13:07:09 +0200 Message-Id: <20230717110709.39220-7-juraj.linkes@pantheon.tech> X-Mailer: git-send-email 2.34.1 In-Reply-To: <20230717110709.39220-1-juraj.linkes@pantheon.tech> References: <20230420093109.594704-1-juraj.linkes@pantheon.tech> <20230717110709.39220-1-juraj.linkes@pantheon.tech> MIME-Version: 1.0 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 The test cases showcases the scapy traffic generator code. Signed-off-by: Juraj Linkeš --- dts/conf.yaml | 1 + dts/framework/config/conf_yaml_schema.json | 3 +- dts/framework/remote_session/linux_session.py | 20 +- dts/framework/remote_session/os_session.py | 20 +- dts/framework/test_suite.py | 217 +++++++++++++++++- dts/framework/testbed_model/node.py | 14 +- dts/framework/testbed_model/sut_node.py | 3 + dts/tests/TestSuite_os_udp.py | 45 ++++ 8 files changed, 315 insertions(+), 8 deletions(-) create mode 100644 dts/tests/TestSuite_os_udp.py diff --git a/dts/conf.yaml b/dts/conf.yaml index 7f089022ba..ba228c5ab2 100644 --- a/dts/conf.yaml +++ b/dts/conf.yaml @@ -13,6 +13,7 @@ executions: skip_smoke_tests: false # optional flag that allow you to skip smoke tests test_suites: - hello_world + - os_udp system_under_test_node: node_name: "SUT 1" vdevs: # optional; if removed, vdevs won't be used in the execution diff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json index 76df84840a..a2f14f0e52 100644 --- a/dts/framework/config/conf_yaml_schema.json +++ b/dts/framework/config/conf_yaml_schema.json @@ -185,7 +185,8 @@ "test_suite": { "type": "string", "enum": [ - "hello_world" + "hello_world", + "os_udp" ] }, "test_target": { diff --git a/dts/framework/remote_session/linux_session.py b/dts/framework/remote_session/linux_session.py index 284c74795d..94023920a7 100644 --- a/dts/framework/remote_session/linux_session.py +++ b/dts/framework/remote_session/linux_session.py @@ -3,7 +3,8 @@ # Copyright(c) 2023 University of New Hampshire import json -from typing import TypedDict +from ipaddress import IPv4Interface, IPv6Interface +from typing import TypedDict, Union from typing_extensions import NotRequired @@ -180,3 +181,20 @@ def configure_port_state(self, port: Port, enable: bool) -> None: self.send_command( f"ip link set dev {port.logical_name} {state}", privileged=True ) + + def configure_port_ip_address( + self, + address: Union[IPv4Interface, IPv6Interface], + port: Port, + delete: bool, + ) -> None: + command = "del" if delete else "add" + self.send_command( + f"ip address {command} {address} dev {port.logical_name}", + privileged=True, + verify=True, + ) + + def configure_ipv4_forwarding(self, enable: bool) -> None: + state = 1 if enable else 0 + self.send_command(f"sysctl -w net.ipv4.ip_forward={state}", privileged=True) diff --git a/dts/framework/remote_session/os_session.py b/dts/framework/remote_session/os_session.py index c17a17a267..ad06c1dcad 100644 --- a/dts/framework/remote_session/os_session.py +++ b/dts/framework/remote_session/os_session.py @@ -4,8 +4,9 @@ from abc import ABC, abstractmethod from collections.abc import Iterable +from ipaddress import IPv4Interface, IPv6Interface from pathlib import PurePath -from typing import Type, TypeVar +from typing import Type, TypeVar, Union from framework.config import Architecture, NodeConfiguration, NodeInfo from framework.logger import DTSLOG @@ -268,3 +269,20 @@ def configure_port_state(self, port: Port, enable: bool) -> None: """ Enable/disable port. """ + + @abstractmethod + def configure_port_ip_address( + self, + address: Union[IPv4Interface, IPv6Interface], + port: Port, + delete: bool, + ) -> None: + """ + Configure (add or delete) an IP address of the input port. + """ + + @abstractmethod + def configure_ipv4_forwarding(self, enable: bool) -> None: + """ + Enable IPv4 forwarding in the underlying OS. + """ diff --git a/dts/framework/test_suite.py b/dts/framework/test_suite.py index 056460dd05..3b890c0451 100644 --- a/dts/framework/test_suite.py +++ b/dts/framework/test_suite.py @@ -9,7 +9,13 @@ import importlib import inspect import re +from ipaddress import IPv4Interface, IPv6Interface, ip_interface from types import MethodType +from typing import Union + +from scapy.layers.inet import IP # type: ignore[import] +from scapy.layers.l2 import Ether # type: ignore[import] +from scapy.packet import Packet, Padding # type: ignore[import] from .exception import ( BlockingTestSuiteError, @@ -21,6 +27,8 @@ from .settings import SETTINGS from .test_result import BuildTargetResult, Result, TestCaseResult, TestSuiteResult from .testbed_model import SutNode, TGNode +from .testbed_model.hw.port import Port, PortLink +from .utils import get_packet_summaries class TestSuite(object): @@ -47,6 +55,15 @@ class TestSuite(object): _test_cases_to_run: list[str] _func: bool _result: TestSuiteResult + _port_links: list[PortLink] + _sut_port_ingress: Port + _sut_port_egress: Port + _sut_ip_address_ingress: Union[IPv4Interface, IPv6Interface] + _sut_ip_address_egress: Union[IPv4Interface, IPv6Interface] + _tg_port_ingress: Port + _tg_port_egress: Port + _tg_ip_address_ingress: Union[IPv4Interface, IPv6Interface] + _tg_ip_address_egress: Union[IPv4Interface, IPv6Interface] def __init__( self, @@ -63,6 +80,31 @@ def __init__( self._test_cases_to_run.extend(SETTINGS.test_cases) self._func = func self._result = build_target_result.add_test_suite(self.__class__.__name__) + self._port_links = [] + self._process_links() + self._sut_port_ingress, self._tg_port_egress = ( + self._port_links[0].sut_port, + self._port_links[0].tg_port, + ) + self._sut_port_egress, self._tg_port_ingress = ( + self._port_links[1].sut_port, + self._port_links[1].tg_port, + ) + self._sut_ip_address_ingress = ip_interface("192.168.100.2/24") + self._sut_ip_address_egress = ip_interface("192.168.101.2/24") + self._tg_ip_address_egress = ip_interface("192.168.100.3/24") + self._tg_ip_address_ingress = ip_interface("192.168.101.3/24") + + def _process_links(self) -> None: + for sut_port in self.sut_node.ports: + for tg_port in self.tg_node.ports: + if (sut_port.identifier, sut_port.peer) == ( + tg_port.peer, + tg_port.identifier, + ): + self._port_links.append( + PortLink(sut_port=sut_port, tg_port=tg_port) + ) def set_up_suite(self) -> None: """ @@ -85,14 +127,181 @@ def tear_down_test_case(self) -> None: Tear down the previously created test fixtures after each test case. """ + def configure_testbed_ipv4(self, restore: bool = False) -> None: + delete = True if restore else False + enable = False if restore else True + self._configure_ipv4_forwarding(enable) + self.sut_node.configure_port_ip_address( + self._sut_ip_address_egress, self._sut_port_egress, delete + ) + self.sut_node.configure_port_state(self._sut_port_egress, enable) + self.sut_node.configure_port_ip_address( + self._sut_ip_address_ingress, self._sut_port_ingress, delete + ) + self.sut_node.configure_port_state(self._sut_port_ingress, enable) + self.tg_node.configure_port_ip_address( + self._tg_ip_address_ingress, self._tg_port_ingress, delete + ) + self.tg_node.configure_port_state(self._tg_port_ingress, enable) + self.tg_node.configure_port_ip_address( + self._tg_ip_address_egress, self._tg_port_egress, delete + ) + self.tg_node.configure_port_state(self._tg_port_egress, enable) + + def _configure_ipv4_forwarding(self, enable: bool) -> None: + self.sut_node.configure_ipv4_forwarding(enable) + + def send_packet_and_capture( + self, packet: Packet, duration: float = 1 + ) -> list[Packet]: + """ + Send a packet through the appropriate interface and + receive on the appropriate interface. + Modify the packet with l3/l2 addresses corresponding + to the testbed and desired traffic. + """ + packet = self._adjust_addresses(packet) + return self.tg_node.send_packet_and_capture( + packet, self._tg_port_egress, self._tg_port_ingress, duration + ) + + def get_expected_packet(self, packet: Packet) -> Packet: + return self._adjust_addresses(packet, expected=True) + + def _adjust_addresses(self, packet: Packet, expected: bool = False) -> Packet: + """ + Assumptions: + Two links between SUT and TG, one link is TG -> SUT, + the other SUT -> TG. + """ + if expected: + # The packet enters the TG from SUT + # update l2 addresses + packet.src = self._sut_port_egress.mac_address + packet.dst = self._tg_port_ingress.mac_address + + # The packet is routed from TG egress to TG ingress + # update l3 addresses + packet.payload.src = self._tg_ip_address_egress.ip.exploded + packet.payload.dst = self._tg_ip_address_ingress.ip.exploded + else: + # The packet leaves TG towards SUT + # update l2 addresses + packet.src = self._tg_port_egress.mac_address + packet.dst = self._sut_port_ingress.mac_address + + # The packet is routed from TG egress to TG ingress + # update l3 addresses + packet.payload.src = self._tg_ip_address_egress.ip.exploded + packet.payload.dst = self._tg_ip_address_ingress.ip.exploded + + return Ether(packet.build()) + def verify(self, condition: bool, failure_description: str) -> None: if not condition: + self._fail_test_case_verify(failure_description) + + def _fail_test_case_verify(self, failure_description: str) -> None: + self._logger.debug( + "A test case failed, showing the last 10 commands executed on SUT:" + ) + for command_res in self.sut_node.main_session.remote_session.history[-10:]: + self._logger.debug(command_res.command) + self._logger.debug( + "A test case failed, showing the last 10 commands executed on TG:" + ) + for command_res in self.tg_node.main_session.remote_session.history[-10:]: + self._logger.debug(command_res.command) + raise TestCaseVerifyError(failure_description) + + def verify_packets( + self, expected_packet: Packet, received_packets: list[Packet] + ) -> None: + for received_packet in received_packets: + if self._compare_packets(expected_packet, received_packet): + break + else: + self._logger.debug( + f"The expected packet {get_packet_summaries(expected_packet)} " + f"not found among received {get_packet_summaries(received_packets)}" + ) + self._fail_test_case_verify( + "An expected packet not found among received packets." + ) + + def _compare_packets( + self, expected_packet: Packet, received_packet: Packet + ) -> bool: + self._logger.debug( + "Comparing packets: \n" + f"{expected_packet.summary()}\n" + f"{received_packet.summary()}" + ) + + l3 = IP in expected_packet.layers() + self._logger.debug("Found l3 layer") + + received_payload = received_packet + expected_payload = expected_packet + while received_payload and expected_payload: + self._logger.debug("Comparing payloads:") + self._logger.debug(f"Received: {received_payload}") + self._logger.debug(f"Expected: {expected_payload}") + if received_payload.__class__ == expected_payload.__class__: + self._logger.debug("The layers are the same.") + if received_payload.__class__ == Ether: + if not self._verify_l2_frame(received_payload, l3): + return False + elif received_payload.__class__ == IP: + if not self._verify_l3_packet(received_payload, expected_payload): + return False + else: + # Different layers => different packets + return False + received_payload = received_payload.payload + expected_payload = expected_payload.payload + + if expected_payload: self._logger.debug( - "A test case failed, showing the last 10 commands executed on SUT:" + f"The expected packet did not contain {expected_payload}." ) - for command_res in self.sut_node.main_session.remote_session.history[-10:]: - self._logger.debug(command_res.command) - raise TestCaseVerifyError(failure_description) + return False + if received_payload and received_payload.__class__ != Padding: + self._logger.debug( + "The received payload had extra layers which were not padding." + ) + return False + return True + + def _verify_l2_frame(self, received_packet: Ether, l3: bool) -> bool: + self._logger.debug("Looking at the Ether layer.") + self._logger.debug( + f"Comparing received dst mac '{received_packet.dst}' " + f"with expected '{self._tg_port_ingress.mac_address}'." + ) + if received_packet.dst != self._tg_port_ingress.mac_address: + return False + + expected_src_mac = self._tg_port_egress.mac_address + if l3: + expected_src_mac = self._sut_port_egress.mac_address + self._logger.debug( + f"Comparing received src mac '{received_packet.src}' " + f"with expected '{expected_src_mac}'." + ) + if received_packet.src != expected_src_mac: + return False + + return True + + def _verify_l3_packet(self, received_packet: IP, expected_packet: IP) -> bool: + self._logger.debug("Looking at the IP layer.") + if ( + received_packet.src != expected_packet.src + or received_packet.dst != expected_packet.dst + ): + return False + return True def run(self) -> None: """ diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py index f70e4d5ce6..b45fea6bbf 100644 --- a/dts/framework/testbed_model/node.py +++ b/dts/framework/testbed_model/node.py @@ -7,7 +7,8 @@ A node is a generic host that DTS connects to and manages. """ -from typing import Any, Callable, Type +from ipaddress import IPv4Interface, IPv6Interface +from typing import Any, Callable, Type, Union from framework.config import ( BuildTargetConfiguration, @@ -214,6 +215,17 @@ def configure_port_state(self, port: Port, enable: bool = True) -> None: """ self.main_session.configure_port_state(port, enable) + def configure_port_ip_address( + self, + address: Union[IPv4Interface, IPv6Interface], + port: Port, + delete: bool = False, + ) -> None: + """ + Configure the IP address of a port on this node. + """ + self.main_session.configure_port_ip_address(address, port, delete) + def close(self) -> None: """ Close all connections and free other resources. diff --git a/dts/framework/testbed_model/sut_node.py b/dts/framework/testbed_model/sut_node.py index f0b017a383..202aebfd06 100644 --- a/dts/framework/testbed_model/sut_node.py +++ b/dts/framework/testbed_model/sut_node.py @@ -351,6 +351,9 @@ def run_dpdk_app( f"{app_path} {eal_args}", timeout, privileged=True, verify=True ) + def configure_ipv4_forwarding(self, enable: bool) -> None: + self.main_session.configure_ipv4_forwarding(enable) + def create_interactive_shell( self, shell_cls: Type[InteractiveShellType], diff --git a/dts/tests/TestSuite_os_udp.py b/dts/tests/TestSuite_os_udp.py new file mode 100644 index 0000000000..9b5f39711d --- /dev/null +++ b/dts/tests/TestSuite_os_udp.py @@ -0,0 +1,45 @@ +# SPDX-License-Identifier: BSD-3-Clause +# Copyright(c) 2023 PANTHEON.tech s.r.o. + +""" +Configure SUT node to route traffic from if1 to if2. +Send a packet to the SUT node, verify it comes back on the second port on the TG node. +""" + +from scapy.layers.inet import IP, UDP # type: ignore[import] +from scapy.layers.l2 import Ether # type: ignore[import] + +from framework.test_suite import TestSuite + + +class TestOSUdp(TestSuite): + def set_up_suite(self) -> None: + """ + Setup: + Configure SUT ports and SUT to route traffic from if1 to if2. + """ + + self.configure_testbed_ipv4() + + def test_os_udp(self) -> None: + """ + Steps: + Send a UDP packet. + Verify: + The packet with proper addresses arrives at the other TG port. + """ + + packet = Ether() / IP() / UDP() + + received_packets = self.send_packet_and_capture(packet) + + expected_packet = self.get_expected_packet(packet) + + self.verify_packets(expected_packet, received_packets) + + def tear_down_suite(self) -> None: + """ + Teardown: + Remove the SUT port configuration configured in setup. + """ + self.configure_testbed_ipv4(restore=True)