[RFC,v2,01/10] dts: add node and os abstractions

Message ID 20221114165438.1133783-2-juraj.linkes@pantheon.tech (mailing list archive)
State Superseded, archived
Delegated to: Thomas Monjalon
Headers
Series dts: add hello world testcase |

Checks

Context Check Description
ci/checkpatch success coding style OK

Commit Message

Juraj Linkeš Nov. 14, 2022, 4:54 p.m. UTC
  The abstraction model in DTS is as follows:
Node, defining and implementing methods common to and the base of SUT
(system under test) Node and TG (traffic generator) Node.
Remote Session, defining and implementing methods common to any remote
session implementation, such as SSH Session.
OSSession, defining and implementing methods common to any operating
system/distribution, such as Linux.

OSSession uses a derived Remote Session and Node in turn uses a derived
OSSession. This split delegates OS-specific and connection-specific code
to specialized classes designed to handle the differences.

The base classes implement the methods or parts of methods that are
common to all implementations and defines abstract methods that must be
implemented by derived classes.

Part of the abstractions is the DTS test execution skeleton: node init,
execution setup, build setup and then test execution.

Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech>
---
 dts/conf.yaml                                 |   8 +-
 dts/framework/config/__init__.py              |  70 +++++++++-
 dts/framework/config/conf_yaml_schema.json    |  66 +++++++++-
 dts/framework/dts.py                          | 116 +++++++++++++----
 dts/framework/exception.py                    |  87 ++++++++++++-
 dts/framework/remote_session/__init__.py      |  18 +--
 dts/framework/remote_session/factory.py       |  14 ++
 dts/framework/remote_session/os/__init__.py   |  17 +++
 .../remote_session/os/linux_session.py        |  11 ++
 dts/framework/remote_session/os/os_session.py |  46 +++++++
 .../remote_session/os/posix_session.py        |  12 ++
 .../remote_session/remote_session.py          |  23 +++-
 dts/framework/remote_session/ssh_session.py   |   2 +-
 dts/framework/testbed_model/__init__.py       |   6 +-
 dts/framework/testbed_model/node.py           |  62 ---------
 dts/framework/testbed_model/node/__init__.py  |   7 +
 dts/framework/testbed_model/node/node.py      | 120 ++++++++++++++++++
 dts/framework/testbed_model/node/sut_node.py  |  13 ++
 18 files changed, 591 insertions(+), 107 deletions(-)
 create mode 100644 dts/framework/remote_session/factory.py
 create mode 100644 dts/framework/remote_session/os/__init__.py
 create mode 100644 dts/framework/remote_session/os/linux_session.py
 create mode 100644 dts/framework/remote_session/os/os_session.py
 create mode 100644 dts/framework/remote_session/os/posix_session.py
 delete mode 100644 dts/framework/testbed_model/node.py
 create mode 100644 dts/framework/testbed_model/node/__init__.py
 create mode 100644 dts/framework/testbed_model/node/node.py
 create mode 100644 dts/framework/testbed_model/node/sut_node.py
  

Patch

diff --git a/dts/conf.yaml b/dts/conf.yaml
index 1aaa593612..6b0bc5c2bf 100644
--- a/dts/conf.yaml
+++ b/dts/conf.yaml
@@ -2,8 +2,14 @@ 
 # Copyright 2022 The DPDK contributors
 
 executions:
-  - system_under_test: "SUT 1"
+  - build_targets:
+      - arch: x86_64
+        os: linux
+        cpu: native
+        compiler: gcc
+    system_under_test: "SUT 1"
 nodes:
   - name: "SUT 1"
     hostname: sut1.change.me.localhost
     user: root
+    os: linux
diff --git a/dts/framework/config/__init__.py b/dts/framework/config/__init__.py
index 214be8e7f4..1b97dc3ab9 100644
--- a/dts/framework/config/__init__.py
+++ b/dts/framework/config/__init__.py
@@ -3,13 +3,14 @@ 
 # Copyright(c) 2022 University of New Hampshire
 
 """
-Generic port and topology nodes configuration file load function
+Yaml config parsing methods
 """
 
 import json
 import os.path
 import pathlib
 from dataclasses import dataclass
+from enum import Enum, auto, unique
 from typing import Any
 
 import warlock  # type: ignore
@@ -18,6 +19,47 @@ 
 from framework.settings import SETTINGS
 
 
+class StrEnum(Enum):
+    @staticmethod
+    def _generate_next_value_(
+        name: str, start: int, count: int, last_values: object
+    ) -> str:
+        return name
+
+
+@unique
+class Architecture(StrEnum):
+    i686 = auto()
+    x86_64 = auto()
+    x86_32 = auto()
+    arm64 = auto()
+    ppc64le = auto()
+
+
+@unique
+class OS(StrEnum):
+    linux = auto()
+    freebsd = auto()
+    windows = auto()
+
+
+@unique
+class CPUType(StrEnum):
+    native = auto()
+    armv8a = auto()
+    dpaa2 = auto()
+    thunderx = auto()
+    xgene1 = auto()
+
+
+@unique
+class Compiler(StrEnum):
+    gcc = auto()
+    clang = auto()
+    icc = auto()
+    msvc = auto()
+
+
 # Slots enables some optimizations, by pre-allocating space for the defined
 # attributes in the underlying data structure.
 #
@@ -29,6 +71,7 @@  class NodeConfiguration:
     hostname: str
     user: str
     password: str | None
+    os: OS
 
     @staticmethod
     def from_dict(d: dict) -> "NodeConfiguration":
@@ -37,19 +80,44 @@  def from_dict(d: dict) -> "NodeConfiguration":
             hostname=d["hostname"],
             user=d["user"],
             password=d.get("password"),
+            os=OS(d["os"]),
+        )
+
+
+@dataclass(slots=True, frozen=True)
+class BuildTargetConfiguration:
+    arch: Architecture
+    os: OS
+    cpu: CPUType
+    compiler: Compiler
+    name: str
+
+    @staticmethod
+    def from_dict(d: dict) -> "BuildTargetConfiguration":
+        return BuildTargetConfiguration(
+            arch=Architecture(d["arch"]),
+            os=OS(d["os"]),
+            cpu=CPUType(d["cpu"]),
+            compiler=Compiler(d["compiler"]),
+            name=f"{d['arch']}-{d['os']}-{d['cpu']}-{d['compiler']}",
         )
 
 
 @dataclass(slots=True, frozen=True)
 class ExecutionConfiguration:
+    build_targets: list[BuildTargetConfiguration]
     system_under_test: NodeConfiguration
 
     @staticmethod
     def from_dict(d: dict, node_map: dict) -> "ExecutionConfiguration":
+        build_targets: list[BuildTargetConfiguration] = list(
+            map(BuildTargetConfiguration.from_dict, d["build_targets"])
+        )
         sut_name = d["system_under_test"]
         assert sut_name in node_map, f"Unknown SUT {sut_name} in execution {d}"
 
         return ExecutionConfiguration(
+            build_targets=build_targets,
             system_under_test=node_map[sut_name],
         )
 
diff --git a/dts/framework/config/conf_yaml_schema.json b/dts/framework/config/conf_yaml_schema.json
index 6b8d6ccd05..409ce7ac74 100644
--- a/dts/framework/config/conf_yaml_schema.json
+++ b/dts/framework/config/conf_yaml_schema.json
@@ -5,6 +5,58 @@ 
     "node_name": {
       "type": "string",
       "description": "A unique identifier for a node"
+    },
+    "OS": {
+      "type": "string",
+      "enum": [
+        "linux"
+      ]
+    },
+    "cpu": {
+      "type": "string",
+      "description": "Native should be the default on x86",
+      "enum": [
+        "native",
+        "armv8a",
+        "dpaa2",
+        "thunderx",
+        "xgene1"
+      ]
+    },
+    "compiler": {
+      "type": "string",
+      "enum": [
+        "gcc",
+        "clang",
+        "icc",
+        "mscv"
+      ]
+    },
+    "build_target": {
+      "type": "object",
+      "description": "Targets supported by DTS",
+      "properties": {
+        "arch": {
+          "type": "string",
+          "enum": [
+            "ALL",
+            "x86_64",
+            "arm64",
+            "ppc64le",
+            "other"
+          ]
+        },
+        "os": {
+          "$ref": "#/definitions/OS"
+        },
+        "cpu": {
+          "$ref": "#/definitions/cpu"
+        },
+        "compiler": {
+          "$ref": "#/definitions/compiler"
+        }
+      },
+      "additionalProperties": false
     }
   },
   "type": "object",
@@ -29,13 +81,17 @@ 
           "password": {
             "type": "string",
             "description": "The password to use on this node. Use only as a last resort. SSH keys are STRONGLY preferred."
+          },
+          "os": {
+            "$ref": "#/definitions/OS"
           }
         },
         "additionalProperties": false,
         "required": [
           "name",
           "hostname",
-          "user"
+          "user",
+          "os"
         ]
       },
       "minimum": 1
@@ -45,12 +101,20 @@ 
       "items": {
         "type": "object",
         "properties": {
+          "build_targets": {
+            "type": "array",
+            "items": {
+              "$ref": "#/definitions/build_target"
+            },
+            "minimum": 1
+          },
           "system_under_test": {
             "$ref": "#/definitions/node_name"
           }
         },
         "additionalProperties": false,
         "required": [
+          "build_targets",
           "system_under_test"
         ]
       },
diff --git a/dts/framework/dts.py b/dts/framework/dts.py
index d23cfc4526..262c392d8e 100644
--- a/dts/framework/dts.py
+++ b/dts/framework/dts.py
@@ -3,32 +3,38 @@ 
 # Copyright(c) 2022 PANTHEON.tech s.r.o.
 # Copyright(c) 2022 University of New Hampshire
 
+import os
 import sys
 import traceback
 from collections.abc import Iterable
 
-from framework.testbed_model.node import Node
+from framework.testbed_model import Node, SutNode
 
-from .config import CONFIGURATION
+from .config import CONFIGURATION, BuildTargetConfiguration, ExecutionConfiguration
+from .exception import DTSError, ReturnCode
 from .logger import DTSLOG, getLogger
+from .settings import SETTINGS
 from .utils import check_dts_python_version
 
-dts_logger: DTSLOG | None = None
+dts_logger: DTSLOG = getLogger("dts")
 
 
 def run_all() -> None:
     """
-    Main process of DTS, it will run all test suites in the config file.
+    The main process of DTS. Runs all build targets in all executions from the main
+    config file.
     """
-
+    return_code = ReturnCode.NO_ERR
     global dts_logger
 
     # check the python version of the server that run dts
     check_dts_python_version()
 
-    dts_logger = getLogger("dts")
+    # prepare the output folder
+    if not os.path.exists(SETTINGS.output_dir):
+        os.mkdir(SETTINGS.output_dir)
 
-    nodes = {}
+    nodes: dict[str, Node] = {}
     # This try/finally block means "Run the try block, if there is an exception,
     # run the finally block before passing it upward. If there is not an exception,
     # run the finally block after the try block is finished." This helps avoid the
@@ -38,30 +44,92 @@  def run_all() -> None:
     try:
         # for all Execution sections
         for execution in CONFIGURATION.executions:
-            sut_config = execution.system_under_test
-            if sut_config.name not in nodes:
-                node = Node(sut_config)
-                nodes[sut_config.name] = node
-                node.send_command("echo Hello World")
-
-    except Exception as e:
-        # sys.exit() doesn't produce a stack trace, need to print it explicitly
-        traceback.print_exc()
+            sut_node = init_nodes(execution, nodes)
+            run_execution(sut_node, execution)
+
+    except DTSError as e:
+        dts_logger.error(traceback.format_exc())
+        return_code = e.return_code
         raise e
 
+    except Exception:
+        # sys.exit() doesn't produce a stack trace, need to produce it explicitly
+        dts_logger.error(traceback.format_exc())
+        return_code = ReturnCode.GENERIC_ERR
+        raise
+
+    finally:
+        quit_execution(nodes.values(), return_code)
+
+
+def init_nodes(
+    execution: ExecutionConfiguration, existing_nodes: dict[str, Node]
+) -> SutNode:
+    """
+    Create DTS SUT instance used in the given execution and initialize it. If already
+    initialized (in a previous execution), return the existing SUT.
+    """
+    if execution.system_under_test.name in existing_nodes:
+        # a Node with the same name already exists
+        sut_node = existing_nodes[execution.system_under_test.name]
+    else:
+        # the SUT has not been initialized yet
+        sut_node = SutNode(execution.system_under_test)
+        existing_nodes[sut_node.name] = sut_node
+
+    return sut_node
+
+
+def run_execution(sut_node: SutNode, execution: ExecutionConfiguration) -> None:
+    """
+    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}'.")
+    try:
+        sut_node.setup_execution(execution)
+        for build_target in execution.build_targets:
+            run_build_target(sut_node, build_target, execution)
+
     finally:
-        quit_execution(nodes.values())
+        sut_node.cleanup_execution()
+
+
+def run_build_target(
+    sut_node: SutNode,
+    build_target: BuildTargetConfiguration,
+    execution: ExecutionConfiguration,
+) -> None:
+    """
+    Run the given build target.
+    """
+    dts_logger.info(f"Running target '{build_target.name}'.")
+    try:
+        sut_node.setup_build_target(build_target)
+        run_suite(sut_node, build_target, execution)
+
+    finally:
+        sut_node.teardown_build_target()
+
+
+def run_suite(
+    sut_node: SutNode,
+    build_target: BuildTargetConfiguration,
+    execution: ExecutionConfiguration,
+) -> None:
+    """
+    Use the given build_target to run the test suite with possibly only a subset
+    of tests. If no subset is specified, run all tests.
+    """
 
 
-def quit_execution(sut_nodes: Iterable[Node]) -> None:
+def quit_execution(nodes: Iterable[Node], return_code: ReturnCode) -> None:
     """
-    Close session to SUT and TG before quit.
-    Return exit status when failure occurred.
+    Close all node resources before quitting.
     """
-    for sut_node in sut_nodes:
-        # close all session
-        sut_node.node_exit()
+    for node in nodes:
+        node.close()
 
     if dts_logger is not None:
         dts_logger.info("DTS execution has ended.")
-    sys.exit(0)
+    sys.exit(return_code)
diff --git a/dts/framework/exception.py b/dts/framework/exception.py
index 8b2f08a8f0..cac8d84416 100644
--- a/dts/framework/exception.py
+++ b/dts/framework/exception.py
@@ -7,14 +7,45 @@ 
 User-defined exceptions used across the framework.
 """
 
+from enum import IntEnum, unique
+from typing import Callable, ClassVar
 
-class SSHTimeoutError(Exception):
+
+@unique
+class ReturnCode(IntEnum):
+    """
+    The various return codes that DTS exists with.
+    There are four categories of return codes:
+    0-9 DTS Framework errors
+    10-19 DPDK/Traffic Generator errors
+    20-29 Node errors
+    30-39 Test errors
+    """
+
+    NO_ERR = 0
+    GENERIC_ERR = 1
+    SSH_ERR = 2
+    NODE_SETUP_ERR = 20
+    NODE_CLEANUP_ERR = 21
+
+
+class DTSError(Exception):
+    """
+    The base exception from which all DTS exceptions are derived. Servers to hold
+    the return code with which DTS should exit.
+    """
+
+    return_code: ClassVar[ReturnCode] = ReturnCode.GENERIC_ERR
+
+
+class SSHTimeoutError(DTSError):
     """
     Command execution timeout.
     """
 
     command: str
     output: str
+    return_code: ClassVar[ReturnCode] = ReturnCode.SSH_ERR
 
     def __init__(self, command: str, output: str):
         self.command = command
@@ -27,12 +58,13 @@  def get_output(self) -> str:
         return self.output
 
 
-class SSHConnectionError(Exception):
+class SSHConnectionError(DTSError):
     """
     SSH connection error.
     """
 
     host: str
+    return_code: ClassVar[ReturnCode] = ReturnCode.SSH_ERR
 
     def __init__(self, host: str):
         self.host = host
@@ -41,16 +73,65 @@  def __str__(self) -> str:
         return f"Error trying to connect with {self.host}"
 
 
-class SSHSessionDeadError(Exception):
+class SSHSessionDeadError(DTSError):
     """
     SSH session is not alive.
     It can no longer be used.
     """
 
     host: str
+    return_code: ClassVar[ReturnCode] = ReturnCode.SSH_ERR
 
     def __init__(self, host: str):
         self.host = host
 
     def __str__(self) -> str:
         return f"SSH session with {self.host} has died"
+
+
+class NodeSetupError(DTSError):
+    """
+    Raised when setting up a node.
+    """
+
+    return_code: ClassVar[ReturnCode] = ReturnCode.NODE_SETUP_ERR
+
+    def __init__(self):
+        super(NodeSetupError, self).__init__(
+            "An error occurred during node execution setup."
+        )
+
+
+class NodeCleanupError(DTSError):
+    """
+    Raised when cleaning up node.
+    """
+
+    return_code: ClassVar[ReturnCode] = ReturnCode.NODE_CLEANUP_ERR
+
+    def __init__(self):
+        super(NodeCleanupError, self).__init__(
+            "An error occurred during node execution cleanup."
+        )
+
+
+def convert_exception(exception: type[DTSError]) -> Callable[..., Callable[..., None]]:
+    """
+    When a non-DTS exception is raised while executing the decorated function,
+    convert it to the supplied exception.
+    """
+
+    def convert_exception_wrapper(func) -> Callable[..., None]:
+        def convert(*args, **kwargs) -> None:
+            try:
+                func(*args, **kwargs)
+
+            except DTSError:
+                raise
+
+            except Exception as e:
+                raise exception() from e
+
+        return convert
+
+    return convert_exception_wrapper
diff --git a/dts/framework/remote_session/__init__.py b/dts/framework/remote_session/__init__.py
index a227d8db22..f2339b20bd 100644
--- a/dts/framework/remote_session/__init__.py
+++ b/dts/framework/remote_session/__init__.py
@@ -1,14 +1,14 @@ 
 # SPDX-License-Identifier: BSD-3-Clause
 # Copyright(c) 2022 PANTHEON.tech s.r.o.
 
-from framework.config import NodeConfiguration
-from framework.logger import DTSLOG
+"""
+The package provides modules for managing remote connections to a remote host (node),
+differentiated by OS.
+The package provides a factory function, create_session, that returns the appropriate
+remote connection based on the passed configuration. The differences are in the
+underlying transport protocol (e.g. SSH) and remote OS (e.g. Linux).
+"""
 
-from .remote_session import RemoteSession
-from .ssh_session import SSHSession
+# pylama:ignore=W0611
 
-
-def create_remote_session(
-    node_config: NodeConfiguration, name: str, logger: DTSLOG
-) -> RemoteSession:
-    return SSHSession(node_config, name, logger)
+from .os import OSSession, create_session
diff --git a/dts/framework/remote_session/factory.py b/dts/framework/remote_session/factory.py
new file mode 100644
index 0000000000..a227d8db22
--- /dev/null
+++ b/dts/framework/remote_session/factory.py
@@ -0,0 +1,14 @@ 
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 PANTHEON.tech s.r.o.
+
+from framework.config import NodeConfiguration
+from framework.logger import DTSLOG
+
+from .remote_session import RemoteSession
+from .ssh_session import SSHSession
+
+
+def create_remote_session(
+    node_config: NodeConfiguration, name: str, logger: DTSLOG
+) -> RemoteSession:
+    return SSHSession(node_config, name, logger)
diff --git a/dts/framework/remote_session/os/__init__.py b/dts/framework/remote_session/os/__init__.py
new file mode 100644
index 0000000000..9d2ec7fca2
--- /dev/null
+++ b/dts/framework/remote_session/os/__init__.py
@@ -0,0 +1,17 @@ 
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 PANTHEON.tech s.r.o.
+# Copyright(c) 2022 University of New Hampshire
+
+from framework.config import OS, NodeConfiguration
+from framework.logger import DTSLOG
+
+from .linux_session import LinuxSession
+from .os_session import OSSession
+
+
+def create_session(
+    node_config: NodeConfiguration, name: str, logger: DTSLOG
+) -> OSSession:
+    match node_config.os:
+        case OS.linux:
+            return LinuxSession(node_config, name, logger)
diff --git a/dts/framework/remote_session/os/linux_session.py b/dts/framework/remote_session/os/linux_session.py
new file mode 100644
index 0000000000..39e80631dd
--- /dev/null
+++ b/dts/framework/remote_session/os/linux_session.py
@@ -0,0 +1,11 @@ 
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 PANTHEON.tech s.r.o.
+# Copyright(c) 2022 University of New Hampshire
+
+from .posix_session import PosixSession
+
+
+class LinuxSession(PosixSession):
+    """
+    The implementation of non-Posix compliant parts of Linux remote sessions.
+    """
diff --git a/dts/framework/remote_session/os/os_session.py b/dts/framework/remote_session/os/os_session.py
new file mode 100644
index 0000000000..2a72082628
--- /dev/null
+++ b/dts/framework/remote_session/os/os_session.py
@@ -0,0 +1,46 @@ 
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 PANTHEON.tech s.r.o.
+# Copyright(c) 2022 University of New Hampshire
+
+from abc import ABC
+
+from framework.config import NodeConfiguration
+from framework.logger import DTSLOG
+from framework.remote_session.factory import create_remote_session
+from framework.remote_session.remote_session import RemoteSession
+
+
+class OSSession(ABC):
+    """
+    The OS classes create a DTS node remote session and implement OS specific
+    behavior. There a few control methods implemented by the base class, the rest need
+    to be implemented by derived classes.
+    """
+
+    _config: NodeConfiguration
+    name: str
+    logger: DTSLOG
+    remote_session: RemoteSession
+
+    def __init__(
+        self,
+        node_config: NodeConfiguration,
+        name: str,
+        logger: DTSLOG,
+    ) -> None:
+        self._config = node_config
+        self.name = name
+        self.logger = logger
+        self.remote_session = create_remote_session(node_config, name, logger)
+
+    def close(self, force: bool = False) -> None:
+        """
+        Close the remote session.
+        """
+        self.remote_session.close(force)
+
+    def is_alive(self) -> bool:
+        """
+        Check whether the remote session is still responding.
+        """
+        return self.remote_session.is_alive()
diff --git a/dts/framework/remote_session/os/posix_session.py b/dts/framework/remote_session/os/posix_session.py
new file mode 100644
index 0000000000..9622a4ea30
--- /dev/null
+++ b/dts/framework/remote_session/os/posix_session.py
@@ -0,0 +1,12 @@ 
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 PANTHEON.tech s.r.o.
+# Copyright(c) 2022 University of New Hampshire
+
+from .os_session import OSSession
+
+
+class PosixSession(OSSession):
+    """
+    An intermediary class implementing the Posix compliant parts of
+    Linux and other OS remote sessions.
+    """
diff --git a/dts/framework/remote_session/remote_session.py b/dts/framework/remote_session/remote_session.py
index 33047d9d0a..4095e02c1b 100644
--- a/dts/framework/remote_session/remote_session.py
+++ b/dts/framework/remote_session/remote_session.py
@@ -19,6 +19,15 @@  class HistoryRecord:
 
 
 class RemoteSession(ABC):
+    """
+    The base class for defining which methods must be implemented in order to connect
+    to a remote host (node) and maintain a remote session. The derived classes are
+    supposed to implement/use some underlying transport protocol (e.g. SSH) to
+    implement the methods. On top of that, it provides some basic services common to
+    all derived classes, such as keeping history and logging what's being executed
+    on the remote node.
+    """
+
     name: str
     hostname: str
     ip: str
@@ -58,9 +67,11 @@  def _connect(self) -> None:
         """
         Create connection to assigned node.
         """
-        pass
 
     def send_command(self, command: str, timeout: float = SETTINGS.timeout) -> str:
+        """
+        Send a command and return the output.
+        """
         self.logger.info(f"Sending: {command}")
         out = self._send_command(command, timeout)
         self.logger.debug(f"Received from {command}: {out}")
@@ -70,7 +81,8 @@  def send_command(self, command: str, timeout: float = SETTINGS.timeout) -> str:
     @abstractmethod
     def _send_command(self, command: str, timeout: float) -> str:
         """
-        Send a command and return the output.
+        Use the underlying protocol to execute the command and return the output
+        of the command.
         """
 
     def _history_add(self, command: str, output: str) -> None:
@@ -79,17 +91,20 @@  def _history_add(self, command: str, output: str) -> None:
         )
 
     def close(self, force: bool = False) -> None:
+        """
+        Close the remote session and free all used resources.
+        """
         self.logger.logger_exit()
         self._close(force)
 
     @abstractmethod
     def _close(self, force: bool = False) -> None:
         """
-        Close the remote session, freeing all used resources.
+        Execute protocol specific steps needed to close the session properly.
         """
 
     @abstractmethod
     def is_alive(self) -> bool:
         """
-        Check whether the session is still responding.
+        Check whether the remote session is still responding.
         """
diff --git a/dts/framework/remote_session/ssh_session.py b/dts/framework/remote_session/ssh_session.py
index 7ec327054d..5816b1ce6b 100644
--- a/dts/framework/remote_session/ssh_session.py
+++ b/dts/framework/remote_session/ssh_session.py
@@ -17,7 +17,7 @@ 
 
 class SSHSession(RemoteSession):
     """
-    Module for creating Pexpect SSH sessions to a node.
+    Module for creating Pexpect SSH remote sessions.
     """
 
     session: pxssh.pxssh
diff --git a/dts/framework/testbed_model/__init__.py b/dts/framework/testbed_model/__init__.py
index c5512e5812..13c29c59c8 100644
--- a/dts/framework/testbed_model/__init__.py
+++ b/dts/framework/testbed_model/__init__.py
@@ -2,6 +2,10 @@ 
 # Copyright(c) 2022 University of New Hampshire
 
 """
-This module contains the classes used to model the physical traffic generator,
+This package contains the classes used to model the physical traffic generator,
 system under test and any other components that need to be interacted with.
 """
+
+# pylama:ignore=W0611
+
+from .node import Node, SutNode
diff --git a/dts/framework/testbed_model/node.py b/dts/framework/testbed_model/node.py
deleted file mode 100644
index 8437975416..0000000000
--- a/dts/framework/testbed_model/node.py
+++ /dev/null
@@ -1,62 +0,0 @@ 
-# SPDX-License-Identifier: BSD-3-Clause
-# Copyright(c) 2010-2014 Intel Corporation
-# Copyright(c) 2022 PANTHEON.tech s.r.o.
-# Copyright(c) 2022 University of New Hampshire
-
-"""
-A node is a generic host that DTS connects to and manages.
-"""
-
-from framework.config import NodeConfiguration
-from framework.logger import DTSLOG, getLogger
-from framework.remote_session import RemoteSession, create_remote_session
-from framework.settings import SETTINGS
-
-
-class Node(object):
-    """
-    Basic module for node management. This module implements methods that
-    manage a node, such as information gathering (of CPU/PCI/NIC) and
-    environment setup.
-    """
-
-    name: str
-    main_session: RemoteSession
-    logger: DTSLOG
-    _config: NodeConfiguration
-    _other_sessions: list[RemoteSession]
-
-    def __init__(self, node_config: NodeConfiguration):
-        self._config = node_config
-        self._other_sessions = []
-
-        self.name = node_config.name
-        self.logger = getLogger(self.name)
-        self.logger.info(f"Created node: {self.name}")
-        self.main_session = create_remote_session(self._config, self.name, self.logger)
-
-    def send_command(self, cmds: str, timeout: float = SETTINGS.timeout) -> str:
-        """
-        Send commands to node and return string before timeout.
-        """
-
-        return self.main_session.send_command(cmds, timeout)
-
-    def create_session(self, name: str) -> RemoteSession:
-        connection = create_remote_session(
-            self._config,
-            name,
-            getLogger(name, node=self.name),
-        )
-        self._other_sessions.append(connection)
-        return connection
-
-    def node_exit(self) -> None:
-        """
-        Recover all resource before node exit
-        """
-        if self.main_session:
-            self.main_session.close()
-        for session in self._other_sessions:
-            session.close()
-        self.logger.logger_exit()
diff --git a/dts/framework/testbed_model/node/__init__.py b/dts/framework/testbed_model/node/__init__.py
new file mode 100644
index 0000000000..a179056f1f
--- /dev/null
+++ b/dts/framework/testbed_model/node/__init__.py
@@ -0,0 +1,7 @@ 
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2022 PANTHEON.tech s.r.o.
+
+# pylama:ignore=W0611
+
+from .node import Node
+from .sut_node import SutNode
diff --git a/dts/framework/testbed_model/node/node.py b/dts/framework/testbed_model/node/node.py
new file mode 100644
index 0000000000..86654e55ae
--- /dev/null
+++ b/dts/framework/testbed_model/node/node.py
@@ -0,0 +1,120 @@ 
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2010-2014 Intel Corporation
+# Copyright(c) 2022 PANTHEON.tech s.r.o.
+# Copyright(c) 2022 University of New Hampshire
+
+"""
+A node is a generic host that DTS connects to and manages.
+"""
+
+from framework.config import (
+    BuildTargetConfiguration,
+    ExecutionConfiguration,
+    NodeConfiguration,
+)
+from framework.exception import NodeCleanupError, NodeSetupError, convert_exception
+from framework.logger import DTSLOG, getLogger
+from framework.remote_session import OSSession, create_session
+
+
+class Node(object):
+    """
+    Basic class for node management. This class implements methods that
+    manage a node, such as information gathering (of CPU/PCI/NIC) and
+    environment setup.
+    """
+
+    name: str
+    main_session: OSSession
+    logger: DTSLOG
+    config: NodeConfiguration
+    _other_sessions: list[OSSession]
+
+    def __init__(self, node_config: NodeConfiguration):
+        self.config = node_config
+        self._other_sessions = []
+
+        self.name = node_config.name
+        self.logger = getLogger(self.name)
+        self.logger.info(f"Created node: {self.name}")
+        self.main_session = create_session(self.config, self.name, self.logger)
+
+    @convert_exception(NodeSetupError)
+    def setup_execution(self, execution_config: ExecutionConfiguration) -> None:
+        """
+        Perform the execution setup that will be done for each execution
+        this node is part of.
+        """
+        self._setup_execution(execution_config)
+
+    def _setup_execution(self, execution_config: ExecutionConfiguration) -> None:
+        """
+        This method exists to be optionally overwritten by derived classes and
+        is not decorated so that the derived class doesn't have to use the decorator.
+        """
+
+    @convert_exception(NodeSetupError)
+    def setup_build_target(self, build_target_config: BuildTargetConfiguration) -> None:
+        """
+        Perform the build target setup that will be done for each build target
+        tested on this node.
+        """
+        self._setup_build_target(build_target_config)
+
+    def _setup_build_target(
+        self, build_target_config: BuildTargetConfiguration
+    ) -> None:
+        """
+        This method exists to be optionally overwritten by derived classes and
+        is not decorated so that the derived class doesn't have to use the decorator.
+        """
+
+    @convert_exception(NodeCleanupError)
+    def teardown_build_target(self) -> None:
+        """
+        Perform the build target cleanup that will be done after each build target
+        tested on this node.
+        """
+        self._cleanup_build_target()
+
+    def _cleanup_build_target(self) -> None:
+        """
+        This method exists to be optionally overwritten by derived classes and
+        is not decorated so that the derived class doesn't have to use the decorator.
+        """
+
+    @convert_exception(NodeCleanupError)
+    def cleanup_execution(self) -> None:
+        """
+        Perform the execution cleanup that will be done after each execution
+        this node is part of concludes.
+        """
+        self._cleanup_execution()
+
+    def _cleanup_execution(self) -> None:
+        """
+        This method exists to be optionally overwritten by derived classes and
+        is not decorated so that the derived class doesn't have to use the decorator.
+        """
+
+    def create_session(self, name: str) -> OSSession:
+        """
+        Create and return a new OSSession tailored to the remote OS.
+        """
+        connection = create_session(
+            self.config,
+            name,
+            getLogger(name, node=self.name),
+        )
+        self._other_sessions.append(connection)
+        return connection
+
+    def close(self) -> None:
+        """
+        Close all connections and free other resources.
+        """
+        if self.main_session:
+            self.main_session.close()
+        for session in self._other_sessions:
+            session.close()
+        self.logger.logger_exit()
diff --git a/dts/framework/testbed_model/node/sut_node.py b/dts/framework/testbed_model/node/sut_node.py
new file mode 100644
index 0000000000..79d54585c9
--- /dev/null
+++ b/dts/framework/testbed_model/node/sut_node.py
@@ -0,0 +1,13 @@ 
+# SPDX-License-Identifier: BSD-3-Clause
+# Copyright(c) 2010-2014 Intel Corporation
+# Copyright(c) 2022 PANTHEON.tech s.r.o.
+
+from .node import Node
+
+
+class SutNode(Node):
+    """
+    A class for managing connections to the System under Test, providing
+    methods that retrieve the necessary information about the node (such as
+    cpu, memory and NIC details) and configuration capabilities.
+    """