get:
Show a patch.

patch:
Update a patch.

put:
Update a patch.

GET /api/patches/124465/?format=api
HTTP 200 OK
Allow: GET, PUT, PATCH, HEAD, OPTIONS
Content-Type: application/json
Vary: Accept

{
    "id": 124465,
    "url": "http://patches.dpdk.org/api/patches/124465/?format=api",
    "web_url": "http://patches.dpdk.org/project/dpdk/patch/20230223152840.634183-5-juraj.linkes@pantheon.tech/",
    "project": {
        "id": 1,
        "url": "http://patches.dpdk.org/api/projects/1/?format=api",
        "name": "DPDK",
        "link_name": "dpdk",
        "list_id": "dev.dpdk.org",
        "list_email": "dev@dpdk.org",
        "web_url": "http://core.dpdk.org",
        "scm_url": "git://dpdk.org/dpdk",
        "webscm_url": "http://git.dpdk.org/dpdk",
        "list_archive_url": "https://inbox.dpdk.org/dev",
        "list_archive_url_format": "https://inbox.dpdk.org/dev/{}",
        "commit_url_format": ""
    },
    "msgid": "<20230223152840.634183-5-juraj.linkes@pantheon.tech>",
    "list_archive_url": "https://inbox.dpdk.org/dev/20230223152840.634183-5-juraj.linkes@pantheon.tech",
    "date": "2023-02-23T15:28:34",
    "name": "[v5,04/10] dts: add dpdk execution handling",
    "commit_ref": null,
    "pull_url": null,
    "state": "superseded",
    "archived": true,
    "hash": "e55b8996c6cb022da24381d858f14baa9853e8d8",
    "submitter": {
        "id": 1626,
        "url": "http://patches.dpdk.org/api/people/1626/?format=api",
        "name": "Juraj Linkeš",
        "email": "juraj.linkes@pantheon.tech"
    },
    "delegate": {
        "id": 1,
        "url": "http://patches.dpdk.org/api/users/1/?format=api",
        "username": "tmonjalo",
        "first_name": "Thomas",
        "last_name": "Monjalon",
        "email": "thomas@monjalon.net"
    },
    "mbox": "http://patches.dpdk.org/project/dpdk/patch/20230223152840.634183-5-juraj.linkes@pantheon.tech/mbox/",
    "series": [
        {
            "id": 27159,
            "url": "http://patches.dpdk.org/api/series/27159/?format=api",
            "web_url": "http://patches.dpdk.org/project/dpdk/list/?series=27159",
            "date": "2023-02-23T15:28:30",
            "name": "dts: add hello world testcase",
            "version": 5,
            "mbox": "http://patches.dpdk.org/series/27159/mbox/"
        }
    ],
    "comments": "http://patches.dpdk.org/api/patches/124465/comments/",
    "check": "success",
    "checks": "http://patches.dpdk.org/api/patches/124465/checks/",
    "tags": {},
    "related": [],
    "headers": {
        "Return-Path": "<dev-bounces@dpdk.org>",
        "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])\n\tby inbox.dpdk.org (Postfix) with ESMTP id A5E3541D53;\n\tThu, 23 Feb 2023 16:29:26 +0100 (CET)",
            "from mails.dpdk.org (localhost [127.0.0.1])\n\tby mails.dpdk.org (Postfix) with ESMTP id F070542D67;\n\tThu, 23 Feb 2023 16:28:57 +0100 (CET)",
            "from mail-ed1-f53.google.com (mail-ed1-f53.google.com\n [209.85.208.53]) by mails.dpdk.org (Postfix) with ESMTP id 9FB0040ED5\n for <dev@dpdk.org>; Thu, 23 Feb 2023 16:28:49 +0100 (CET)",
            "by mail-ed1-f53.google.com with SMTP id cq23so43171452edb.1\n for <dev@dpdk.org>; Thu, 23 Feb 2023 07:28:49 -0800 (PST)",
            "from localhost.localdomain ([84.245.121.112])\n by smtp.gmail.com with ESMTPSA id\n r6-20020a50c006000000b004af6a8617ffsm1158892edb.46.2023.02.23.07.28.48\n (version=TLS1_3 cipher=TLS_AES_256_GCM_SHA384 bits=256/256);\n Thu, 23 Feb 2023 07:28:49 -0800 (PST)"
        ],
        "DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;\n d=pantheon-tech.20210112.gappssmtp.com; s=20210112;\n h=content-transfer-encoding:mime-version:references:in-reply-to\n :message-id:date:subject:cc:to:from:from:to:cc:subject:date\n :message-id:reply-to;\n bh=1ZWyigxmbQMZ38U5+4Kee8ifdGr8PILyWiu3+Cu7GWU=;\n b=8PeaIl+LQLYZd5UtVP4ZDGBDDDWeH7eL4OUmg9U5R2iayrts8arNq8kXFASGUux5EV\n vDm59UJnlp0J5UZcHJK/rQuaQs9aNuBUyKPUXJcssL+X8jBEnHx4Z4OIG60jm+zZPISl\n NLDUPENAWo2SMU/Ygi0V6Q3HY5JHOP/Kb5z4RGk15rXDZJN2/ZiaV3fUwrhq76QnYfcJ\n Tv7BzR/2oqIxZKMWl3sKpzpI2YcdShy8fUN9SNgEDOJ5rbYNWZ05X6dyfVN5gGaYXIQ5\n DP4SMcD747OK+ArJlKdQByiussWGpoduIMfUy6JiHkNdXYHAOMUPP3YscNqfZ0xVvAtb\n GRpA==",
        "X-Google-DKIM-Signature": "v=1; a=rsa-sha256; c=relaxed/relaxed;\n d=1e100.net; s=20210112;\n h=content-transfer-encoding:mime-version:references:in-reply-to\n :message-id:date:subject:cc:to:from:x-gm-message-state:from:to:cc\n :subject:date:message-id:reply-to;\n bh=1ZWyigxmbQMZ38U5+4Kee8ifdGr8PILyWiu3+Cu7GWU=;\n b=FdXV8oN+aIpnKeJKtr39kJ6p5Xcefh7Pr2PjHb1HX0VX/xe00cgY4drlA0r/2NHZua\n yhqbyvXW5ghpxaN9WTITF7ynTnfUdQVBUCjhJIF4ucLAf4pGcGOV84iU4D4UcJbDiyH5\n NAUP3WCrIJG4xWVMYtpYxPfR+hUbOkkG/kqNNI3SiLFIdbJIPYPBANdgJdNydfoqQ6/X\n Q6lFqUQWELJ0UZRn3ok3qKdZjWY0lR5BjUwWnQBPdipSup1NdjmgHRpYG4dIJzqouovW\n jK0jSorLAyaJjpdset8J9Q3LXrqKRyXGSq32b5PcFcKoKhJcaaAaXGx7n+rfZ006P07b\n 2A6w==",
        "X-Gm-Message-State": "AO0yUKWjIxmVAoelYvmMe9HbpsJ4ET5JpfhxISjzxUBiv+6wpoWHAUAx\n eypX3+hC1OR9+XPfQWmsYLfOPQ==",
        "X-Google-Smtp-Source": "\n AK7set+AIwDyR3nHK9+lm5T7YMHuwQjeEHzETp5l8btFHkRwKb+yIT9r1VWUQ6RCQqSV4WLzWNGrhw==",
        "X-Received": "by 2002:aa7:d6d4:0:b0:4af:601c:ea93 with SMTP id\n x20-20020aa7d6d4000000b004af601cea93mr6316365edr.10.1677166129290;\n Thu, 23 Feb 2023 07:28:49 -0800 (PST)",
        "From": "=?utf-8?q?Juraj_Linke=C5=A1?= <juraj.linkes@pantheon.tech>",
        "To": "thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, lijuan.tu@intel.com,\n bruce.richardson@intel.com, probb@iol.unh.edu",
        "Cc": "dev@dpdk.org, =?utf-8?q?Juraj_Linke=C5=A1?= <juraj.linkes@pantheon.tech>",
        "Subject": "[PATCH v5 04/10] dts: add dpdk execution handling",
        "Date": "Thu, 23 Feb 2023 16:28:34 +0100",
        "Message-Id": "<20230223152840.634183-5-juraj.linkes@pantheon.tech>",
        "X-Mailer": "git-send-email 2.30.2",
        "In-Reply-To": "<20230223152840.634183-1-juraj.linkes@pantheon.tech>",
        "References": "<20230213152846.284191-1-juraj.linkes@pantheon.tech>\n <20230223152840.634183-1-juraj.linkes@pantheon.tech>",
        "MIME-Version": "1.0",
        "Content-Type": "text/plain; charset=UTF-8",
        "Content-Transfer-Encoding": "8bit",
        "X-BeenThere": "dev@dpdk.org",
        "X-Mailman-Version": "2.1.29",
        "Precedence": "list",
        "List-Id": "DPDK patches and discussions <dev.dpdk.org>",
        "List-Unsubscribe": "<https://mails.dpdk.org/options/dev>,\n <mailto:dev-request@dpdk.org?subject=unsubscribe>",
        "List-Archive": "<http://mails.dpdk.org/archives/dev/>",
        "List-Post": "<mailto:dev@dpdk.org>",
        "List-Help": "<mailto:dev-request@dpdk.org?subject=help>",
        "List-Subscribe": "<https://mails.dpdk.org/listinfo/dev>,\n <mailto:dev-request@dpdk.org?subject=subscribe>",
        "Errors-To": "dev-bounces@dpdk.org"
    },
    "content": "Add methods for setting up and shutting down DPDK apps and for\nconstructing EAL parameters.\n\nSigned-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>\n---\n dts/conf.yaml                                 |   4 +\n dts/framework/config/__init__.py              |   8 +\n dts/framework/config/conf_yaml_schema.json    |  25 ++\n dts/framework/remote_session/linux_session.py |  18 ++\n dts/framework/remote_session/os_session.py    |  23 +-\n dts/framework/remote_session/posix_session.py |  83 ++++++\n dts/framework/testbed_model/__init__.py       |   8 +\n dts/framework/testbed_model/dpdk.py           |  45 +++\n dts/framework/testbed_model/hw/__init__.py    |  27 ++\n dts/framework/testbed_model/hw/cpu.py         | 274 ++++++++++++++++++\n .../testbed_model/hw/virtual_device.py        |  16 +\n dts/framework/testbed_model/node.py           |  43 +++\n dts/framework/testbed_model/sut_node.py       |  81 +++++-\n dts/framework/utils.py                        |  20 ++\n 14 files changed, 673 insertions(+), 2 deletions(-)\n create mode 100644 dts/framework/testbed_model/hw/__init__.py\n create mode 100644 dts/framework/testbed_model/hw/cpu.py\n create mode 100644 dts/framework/testbed_model/hw/virtual_device.py",
    "diff": "diff --git a/dts/conf.yaml b/dts/conf.yaml\nindex 03696d2bab..1648e5c3c5 100644\n--- a/dts/conf.yaml\n+++ b/dts/conf.yaml\n@@ -13,4 +13,8 @@ nodes:\n   - name: \"SUT 1\"\n     hostname: sut1.change.me.localhost\n     user: root\n+    arch: x86_64\n     os: linux\n+    lcores: \"\"\n+    use_first_core: false\n+    memory_channels: 4\ndiff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py\nindex ca61cb10fe..17b917f3b3 100644\n--- a/dts/framework/config/__init__.py\n+++ b/dts/framework/config/__init__.py\n@@ -72,7 +72,11 @@ class NodeConfiguration:\n     hostname: str\n     user: str\n     password: str | None\n+    arch: Architecture\n     os: OS\n+    lcores: str\n+    use_first_core: bool\n+    memory_channels: int\n \n     @staticmethod\n     def from_dict(d: dict) -> \"NodeConfiguration\":\n@@ -81,7 +85,11 @@ def from_dict(d: dict) -> \"NodeConfiguration\":\n             hostname=d[\"hostname\"],\n             user=d[\"user\"],\n             password=d.get(\"password\"),\n+            arch=Architecture(d[\"arch\"]),\n             os=OS(d[\"os\"]),\n+            lcores=d.get(\"lcores\", \"1\"),\n+            use_first_core=d.get(\"use_first_core\", False),\n+            memory_channels=d.get(\"memory_channels\", 1),\n         )\n \n \ndiff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json\nindex 9170307fbe..334b4bd8ab 100644\n--- a/dts/framework/config/conf_yaml_schema.json\n+++ b/dts/framework/config/conf_yaml_schema.json\n@@ -6,6 +6,14 @@\n       \"type\": \"string\",\n       \"description\": \"A unique identifier for a node\"\n     },\n+    \"ARCH\": {\n+      \"type\": \"string\",\n+      \"enum\": [\n+        \"x86_64\",\n+        \"arm64\",\n+        \"ppc64le\"\n+      ]\n+    },\n     \"OS\": {\n       \"type\": \"string\",\n       \"enum\": [\n@@ -92,8 +100,24 @@\n             \"type\": \"string\",\n             \"description\": \"The password to use on this node. Use only as a last resort. SSH keys are STRONGLY preferred.\"\n           },\n+          \"arch\": {\n+            \"$ref\": \"#/definitions/ARCH\"\n+          },\n           \"os\": {\n             \"$ref\": \"#/definitions/OS\"\n+          },\n+          \"lcores\": {\n+            \"type\": \"string\",\n+            \"pattern\": \"^(([0-9]+|([0-9]+-[0-9]+))(,([0-9]+|([0-9]+-[0-9]+)))*)?$\",\n+            \"description\": \"Optional comma-separated list of logical cores to use, e.g.: 1,2,3,4,5,18-22. Defaults to 1. An empty string means use all lcores.\"\n+          },\n+          \"use_first_core\": {\n+            \"type\": \"boolean\",\n+            \"description\": \"Indicate whether DPDK should use the first physical core. It won't be used by default.\"\n+          },\n+          \"memory_channels\": {\n+            \"type\": \"integer\",\n+            \"description\": \"How many memory channels to use. Optional, defaults to 1.\"\n           }\n         },\n         \"additionalProperties\": false,\n@@ -101,6 +125,7 @@\n           \"name\",\n           \"hostname\",\n           \"user\",\n+          \"arch\",\n           \"os\"\n         ]\n       },\ndiff --git a/dts/framework/remote_session/linux_session.py b/dts/framework/remote_session/linux_session.py\nindex 9d14166077..c49b6bb1d7 100644\n--- a/dts/framework/remote_session/linux_session.py\n+++ b/dts/framework/remote_session/linux_session.py\n@@ -2,6 +2,8 @@\n # Copyright(c) 2023 PANTHEON.tech s.r.o.\n # Copyright(c) 2023 University of New Hampshire\n \n+from framework.testbed_model import LogicalCore\n+\n from .posix_session import PosixSession\n \n \n@@ -9,3 +11,19 @@ class LinuxSession(PosixSession):\n     \"\"\"\n     The implementation of non-Posix compliant parts of Linux remote sessions.\n     \"\"\"\n+\n+    def get_remote_cpus(self, use_first_core: bool) -> list[LogicalCore]:\n+        cpu_info = self.remote_session.send_command(\n+            \"lscpu -p=CPU,CORE,SOCKET,NODE|grep -v \\\\#\"\n+        ).stdout\n+        lcores = []\n+        for cpu_line in cpu_info.splitlines():\n+            lcore, core, socket, node = map(int, cpu_line.split(\",\"))\n+            if core == 0 and socket == 0 and not use_first_core:\n+                self._logger.info(\"Not using the first physical core.\")\n+                continue\n+            lcores.append(LogicalCore(lcore, core, socket, node))\n+        return lcores\n+\n+    def get_dpdk_file_prefix(self, dpdk_prefix) -> str:\n+        return dpdk_prefix\ndiff --git a/dts/framework/remote_session/os_session.py b/dts/framework/remote_session/os_session.py\nindex 06d1ffefdd..c30753e0b8 100644\n--- a/dts/framework/remote_session/os_session.py\n+++ b/dts/framework/remote_session/os_session.py\n@@ -3,12 +3,13 @@\n # Copyright(c) 2023 University of New Hampshire\n \n from abc import ABC, abstractmethod\n+from collections.abc import Iterable\n from pathlib import PurePath\n \n from framework.config import Architecture, NodeConfiguration\n from framework.logger import DTSLOG\n from framework.settings import SETTINGS\n-from framework.testbed_model import MesonArgs\n+from framework.testbed_model import LogicalCore, MesonArgs\n from framework.utils import EnvVarsDict\n \n from .remote import RemoteSession, create_remote_session\n@@ -130,3 +131,23 @@ def get_dpdk_version(self, version_path: str | PurePath) -> str:\n         \"\"\"\n         Inspect DPDK version on the remote node from version_path.\n         \"\"\"\n+\n+    @abstractmethod\n+    def get_remote_cpus(self, use_first_core: bool) -> list[LogicalCore]:\n+        \"\"\"\n+        Compose a list of LogicalCores present on the remote node.\n+        If use_first_core is False, the first physical core won't be used.\n+        \"\"\"\n+\n+    @abstractmethod\n+    def kill_cleanup_dpdk_apps(self, dpdk_prefix_list: Iterable[str]) -> None:\n+        \"\"\"\n+        Kill and cleanup all DPDK apps identified by dpdk_prefix_list. If\n+        dpdk_prefix_list is empty, attempt to find running DPDK apps to kill and clean.\n+        \"\"\"\n+\n+    @abstractmethod\n+    def get_dpdk_file_prefix(self, dpdk_prefix) -> str:\n+        \"\"\"\n+        Get the DPDK file prefix that will be used when running DPDK apps.\n+        \"\"\"\ndiff --git a/dts/framework/remote_session/posix_session.py b/dts/framework/remote_session/posix_session.py\nindex 7a5c38c36e..9b05464d65 100644\n--- a/dts/framework/remote_session/posix_session.py\n+++ b/dts/framework/remote_session/posix_session.py\n@@ -2,6 +2,8 @@\n # Copyright(c) 2023 PANTHEON.tech s.r.o.\n # Copyright(c) 2023 University of New Hampshire\n \n+import re\n+from collections.abc import Iterable\n from pathlib import PurePath, PurePosixPath\n \n from framework.config import Architecture\n@@ -137,3 +139,84 @@ def get_dpdk_version(self, build_dir: str | PurePath) -> str:\n             f\"cat {self.join_remote_path(build_dir, 'VERSION')}\", verify=True\n         )\n         return out.stdout\n+\n+    def kill_cleanup_dpdk_apps(self, dpdk_prefix_list: Iterable[str]) -> None:\n+        self._logger.info(\"Cleaning up DPDK apps.\")\n+        dpdk_runtime_dirs = self._get_dpdk_runtime_dirs(dpdk_prefix_list)\n+        if dpdk_runtime_dirs:\n+            # kill and cleanup only if DPDK is running\n+            dpdk_pids = self._get_dpdk_pids(dpdk_runtime_dirs)\n+            for dpdk_pid in dpdk_pids:\n+                self.remote_session.send_command(f\"kill -9 {dpdk_pid}\", 20)\n+            self._check_dpdk_hugepages(dpdk_runtime_dirs)\n+            self._remove_dpdk_runtime_dirs(dpdk_runtime_dirs)\n+\n+    def _get_dpdk_runtime_dirs(\n+        self, dpdk_prefix_list: Iterable[str]\n+    ) -> list[PurePosixPath]:\n+        prefix = PurePosixPath(\"/var\", \"run\", \"dpdk\")\n+        if not dpdk_prefix_list:\n+            remote_prefixes = self._list_remote_dirs(prefix)\n+            if not remote_prefixes:\n+                dpdk_prefix_list = []\n+            else:\n+                dpdk_prefix_list = remote_prefixes\n+\n+        return [PurePosixPath(prefix, dpdk_prefix) for dpdk_prefix in dpdk_prefix_list]\n+\n+    def _list_remote_dirs(self, remote_path: str | PurePath) -> list[str] | None:\n+        \"\"\"\n+        Return a list of directories of the remote_dir.\n+        If remote_path doesn't exist, return None.\n+        \"\"\"\n+        out = self.remote_session.send_command(\n+            f\"ls -l {remote_path} | awk '/^d/ {{print $NF}}'\"\n+        ).stdout\n+        if \"No such file or directory\" in out:\n+            return None\n+        else:\n+            return out.splitlines()\n+\n+    def _get_dpdk_pids(self, dpdk_runtime_dirs: Iterable[str | PurePath]) -> list[int]:\n+        pids = []\n+        pid_regex = r\"p(\\d+)\"\n+        for dpdk_runtime_dir in dpdk_runtime_dirs:\n+            dpdk_config_file = PurePosixPath(dpdk_runtime_dir, \"config\")\n+            if self._remote_files_exists(dpdk_config_file):\n+                out = self.remote_session.send_command(\n+                    f\"lsof -Fp {dpdk_config_file}\"\n+                ).stdout\n+                if out and \"No such file or directory\" not in out:\n+                    for out_line in out.splitlines():\n+                        match = re.match(pid_regex, out_line)\n+                        if match:\n+                            pids.append(int(match.group(1)))\n+        return pids\n+\n+    def _remote_files_exists(self, remote_path: PurePath) -> bool:\n+        result = self.remote_session.send_command(f\"test -e {remote_path}\")\n+        return not result.return_code\n+\n+    def _check_dpdk_hugepages(\n+        self, dpdk_runtime_dirs: Iterable[str | PurePath]\n+    ) -> None:\n+        for dpdk_runtime_dir in dpdk_runtime_dirs:\n+            hugepage_info = PurePosixPath(dpdk_runtime_dir, \"hugepage_info\")\n+            if self._remote_files_exists(hugepage_info):\n+                out = self.remote_session.send_command(\n+                    f\"lsof -Fp {hugepage_info}\"\n+                ).stdout\n+                if out and \"No such file or directory\" not in out:\n+                    self._logger.warning(\"Some DPDK processes did not free hugepages.\")\n+                    self._logger.warning(\"*******************************************\")\n+                    self._logger.warning(out)\n+                    self._logger.warning(\"*******************************************\")\n+\n+    def _remove_dpdk_runtime_dirs(\n+        self, dpdk_runtime_dirs: Iterable[str | PurePath]\n+    ) -> None:\n+        for dpdk_runtime_dir in dpdk_runtime_dirs:\n+            self.remove_remote_dir(dpdk_runtime_dir)\n+\n+    def get_dpdk_file_prefix(self, dpdk_prefix) -> str:\n+        return \"\"\ndiff --git a/dts/framework/testbed_model/__init__.py b/dts/framework/testbed_model/__init__.py\nindex 96e2ab7c3f..22c7c16708 100644\n--- a/dts/framework/testbed_model/__init__.py\n+++ b/dts/framework/testbed_model/__init__.py\n@@ -10,5 +10,13 @@\n # pylama:ignore=W0611\n \n from .dpdk import MesonArgs\n+from .hw import (\n+    LogicalCore,\n+    LogicalCoreCount,\n+    LogicalCoreList,\n+    LogicalCoreListFilter,\n+    VirtualDevice,\n+    lcore_filter,\n+)\n from .node import Node\n from .sut_node import SutNode\ndiff --git a/dts/framework/testbed_model/dpdk.py b/dts/framework/testbed_model/dpdk.py\nindex 0526974f72..9b3a9e7381 100644\n--- a/dts/framework/testbed_model/dpdk.py\n+++ b/dts/framework/testbed_model/dpdk.py\n@@ -6,6 +6,8 @@\n Various utilities used for configuring, building and running DPDK.\n \"\"\"\n \n+from .hw import LogicalCoreList, VirtualDevice\n+\n \n class MesonArgs(object):\n     \"\"\"\n@@ -31,3 +33,46 @@ def __init__(self, default_library: str | None = None, **dpdk_args: str | bool):\n \n     def __str__(self) -> str:\n         return \" \".join(f\"{self.default_library} {self.dpdk_args}\".split())\n+\n+\n+class EalParameters(object):\n+    def __init__(\n+        self,\n+        lcore_list: LogicalCoreList,\n+        memory_channels: int,\n+        prefix: str,\n+        no_pci: bool,\n+        vdevs: list[VirtualDevice],\n+        other_eal_param: str,\n+    ):\n+        \"\"\"\n+        Generate eal parameters character string;\n+        :param lcore_list: the list of logical cores to use.\n+        :param memory_channels: the number of memory channels to use.\n+        :param prefix: set file prefix string, eg:\n+                        prefix='vf'\n+        :param no_pci: switch of disable PCI bus eg:\n+                        no_pci=True\n+        :param vdevs: virtual device list, eg:\n+                        vdevs=['net_ring0', 'net_ring1']\n+        :param other_eal_param: user defined DPDK eal parameters, eg:\n+                        other_eal_param='--single-file-segments'\n+        \"\"\"\n+        self._lcore_list = f\"-l {lcore_list}\"\n+        self._memory_channels = f\"-n {memory_channels}\"\n+        self._prefix = prefix\n+        if prefix:\n+            self._prefix = f\"--file-prefix={prefix}\"\n+        self._no_pci = \"--no-pci\" if no_pci else \"\"\n+        self._vdevs = \" \".join(f\"--vdev {vdev}\" for vdev in vdevs)\n+        self._other_eal_param = other_eal_param\n+\n+    def __str__(self) -> str:\n+        return (\n+            f\"{self._lcore_list} \"\n+            f\"{self._memory_channels} \"\n+            f\"{self._prefix} \"\n+            f\"{self._no_pci} \"\n+            f\"{self._vdevs} \"\n+            f\"{self._other_eal_param}\"\n+        )\ndiff --git a/dts/framework/testbed_model/hw/__init__.py b/dts/framework/testbed_model/hw/__init__.py\nnew file mode 100644\nindex 0000000000..88ccac0b0e\n--- /dev/null\n+++ b/dts/framework/testbed_model/hw/__init__.py\n@@ -0,0 +1,27 @@\n+# SPDX-License-Identifier: BSD-3-Clause\n+# Copyright(c) 2023 PANTHEON.tech s.r.o.\n+\n+# pylama:ignore=W0611\n+\n+from .cpu import (\n+    LogicalCore,\n+    LogicalCoreCount,\n+    LogicalCoreCountFilter,\n+    LogicalCoreFilter,\n+    LogicalCoreList,\n+    LogicalCoreListFilter,\n+)\n+from .virtual_device import VirtualDevice\n+\n+\n+def lcore_filter(\n+    core_list: list[LogicalCore],\n+    filter_specifier: LogicalCoreCount | LogicalCoreList,\n+    ascending: bool,\n+) -> LogicalCoreFilter:\n+    if isinstance(filter_specifier, LogicalCoreList):\n+        return LogicalCoreListFilter(core_list, filter_specifier, ascending)\n+    elif isinstance(filter_specifier, LogicalCoreCount):\n+        return LogicalCoreCountFilter(core_list, filter_specifier, ascending)\n+    else:\n+        raise ValueError(f\"Unsupported filter r{filter_specifier}\")\ndiff --git a/dts/framework/testbed_model/hw/cpu.py b/dts/framework/testbed_model/hw/cpu.py\nnew file mode 100644\nindex 0000000000..d1918a12dc\n--- /dev/null\n+++ b/dts/framework/testbed_model/hw/cpu.py\n@@ -0,0 +1,274 @@\n+# SPDX-License-Identifier: BSD-3-Clause\n+# Copyright(c) 2023 PANTHEON.tech s.r.o.\n+\n+import dataclasses\n+from abc import ABC, abstractmethod\n+from collections.abc import Iterable, ValuesView\n+from dataclasses import dataclass\n+\n+from framework.utils import expand_range\n+\n+\n+@dataclass(slots=True, frozen=True)\n+class LogicalCore(object):\n+    \"\"\"\n+    Representation of a CPU core. A physical core is represented in OS\n+    by multiple logical cores (lcores) if CPU multithreading is enabled.\n+    \"\"\"\n+\n+    lcore: int\n+    core: int\n+    socket: int\n+    node: int\n+\n+    def __int__(self) -> int:\n+        return self.lcore\n+\n+\n+class LogicalCoreList(object):\n+    \"\"\"\n+    Convert these options into a list of logical core ids.\n+    lcore_list=[LogicalCore1, LogicalCore2] - a list of LogicalCores\n+    lcore_list=[0,1,2,3] - a list of int indices\n+    lcore_list=['0','1','2-3'] - a list of str indices; ranges are supported\n+    lcore_list='0,1,2-3' - a comma delimited str of indices; ranges are supported\n+\n+    The class creates a unified format used across the framework and allows\n+    the user to use either a str representation (using str(instance) or directly\n+    in f-strings) or a list representation (by accessing instance.lcore_list).\n+    Empty lcore_list is allowed.\n+    \"\"\"\n+\n+    _lcore_list: list[int]\n+    _lcore_str: str\n+\n+    def __init__(self, lcore_list: list[int] | list[str] | list[LogicalCore] | str):\n+        self._lcore_list = []\n+        if isinstance(lcore_list, str):\n+            lcore_list = lcore_list.split(\",\")\n+        for lcore in lcore_list:\n+            if isinstance(lcore, str):\n+                self._lcore_list.extend(expand_range(lcore))\n+            else:\n+                self._lcore_list.append(int(lcore))\n+\n+        # the input lcores may not be sorted\n+        self._lcore_list.sort()\n+        self._lcore_str = (\n+            f'{\",\".join(self._get_consecutive_lcores_range(self._lcore_list))}'\n+        )\n+\n+    @property\n+    def lcore_list(self) -> list[int]:\n+        return self._lcore_list\n+\n+    def _get_consecutive_lcores_range(self, lcore_ids_list: list[int]) -> list[str]:\n+        formatted_core_list = []\n+        segment = lcore_ids_list[:1]\n+        for lcore_id in lcore_ids_list[1:]:\n+            if lcore_id - segment[-1] == 1:\n+                segment.append(lcore_id)\n+            else:\n+                formatted_core_list.append(\n+                    f\"{segment[0]}-{segment[-1]}\"\n+                    if len(segment) > 1\n+                    else f\"{segment[0]}\"\n+                )\n+                current_core_index = lcore_ids_list.index(lcore_id)\n+                formatted_core_list.extend(\n+                    self._get_consecutive_lcores_range(\n+                        lcore_ids_list[current_core_index:]\n+                    )\n+                )\n+                segment.clear()\n+                break\n+        if len(segment) > 0:\n+            formatted_core_list.append(\n+                f\"{segment[0]}-{segment[-1]}\" if len(segment) > 1 else f\"{segment[0]}\"\n+            )\n+        return formatted_core_list\n+\n+    def __str__(self) -> str:\n+        return self._lcore_str\n+\n+\n+@dataclasses.dataclass(slots=True, frozen=True)\n+class LogicalCoreCount(object):\n+    \"\"\"\n+    Define the number of logical cores to use.\n+    If sockets is not None, socket_count is ignored.\n+    \"\"\"\n+\n+    lcores_per_core: int = 1\n+    cores_per_socket: int = 2\n+    socket_count: int = 1\n+    sockets: list[int] | None = None\n+\n+\n+class LogicalCoreFilter(ABC):\n+    \"\"\"\n+    Filter according to the input filter specifier. Each filter needs to be\n+    implemented in a derived class.\n+    This class only implements operations common to all filters, such as sorting\n+    the list to be filtered beforehand.\n+    \"\"\"\n+\n+    _filter_specifier: LogicalCoreCount | LogicalCoreList\n+    _lcores_to_filter: list[LogicalCore]\n+\n+    def __init__(\n+        self,\n+        lcore_list: list[LogicalCore],\n+        filter_specifier: LogicalCoreCount | LogicalCoreList,\n+        ascending: bool = True,\n+    ):\n+        self._filter_specifier = filter_specifier\n+\n+        # sorting by core is needed in case hyperthreading is enabled\n+        self._lcores_to_filter = sorted(\n+            lcore_list, key=lambda x: x.core, reverse=not ascending\n+        )\n+        self.filter()\n+\n+    @abstractmethod\n+    def filter(self) -> list[LogicalCore]:\n+        \"\"\"\n+        Use self._filter_specifier to filter self._lcores_to_filter\n+        and return the list of filtered LogicalCores.\n+        self._lcores_to_filter is a sorted copy of the original list,\n+        so it may be modified.\n+        \"\"\"\n+\n+\n+class LogicalCoreCountFilter(LogicalCoreFilter):\n+    \"\"\"\n+    Filter the input list of LogicalCores according to specified rules:\n+    Use cores from the specified number of sockets or from the specified socket ids.\n+    If sockets is specified, it takes precedence over socket_count.\n+    From each of those sockets, use only cores_per_socket of cores.\n+    And for each core, use lcores_per_core of logical cores. Hypertheading\n+    must be enabled for this to take effect.\n+    If ascending is True, use cores with the lowest numerical id first\n+    and continue in ascending order. If False, start with the highest\n+    id and continue in descending order. This ordering affects which\n+    sockets to consider first as well.\n+    \"\"\"\n+\n+    _filter_specifier: LogicalCoreCount\n+\n+    def filter(self) -> list[LogicalCore]:\n+        sockets_to_filter = self._filter_sockets(self._lcores_to_filter)\n+        filtered_lcores = []\n+        for socket_to_filter in sockets_to_filter:\n+            filtered_lcores.extend(self._filter_cores_from_socket(socket_to_filter))\n+        return filtered_lcores\n+\n+    def _filter_sockets(\n+        self, lcores_to_filter: Iterable[LogicalCore]\n+    ) -> ValuesView[list[LogicalCore]]:\n+        \"\"\"\n+        Remove all lcores that don't match the specified socket(s).\n+        If self._filter_specifier.sockets is not None, keep lcores from those sockets,\n+        otherwise keep lcores from the first\n+        self._filter_specifier.socket_count sockets.\n+        \"\"\"\n+        allowed_sockets: set[int] = set()\n+        socket_count = self._filter_specifier.socket_count\n+        if self._filter_specifier.sockets:\n+            socket_count = len(self._filter_specifier.sockets)\n+            allowed_sockets = set(self._filter_specifier.sockets)\n+\n+        filtered_lcores: dict[int, list[LogicalCore]] = {}\n+        for lcore in lcores_to_filter:\n+            if not self._filter_specifier.sockets:\n+                if len(allowed_sockets) < socket_count:\n+                    allowed_sockets.add(lcore.socket)\n+            if lcore.socket in allowed_sockets:\n+                if lcore.socket in filtered_lcores:\n+                    filtered_lcores[lcore.socket].append(lcore)\n+                else:\n+                    filtered_lcores[lcore.socket] = [lcore]\n+\n+        if len(allowed_sockets) < socket_count:\n+            raise ValueError(\n+                f\"The actual number of sockets from which to use cores \"\n+                f\"({len(allowed_sockets)}) is lower than required ({socket_count}).\"\n+            )\n+\n+        return filtered_lcores.values()\n+\n+    def _filter_cores_from_socket(\n+        self, lcores_to_filter: Iterable[LogicalCore]\n+    ) -> list[LogicalCore]:\n+        \"\"\"\n+        Keep only the first self._filter_specifier.cores_per_socket cores.\n+        In multithreaded environments, keep only\n+        the first self._filter_specifier.lcores_per_core lcores of those cores.\n+        \"\"\"\n+\n+        # no need to use ordered dict, from Python3.7 the dict\n+        # insertion order is preserved (LIFO).\n+        lcore_count_per_core_map: dict[int, int] = {}\n+        filtered_lcores = []\n+        for lcore in lcores_to_filter:\n+            if lcore.core in lcore_count_per_core_map:\n+                current_core_lcore_count = lcore_count_per_core_map[lcore.core]\n+                if self._filter_specifier.lcores_per_core > current_core_lcore_count:\n+                    # only add lcores of the given core\n+                    lcore_count_per_core_map[lcore.core] += 1\n+                    filtered_lcores.append(lcore)\n+                else:\n+                    # we have enough lcores per this core\n+                    continue\n+            elif self._filter_specifier.cores_per_socket > len(\n+                lcore_count_per_core_map\n+            ):\n+                # only add cores if we need more\n+                lcore_count_per_core_map[lcore.core] = 1\n+                filtered_lcores.append(lcore)\n+            else:\n+                # we have enough cores\n+                break\n+\n+        cores_per_socket = len(lcore_count_per_core_map)\n+        if cores_per_socket < self._filter_specifier.cores_per_socket:\n+            raise ValueError(\n+                f\"The actual number of cores per socket ({cores_per_socket}) \"\n+                f\"is lower than required ({self._filter_specifier.cores_per_socket}).\"\n+            )\n+\n+        lcores_per_core = lcore_count_per_core_map[filtered_lcores[-1].core]\n+        if lcores_per_core < self._filter_specifier.lcores_per_core:\n+            raise ValueError(\n+                f\"The actual number of logical cores per core ({lcores_per_core}) \"\n+                f\"is lower than required ({self._filter_specifier.lcores_per_core}).\"\n+            )\n+\n+        return filtered_lcores\n+\n+\n+class LogicalCoreListFilter(LogicalCoreFilter):\n+    \"\"\"\n+    Filter the input list of Logical Cores according to the input list of\n+    lcore indices.\n+    An empty LogicalCoreList won't filter anything.\n+    \"\"\"\n+\n+    _filter_specifier: LogicalCoreList\n+\n+    def filter(self) -> list[LogicalCore]:\n+        if not len(self._filter_specifier.lcore_list):\n+            return self._lcores_to_filter\n+\n+        filtered_lcores = []\n+        for core in self._lcores_to_filter:\n+            if core.lcore in self._filter_specifier.lcore_list:\n+                filtered_lcores.append(core)\n+\n+        if len(filtered_lcores) != len(self._filter_specifier.lcore_list):\n+            raise ValueError(\n+                f\"Not all logical cores from {self._filter_specifier.lcore_list} \"\n+                f\"were found among {self._lcores_to_filter}\"\n+            )\n+\n+        return filtered_lcores\ndiff --git a/dts/framework/testbed_model/hw/virtual_device.py b/dts/framework/testbed_model/hw/virtual_device.py\nnew file mode 100644\nindex 0000000000..eb664d9f17\n--- /dev/null\n+++ b/dts/framework/testbed_model/hw/virtual_device.py\n@@ -0,0 +1,16 @@\n+# SPDX-License-Identifier: BSD-3-Clause\n+# Copyright(c) 2023 PANTHEON.tech s.r.o.\n+\n+\n+class VirtualDevice(object):\n+    \"\"\"\n+    Base class for virtual devices used by DPDK.\n+    \"\"\"\n+\n+    name: str\n+\n+    def __init__(self, name: str):\n+        self.name = name\n+\n+    def __str__(self) -> str:\n+        return self.name\ndiff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py\nindex a37f7921e0..b93b9d238e 100644\n--- a/dts/framework/testbed_model/node.py\n+++ b/dts/framework/testbed_model/node.py\n@@ -15,6 +15,14 @@\n from framework.logger import DTSLOG, getLogger\n from framework.remote_session import OSSession, create_session\n \n+from .hw import (\n+    LogicalCore,\n+    LogicalCoreCount,\n+    LogicalCoreList,\n+    LogicalCoreListFilter,\n+    lcore_filter,\n+)\n+\n \n class Node(object):\n     \"\"\"\n@@ -26,6 +34,7 @@ class Node(object):\n     main_session: OSSession\n     config: NodeConfiguration\n     name: str\n+    lcores: list[LogicalCore]\n     _logger: DTSLOG\n     _other_sessions: list[OSSession]\n \n@@ -35,6 +44,12 @@ def __init__(self, node_config: NodeConfiguration):\n         self._logger = getLogger(self.name)\n         self.main_session = create_session(self.config, self.name, self._logger)\n \n+        self._get_remote_cpus()\n+        # filter the node lcores according to user config\n+        self.lcores = LogicalCoreListFilter(\n+            self.lcores, LogicalCoreList(self.config.lcores)\n+        ).filter()\n+\n         self._other_sessions = []\n \n         self._logger.info(f\"Created node: {self.name}\")\n@@ -107,6 +122,34 @@ def create_session(self, name: str) -> OSSession:\n         self._other_sessions.append(connection)\n         return connection\n \n+    def filter_lcores(\n+        self,\n+        filter_specifier: LogicalCoreCount | LogicalCoreList,\n+        ascending: bool = True,\n+    ) -> list[LogicalCore]:\n+        \"\"\"\n+        Filter the LogicalCores found on the Node according to\n+        a LogicalCoreCount or a LogicalCoreList.\n+\n+        If ascending is True, use cores with the lowest numerical id first\n+        and continue in ascending order. If False, start with the highest\n+        id and continue in descending order. This ordering affects which\n+        sockets to consider first as well.\n+        \"\"\"\n+        self._logger.debug(f\"Filtering {filter_specifier} from {self.lcores}.\")\n+        return lcore_filter(\n+            self.lcores,\n+            filter_specifier,\n+            ascending,\n+        ).filter()\n+\n+    def _get_remote_cpus(self) -> None:\n+        \"\"\"\n+        Scan CPUs in the remote OS and store a list of LogicalCores.\n+        \"\"\"\n+        self._logger.info(\"Getting CPU information.\")\n+        self.lcores = self.main_session.get_remote_cpus(self.config.use_first_core)\n+\n     def close(self) -> None:\n         \"\"\"\n         Close all connections and free other resources.\ndiff --git a/dts/framework/testbed_model/sut_node.py b/dts/framework/testbed_model/sut_node.py\nindex 442a41bdc8..a9ae2e4a6f 100644\n--- a/dts/framework/testbed_model/sut_node.py\n+++ b/dts/framework/testbed_model/sut_node.py\n@@ -4,13 +4,16 @@\n \n import os\n import tarfile\n+import time\n from pathlib import PurePath\n \n from framework.config import BuildTargetConfiguration, NodeConfiguration\n+from framework.remote_session import OSSession\n from framework.settings import SETTINGS\n from framework.utils import EnvVarsDict, skip_setup\n \n-from .dpdk import MesonArgs\n+from .dpdk import EalParameters, MesonArgs\n+from .hw import LogicalCoreCount, LogicalCoreList, VirtualDevice\n from .node import Node\n \n \n@@ -22,21 +25,29 @@ class SutNode(Node):\n     Another key capability is building DPDK according to given build target.\n     \"\"\"\n \n+    _dpdk_prefix_list: list[str]\n+    _dpdk_timestamp: str\n     _build_target_config: BuildTargetConfiguration | None\n     _env_vars: EnvVarsDict\n     _remote_tmp_dir: PurePath\n     __remote_dpdk_dir: PurePath | None\n     _dpdk_version: str | None\n     _app_compile_timeout: float\n+    _dpdk_kill_session: OSSession | None\n \n     def __init__(self, node_config: NodeConfiguration):\n         super(SutNode, self).__init__(node_config)\n+        self._dpdk_prefix_list = []\n         self._build_target_config = None\n         self._env_vars = EnvVarsDict()\n         self._remote_tmp_dir = self.main_session.get_remote_tmp_dir()\n         self.__remote_dpdk_dir = None\n         self._dpdk_version = None\n         self._app_compile_timeout = 90\n+        self._dpdk_kill_session = None\n+        self._dpdk_timestamp = (\n+            f\"{str(os.getpid())}_{time.strftime('%Y%m%d%H%M%S', time.localtime())}\"\n+        )\n \n     @property\n     def _remote_dpdk_dir(self) -> PurePath:\n@@ -169,3 +180,71 @@ def build_dpdk_app(self, app_name: str, **meson_dpdk_args: str | bool) -> PurePa\n         return self.main_session.join_remote_path(\n             self.remote_dpdk_build_dir, \"examples\", f\"dpdk-{app_name}\"\n         )\n+\n+    def kill_cleanup_dpdk_apps(self) -> None:\n+        \"\"\"\n+        Kill all dpdk applications on the SUT. Cleanup hugepages.\n+        \"\"\"\n+        if self._dpdk_kill_session and self._dpdk_kill_session.is_alive():\n+            # we can use the session if it exists and responds\n+            self._dpdk_kill_session.kill_cleanup_dpdk_apps(self._dpdk_prefix_list)\n+        else:\n+            # otherwise, we need to (re)create it\n+            self._dpdk_kill_session = self.create_session(\"dpdk_kill\")\n+        self._dpdk_prefix_list = []\n+\n+    def create_eal_parameters(\n+        self,\n+        lcore_filter_specifier: LogicalCoreCount | LogicalCoreList = LogicalCoreCount(),\n+        ascending_cores: bool = True,\n+        prefix: str = \"dpdk\",\n+        append_prefix_timestamp: bool = True,\n+        no_pci: bool = False,\n+        vdevs: list[VirtualDevice] = None,\n+        other_eal_param: str = \"\",\n+    ) -> EalParameters:\n+        \"\"\"\n+        Generate eal parameters character string;\n+        :param lcore_filter_specifier: a number of lcores/cores/sockets to use\n+                        or a list of lcore ids to use.\n+                        The default will select one lcore for each of two cores\n+                        on one socket, in ascending order of core ids.\n+        :param ascending_cores: True, use cores with the lowest numerical id first\n+                        and continue in ascending order. If False, start with the\n+                        highest id and continue in descending order. This ordering\n+                        affects which sockets to consider first as well.\n+        :param prefix: set file prefix string, eg:\n+                        prefix='vf'\n+        :param append_prefix_timestamp: if True, will append a timestamp to\n+                        DPDK file prefix.\n+        :param no_pci: switch of disable PCI bus eg:\n+                        no_pci=True\n+        :param vdevs: virtual device list, eg:\n+                        vdevs=['net_ring0', 'net_ring1']\n+        :param other_eal_param: user defined DPDK eal parameters, eg:\n+                        other_eal_param='--single-file-segments'\n+        :return: eal param string, eg:\n+                '-c 0xf -a 0000:88:00.0 --file-prefix=dpdk_1112_20190809143420';\n+        \"\"\"\n+\n+        lcore_list = LogicalCoreList(\n+            self.filter_lcores(lcore_filter_specifier, ascending_cores)\n+        )\n+\n+        if append_prefix_timestamp:\n+            prefix = f\"{prefix}_{self._dpdk_timestamp}\"\n+        prefix = self.main_session.get_dpdk_file_prefix(prefix)\n+        if prefix:\n+            self._dpdk_prefix_list.append(prefix)\n+\n+        if vdevs is None:\n+            vdevs = []\n+\n+        return EalParameters(\n+            lcore_list=lcore_list,\n+            memory_channels=self.config.memory_channels,\n+            prefix=prefix,\n+            no_pci=no_pci,\n+            vdevs=vdevs,\n+            other_eal_param=other_eal_param,\n+        )\ndiff --git a/dts/framework/utils.py b/dts/framework/utils.py\nindex 611071604b..eebe76f16c 100644\n--- a/dts/framework/utils.py\n+++ b/dts/framework/utils.py\n@@ -32,6 +32,26 @@ def skip_setup(func) -> Callable[..., None]:\n         return func\n \n \n+def expand_range(range_str: str) -> list[int]:\n+    \"\"\"\n+    Process range string into a list of integers. There are two possible formats:\n+    n - a single integer\n+    n-m - a range of integers\n+\n+    The returned range includes both n and m. Empty string returns an empty list.\n+    \"\"\"\n+    expanded_range: list[int] = []\n+    if range_str:\n+        range_boundaries = range_str.split(\"-\")\n+        # will throw an exception when items in range_boundaries can't be converted,\n+        # serving as type check\n+        expanded_range.extend(\n+            range(int(range_boundaries[0]), int(range_boundaries[-1]) + 1)\n+        )\n+\n+    return expanded_range\n+\n+\n def GREEN(text: str) -> str:\n     return f\"\\u001B[32;1m{str(text)}\\u001B[0m\"\n \n",
    "prefixes": [
        "v5",
        "04/10"
    ]
}