<div dir="ltr"><div dir="ltr"></div><br><div class="gmail_quote"><div dir="ltr" class="gmail_attr">On Tue, May 2, 2023 at 9:00 AM Juraj Linkeš <juraj.linkes@pantheon.tech> wrote:<br></div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex">On Fri, Apr 28, 2023 at 9:04 PM Jeremy Spewock <<a href="mailto:jspewock@iol.unh.edu" target="_blank">jspewock@iol.unh.edu</a>> wrote:<br>
><br>
><br>
><br>
> On Mon, Apr 24, 2023 at 9:35 AM Juraj Linkeš <juraj.linkes@pantheon.tech> wrote:<br>
>><br>
>> Pexpect is not a dedicated SSH connection library while Fabric is. With<br>
>> Fabric, all SSH-related logic is provided and we can just focus on<br>
>> what's DTS specific.<br>
>><br>
>> Signed-off-by: Juraj Linkeš <juraj.linkes@pantheon.tech><br>
>> ---<br>
>> doc/guides/tools/dts.rst | 29 +-<br>
>> dts/conf.yaml | 2 +-<br>
>> dts/framework/exception.py | 10 +-<br>
>> dts/framework/remote_session/linux_session.py | 31 +-<br>
>> dts/framework/remote_session/os_session.py | 51 +++-<br>
>> dts/framework/remote_session/posix_session.py | 48 +--<br>
>> .../remote_session/remote/remote_session.py | 35 ++-<br>
>> .../remote_session/remote/ssh_session.py | 287 ++++++------------<br>
>> dts/framework/testbed_model/sut_node.py | 12 +-<br>
>> dts/framework/utils.py | 9 -<br>
>> dts/poetry.lock | 161 ++++++++--<br>
>> dts/pyproject.toml | 2 +-<br>
>> 12 files changed, 376 insertions(+), 301 deletions(-)<br>
>><br>
>> diff --git a/doc/guides/tools/dts.rst b/doc/guides/tools/dts.rst<br>
>> index ebd6dceb6a..d15826c098 100644<br>
>> --- a/doc/guides/tools/dts.rst<br>
>> +++ b/doc/guides/tools/dts.rst<br>
>> @@ -95,9 +95,14 @@ Setting up DTS environment<br>
>><br>
>> #. **SSH Connection**<br>
>><br>
>> - DTS uses Python pexpect for SSH connections between DTS environment and the other hosts.<br>
>> - The pexpect implementation is a wrapper around the ssh command in the DTS environment.<br>
>> - This means it'll use the SSH agent providing the ssh command and its keys.<br>
>> + DTS uses the Fabric Python library for SSH connections between DTS environment<br>
>> + and the other hosts.<br>
>> + The authentication method used is pubkey authentication.<br>
>> + Fabric tries to use a passed key/certificate,<br>
>> + then any key it can with through an SSH agent,<br>
>> + then any "id_rsa", "id_dsa" or "id_ecdsa" key discoverable in ``~/.ssh/``<br>
>> + (with any matching OpenSSH-style certificates).<br>
>> + DTS doesn't pass any keys, so Fabric tries to use the other two methods.<br>
>><br>
>><br>
>> Setting up System Under Test<br>
>> @@ -132,6 +137,21 @@ There are two areas that need to be set up on a System Under Test:<br>
>> It's possible to use the hugepage configuration already present on the SUT.<br>
>> If you wish to do so, don't specify the hugepage configuration in the DTS config file.<br>
>><br>
>> +#. **User with administrator privileges**<br>
>> +<br>
>> +.. _sut_admin_user:<br>
>> +<br>
>> + DTS needs administrator privileges to run DPDK applications (such as testpmd) on the SUT.<br>
>> + The SUT user must be able run commands in privileged mode without asking for password.<br>
>> + On most Linux distributions, it's a matter of setting up passwordless sudo:<br>
>> +<br>
>> + #. Run ``sudo visudo`` and check that it contains ``%sudo ALL=(ALL:ALL) ALL``.<br>
>> +<br>
>> + #. Add the SUT user to the sudo group with:<br>
>> +<br>
>> + .. code-block:: console<br>
>> +<br>
>> + sudo usermod -aG sudo <sut_user><br>
>><br>
>> Running DTS<br>
>> -----------<br>
>> @@ -151,7 +171,8 @@ which is a template that illustrates what can be configured in DTS:<br>
>> :start-at: executions:<br>
>><br>
>><br>
>> -The user must be root or any other user with prompt starting with ``#``.<br>
>> +The user must have :ref:`administrator privileges <sut_admin_user>`<br>
>> +which don't require password authentication.<br>
>> The other fields are mostly self-explanatory<br>
>> and documented in more detail in ``dts/framework/config/conf_yaml_schema.json``.<br>
>><br>
>> diff --git a/dts/conf.yaml b/dts/conf.yaml<br>
>> index a9bd8a3ecf..129801d87c 100644<br>
>> --- a/dts/conf.yaml<br>
>> +++ b/dts/conf.yaml<br>
>> @@ -16,7 +16,7 @@ executions:<br>
>> nodes:<br>
>> - name: "SUT 1"<br>
>> hostname: sut1.change.me.localhost<br>
>> - user: root<br>
>> + user: dtsuser<br>
>> arch: x86_64<br>
>> os: linux<br>
>> lcores: ""<br>
>> diff --git a/dts/framework/exception.py b/dts/framework/exception.py<br>
>> index ca353d98fc..44ff4e979a 100644<br>
>> --- a/dts/framework/exception.py<br>
>> +++ b/dts/framework/exception.py<br>
>> @@ -62,13 +62,19 @@ class SSHConnectionError(DTSError):<br>
>> """<br>
>><br>
>> host: str<br>
>> + errors: list[str]<br>
>> severity: ClassVar[ErrorSeverity] = ErrorSeverity.SSH_ERR<br>
>><br>
>> - def __init__(self, host: str):<br>
>> + def __init__(self, host: str, errors: list[str] | None = None):<br>
>> self.host = host<br>
>> + self.errors = [] if errors is None else errors<br>
>><br>
>> def __str__(self) -> str:<br>
>> - return f"Error trying to connect with {self.host}"<br>
>> + message = f"Error trying to connect with {self.host}."<br>
>> + if self.errors:<br>
>> + message += f" Errors encountered while retrying: {', '.join(self.errors)}"<br>
>> +<br>
>> + return message<br>
>><br>
>><br>
>> class SSHSessionDeadError(DTSError):<br>
>> diff --git a/dts/framework/remote_session/linux_session.py b/dts/framework/remote_session/linux_session.py<br>
>> index a1e3bc3a92..f13f399121 100644<br>
>> --- a/dts/framework/remote_session/linux_session.py<br>
>> +++ b/dts/framework/remote_session/linux_session.py<br>
>> @@ -14,10 +14,11 @@ class LinuxSession(PosixSession):<br>
>> The implementation of non-Posix compliant parts of Linux remote sessions.<br>
>> """<br>
>><br>
>> + def _get_privileged_command(self, command: str) -> str:<br>
>> + return f"sudo -- sh -c '{command}'"<br>
>> +<br>
>> def get_remote_cpus(self, use_first_core: bool) -> list[LogicalCore]:<br>
>> - cpu_info = self.remote_session.send_command(<br>
>> - "lscpu -p=CPU,CORE,SOCKET,NODE|grep -v \\#"<br>
>> - ).stdout<br>
>> + cpu_info = self.send_command("lscpu -p=CPU,CORE,SOCKET,NODE|grep -v \\#").stdout<br>
>> lcores = []<br>
>> for cpu_line in cpu_info.splitlines():<br>
>> lcore, core, socket, node = map(int, cpu_line.split(","))<br>
>> @@ -45,20 +46,20 @@ def setup_hugepages(self, hugepage_amount: int, force_first_numa: bool) -> None:<br>
>> self._mount_huge_pages()<br>
>><br>
>> def _get_hugepage_size(self) -> int:<br>
>> - hugepage_size = self.remote_session.send_command(<br>
>> + hugepage_size = self.send_command(<br>
>> "awk '/Hugepagesize/ {print $2}' /proc/meminfo"<br>
>> ).stdout<br>
>> return int(hugepage_size)<br>
>><br>
>> def _get_hugepages_total(self) -> int:<br>
>> - hugepages_total = self.remote_session.send_command(<br>
>> + hugepages_total = self.send_command(<br>
>> "awk '/HugePages_Total/ { print $2 }' /proc/meminfo"<br>
>> ).stdout<br>
>> return int(hugepages_total)<br>
>><br>
>> def _get_numa_nodes(self) -> list[int]:<br>
>> try:<br>
>> - numa_count = self.remote_session.send_command(<br>
>> + numa_count = self.send_command(<br>
>> "cat /sys/devices/system/node/online", verify=True<br>
>> ).stdout<br>
>> numa_range = expand_range(numa_count)<br>
>> @@ -70,14 +71,12 @@ def _get_numa_nodes(self) -> list[int]:<br>
>> def _mount_huge_pages(self) -> None:<br>
>> self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>("Re-mounting Hugepages.")<br>
>> hugapge_fs_cmd = "awk '/hugetlbfs/ { print $2 }' /proc/mounts"<br>
>> - self.remote_session.send_command(f"umount $({hugapge_fs_cmd})")<br>
>> - result = self.remote_session.send_command(hugapge_fs_cmd)<br>
>> + self.send_command(f"umount $({hugapge_fs_cmd})")<br>
>> + result = self.send_command(hugapge_fs_cmd)<br>
>> if result.stdout == "":<br>
>> remote_mount_path = "/mnt/huge"<br>
>> - self.remote_session.send_command(f"mkdir -p {remote_mount_path}")<br>
>> - self.remote_session.send_command(<br>
>> - f"mount -t hugetlbfs nodev {remote_mount_path}"<br>
>> - )<br>
>> + self.send_command(f"mkdir -p {remote_mount_path}")<br>
>> + self.send_command(f"mount -t hugetlbfs nodev {remote_mount_path}")<br>
>><br>
>> def _supports_numa(self) -> bool:<br>
>> # the system supports numa if self._numa_nodes is non-empty and there are more<br>
>> @@ -94,14 +93,12 @@ def _configure_huge_pages(<br>
>> )<br>
>> if force_first_numa and self._supports_numa():<br>
>> # clear non-numa hugepages<br>
>> - self.remote_session.send_command(<br>
>> - f"echo 0 | sudo tee {hugepage_config_path}"<br>
>> - )<br>
>> + self.send_command(f"echo 0 | tee {hugepage_config_path}", privileged=True)<br>
>> hugepage_config_path = (<br>
>> f"/sys/devices/system/node/node{self._numa_nodes[0]}/hugepages"<br>
>> f"/hugepages-{size}kB/nr_hugepages"<br>
>> )<br>
>><br>
>> - self.remote_session.send_command(<br>
>> - f"echo {amount} | sudo tee {hugepage_config_path}"<br>
>> + self.send_command(<br>
>> + f"echo {amount} | tee {hugepage_config_path}", privileged=True<br>
>> )<br>
>> diff --git a/dts/framework/remote_session/os_session.py b/dts/framework/remote_session/os_session.py<br>
>> index 4c48ae2567..bfd70bd480 100644<br>
>> --- a/dts/framework/remote_session/os_session.py<br>
>> +++ b/dts/framework/remote_session/os_session.py<br>
>> @@ -10,7 +10,7 @@<br>
>> from framework.logger import DTSLOG<br>
>> from framework.settings import SETTINGS<br>
>> from framework.testbed_model import LogicalCore<br>
>> -from framework.utils import EnvVarsDict, MesonArgs<br>
>> +from framework.utils import MesonArgs<br>
>><br>
>> from .remote import CommandResult, RemoteSession, create_remote_session<br>
>><br>
>> @@ -53,17 +53,32 @@ def is_alive(self) -> bool:<br>
>> def send_command(<br>
>> self,<br>
>> command: str,<br>
>> - timeout: float,<br>
>> + timeout: float = SETTINGS.timeout,<br>
>> + privileged: bool = False,<br>
>> verify: bool = False,<br>
>> - env: EnvVarsDict | None = None,<br>
>> + env: dict | None = None,<br>
>> ) -> CommandResult:<br>
>> """<br>
>> An all-purpose API in case the command to be executed is already<br>
>> OS-agnostic, such as when the path to the executed command has been<br>
>> constructed beforehand.<br>
>> """<br>
>> + if privileged:<br>
>> + command = self._get_privileged_command(command)<br>
>> +<br>
>> return self.remote_session.send_command(command, timeout, verify, env)<br>
>><br>
>> + @abstractmethod<br>
>> + def _get_privileged_command(self, command: str) -> str:<br>
>> + """Modify the command so that it executes with administrative privileges.<br>
>> +<br>
>> + Args:<br>
>> + command: The command to modify.<br>
>> +<br>
>> + Returns:<br>
>> + The modified command that executes with administrative privileges.<br>
>> + """<br>
>> +<br>
>> @abstractmethod<br>
>> def guess_dpdk_remote_dir(self, remote_dir) -> PurePath:<br>
>> """<br>
>> @@ -90,17 +105,35 @@ def join_remote_path(self, *args: str | PurePath) -> PurePath:<br>
>> """<br>
>><br>
>> @abstractmethod<br>
>> - def copy_file(<br>
>> + def copy_from(<br>
>> self,<br>
>> source_file: str | PurePath,<br>
>> destination_file: str | PurePath,<br>
>> - source_remote: bool = False,<br>
>> ) -> None:<br>
>> + """Copy a file from the remote Node to the local filesystem.<br>
>> +<br>
>> + Copy source_file from the remote Node associated with this remote<br>
>> + session to destination_file on the local filesystem.<br>
>> +<br>
>> + Args:<br>
>> + source_file: the file on the remote Node.<br>
>> + destination_file: a file or directory path on the local filesystem.<br>
>> """<br>
>> +<br>
>> + @abstractmethod<br>
>> + def copy_to(<br>
>> + self,<br>
>> + source_file: str | PurePath,<br>
>> + destination_file: str | PurePath,<br>
>> + ) -> None:<br>
>> + """Copy a file from local filesystem to the remote Node.<br>
>> +<br>
>> Copy source_file from local filesystem to destination_file<br>
>> - on the remote Node associated with the remote session.<br>
>> - If source_remote is True, reverse the direction - copy source_file from the<br>
>> - associated remote Node to destination_file on local storage.<br>
>> + on the remote Node associated with this remote session.<br>
>> +<br>
>> + Args:<br>
>> + source_file: the file on the local filesystem.<br>
>> + destination_file: a file or directory path on the remote Node.<br>
>> """<br>
>><br>
>> @abstractmethod<br>
>> @@ -128,7 +161,7 @@ def extract_remote_tarball(<br>
>> @abstractmethod<br>
>> def build_dpdk(<br>
>> self,<br>
>> - env_vars: EnvVarsDict,<br>
>> + env_vars: dict,<br>
>> meson_args: MesonArgs,<br>
>> remote_dpdk_dir: str | PurePath,<br>
>> remote_dpdk_build_dir: str | PurePath,<br>
>> diff --git a/dts/framework/remote_session/posix_session.py b/dts/framework/remote_session/posix_session.py<br>
>> index d38062e8d6..8ca0acb429 100644<br>
>> --- a/dts/framework/remote_session/posix_session.py<br>
>> +++ b/dts/framework/remote_session/posix_session.py<br>
>> @@ -9,7 +9,7 @@<br>
>> from framework.config import Architecture<br>
>> from framework.exception import DPDKBuildError, RemoteCommandExecutionError<br>
>> from framework.settings import SETTINGS<br>
>> -from framework.utils import EnvVarsDict, MesonArgs<br>
>> +from framework.utils import MesonArgs<br>
>><br>
>> from .os_session import OSSession<br>
>><br>
>> @@ -34,7 +34,7 @@ def combine_short_options(**opts: bool) -> str:<br>
>><br>
>> def guess_dpdk_remote_dir(self, remote_dir) -> PurePosixPath:<br>
>> remote_guess = self.join_remote_path(remote_dir, "dpdk-*")<br>
>> - result = self.remote_session.send_command(f"ls -d {remote_guess} | tail -1")<br>
>> + result = self.send_command(f"ls -d {remote_guess} | tail -1")<br>
>> return PurePosixPath(result.stdout)<br>
>><br>
>> def get_remote_tmp_dir(self) -> PurePosixPath:<br>
>> @@ -48,7 +48,7 @@ def get_dpdk_build_env_vars(self, arch: Architecture) -> dict:<br>
>> env_vars = {}<br>
>> if arch == Architecture.i686:<br>
>> # find the pkg-config path and store it in PKG_CONFIG_LIBDIR<br>
>> - out = self.remote_session.send_command("find /usr -type d -name pkgconfig")<br>
>> + out = self.send_command("find /usr -type d -name pkgconfig")<br>
>> pkg_path = ""<br>
>> res_path = out.stdout.split("\r\n")<br>
>> for cur_path in res_path:<br>
>> @@ -65,13 +65,19 @@ def get_dpdk_build_env_vars(self, arch: Architecture) -> dict:<br>
>> def join_remote_path(self, *args: str | PurePath) -> PurePosixPath:<br>
>> return PurePosixPath(*args)<br>
>><br>
>> - def copy_file(<br>
>> + def copy_from(<br>
>> self,<br>
>> source_file: str | PurePath,<br>
>> destination_file: str | PurePath,<br>
>> - source_remote: bool = False,<br>
>> ) -> None:<br>
>> - self.remote_session.copy_file(source_file, destination_file, source_remote)<br>
>> + self.remote_session.copy_from(source_file, destination_file)<br>
>> +<br>
>> + def copy_to(<br>
>> + self,<br>
>> + source_file: str | PurePath,<br>
>> + destination_file: str | PurePath,<br>
>> + ) -> None:<br>
>> + self.remote_session.copy_to(source_file, destination_file)<br>
>><br>
>> def remove_remote_dir(<br>
>> self,<br>
>> @@ -80,24 +86,24 @@ def remove_remote_dir(<br>
>> force: bool = True,<br>
>> ) -> None:<br>
>> opts = PosixSession.combine_short_options(r=recursive, f=force)<br>
>> - self.remote_session.send_command(f"rm{opts} {remote_dir_path}")<br>
>> + self.send_command(f"rm{opts} {remote_dir_path}")<br>
>><br>
>> def extract_remote_tarball(<br>
>> self,<br>
>> remote_tarball_path: str | PurePath,<br>
>> expected_dir: str | PurePath | None = None,<br>
>> ) -> None:<br>
>> - self.remote_session.send_command(<br>
>> + self.send_command(<br>
>> f"tar xfm {remote_tarball_path} "<br>
>> f"-C {PurePosixPath(remote_tarball_path).parent}",<br>
>> 60,<br>
>> )<br>
>> if expected_dir:<br>
>> - self.remote_session.send_command(f"ls {expected_dir}", verify=True)<br>
>> + self.send_command(f"ls {expected_dir}", verify=True)<br>
>><br>
>> def build_dpdk(<br>
>> self,<br>
>> - env_vars: EnvVarsDict,<br>
>> + env_vars: dict,<br>
>> meson_args: MesonArgs,<br>
>> remote_dpdk_dir: str | PurePath,<br>
>> remote_dpdk_build_dir: str | PurePath,<br>
>> @@ -108,7 +114,7 @@ def build_dpdk(<br>
>> if rebuild:<br>
>> # reconfigure, then build<br>
>> self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>("Reconfiguring DPDK build.")<br>
>> - self.remote_session.send_command(<br>
>> + self.send_command(<br>
>> f"meson configure {meson_args} {remote_dpdk_build_dir}",<br>
>> timeout,<br>
>> verify=True,<br>
>> @@ -118,7 +124,7 @@ def build_dpdk(<br>
>> # fresh build - remove target dir first, then build from scratch<br>
>> self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>("Configuring DPDK build from scratch.")<br>
>> self.remove_remote_dir(remote_dpdk_build_dir)<br>
>> - self.remote_session.send_command(<br>
>> + self.send_command(<br>
>> f"meson setup "<br>
>> f"{meson_args} {remote_dpdk_dir} {remote_dpdk_build_dir}",<br>
>> timeout,<br>
>> @@ -127,14 +133,14 @@ def build_dpdk(<br>
>> )<br>
>><br>
>> self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>("Building DPDK.")<br>
>> - self.remote_session.send_command(<br>
>> + self.send_command(<br>
>> f"ninja -C {remote_dpdk_build_dir}", timeout, verify=True, env=env_vars<br>
>> )<br>
>> except RemoteCommandExecutionError as e:<br>
>> raise DPDKBuildError(f"DPDK build failed when doing '{e.command}'.")<br>
>><br>
>> def get_dpdk_version(self, build_dir: str | PurePath) -> str:<br>
>> - out = self.remote_session.send_command(<br>
>> + out = self.send_command(<br>
>> f"cat {self.join_remote_path(build_dir, 'VERSION')}", verify=True<br>
>> )<br>
>> return out.stdout<br>
>> @@ -146,7 +152,7 @@ def kill_cleanup_dpdk_apps(self, dpdk_prefix_list: Iterable[str]) -> None:<br>
>> # kill and cleanup only if DPDK is running<br>
>> dpdk_pids = self._get_dpdk_pids(dpdk_runtime_dirs)<br>
>> for dpdk_pid in dpdk_pids:<br>
>> - self.remote_session.send_command(f"kill -9 {dpdk_pid}", 20)<br>
>> + self.send_command(f"kill -9 {dpdk_pid}", 20)<br>
>> self._check_dpdk_hugepages(dpdk_runtime_dirs)<br>
>> self._remove_dpdk_runtime_dirs(dpdk_runtime_dirs)<br>
>><br>
>> @@ -168,7 +174,7 @@ def _list_remote_dirs(self, remote_path: str | PurePath) -> list[str] | None:<br>
>> Return a list of directories of the remote_dir.<br>
>> If remote_path doesn't exist, return None.<br>
>> """<br>
>> - out = self.remote_session.send_command(<br>
>> + out = self.send_command(<br>
>> f"ls -l {remote_path} | awk '/^d/ {{print $NF}}'"<br>
>> ).stdout<br>
>> if "No such file or directory" in out:<br>
>> @@ -182,9 +188,7 @@ def _get_dpdk_pids(self, dpdk_runtime_dirs: Iterable[str | PurePath]) -> list[in<br>
>> for dpdk_runtime_dir in dpdk_runtime_dirs:<br>
>> dpdk_config_file = PurePosixPath(dpdk_runtime_dir, "config")<br>
>> if self._remote_files_exists(dpdk_config_file):<br>
>> - out = self.remote_session.send_command(<br>
>> - f"lsof -Fp {dpdk_config_file}"<br>
>> - ).stdout<br>
>> + out = self.send_command(f"lsof -Fp {dpdk_config_file}").stdout<br>
>> if out and "No such file or directory" not in out:<br>
>> for out_line in out.splitlines():<br>
>> match = re.match(pid_regex, out_line)<br>
>> @@ -193,7 +197,7 @@ def _get_dpdk_pids(self, dpdk_runtime_dirs: Iterable[str | PurePath]) -> list[in<br>
>> return pids<br>
>><br>
>> def _remote_files_exists(self, remote_path: PurePath) -> bool:<br>
>> - result = self.remote_session.send_command(f"test -e {remote_path}")<br>
>> + result = self.send_command(f"test -e {remote_path}")<br>
>> return not result.return_code<br>
>><br>
>> def _check_dpdk_hugepages(<br>
>> @@ -202,9 +206,7 @@ def _check_dpdk_hugepages(<br>
>> for dpdk_runtime_dir in dpdk_runtime_dirs:<br>
>> hugepage_info = PurePosixPath(dpdk_runtime_dir, "hugepage_info")<br>
>> if self._remote_files_exists(hugepage_info):<br>
>> - out = self.remote_session.send_command(<br>
>> - f"lsof -Fp {hugepage_info}"<br>
>> - ).stdout<br>
>> + out = self.send_command(f"lsof -Fp {hugepage_info}").stdout<br>
>> if out and "No such file or directory" not in out:<br>
>> self._logger.warning("Some DPDK processes did not free hugepages.")<br>
>> self._logger.warning("*******************************************")<br>
>> diff --git a/dts/framework/remote_session/remote/remote_session.py b/dts/framework/remote_session/remote/remote_session.py<br>
>> index 91dee3cb4f..0647d93de4 100644<br>
>> --- a/dts/framework/remote_session/remote/remote_session.py<br>
>> +++ b/dts/framework/remote_session/remote/remote_session.py<br>
>> @@ -11,7 +11,6 @@<br>
>> from framework.exception import RemoteCommandExecutionError<br>
>> from framework.logger import DTSLOG<br>
>> from framework.settings import SETTINGS<br>
>> -from framework.utils import EnvVarsDict<br>
>><br>
>><br>
>> @dataclasses.dataclass(slots=True, frozen=True)<br>
>> @@ -89,7 +88,7 @@ def send_command(<br>
>> command: str,<br>
>> timeout: float = SETTINGS.timeout,<br>
>> verify: bool = False,<br>
>> - env: EnvVarsDict | None = None,<br>
>> + env: dict | None = None,<br>
>> ) -> CommandResult:<br>
>> """<br>
>> Send a command to the connected node using optional env vars<br>
>> @@ -114,7 +113,7 @@ def send_command(<br>
>><br>
>> @abstractmethod<br>
>> def _send_command(<br>
>> - self, command: str, timeout: float, env: EnvVarsDict | None<br>
>> + self, command: str, timeout: float, env: dict | None<br>
>> ) -> CommandResult:<br>
>> """<br>
>> Use the underlying protocol to execute the command using optional env vars<br>
>> @@ -141,15 +140,33 @@ def is_alive(self) -> bool:<br>
>> """<br>
>><br>
>> @abstractmethod<br>
>> - def copy_file(<br>
>> + def copy_from(<br>
>> self,<br>
>> source_file: str | PurePath,<br>
>> destination_file: str | PurePath,<br>
>> - source_remote: bool = False,<br>
>> ) -> None:<br>
>> + """Copy a file from the remote Node to the local filesystem.<br>
>> +<br>
>> + Copy source_file from the remote Node associated with this remote<br>
>> + session to destination_file on the local filesystem.<br>
>> +<br>
>> + Args:<br>
>> + source_file: the file on the remote Node.<br>
>> + destination_file: a file or directory path on the local filesystem.<br>
>> """<br>
>> - Copy source_file from local filesystem to destination_file on the remote Node<br>
>> - associated with the remote session.<br>
>> - If source_remote is True, reverse the direction - copy source_file from the<br>
>> - associated Node to destination_file on local filesystem.<br>
>> +<br>
>> + @abstractmethod<br>
>> + def copy_to(<br>
>> + self,<br>
>> + source_file: str | PurePath,<br>
>> + destination_file: str | PurePath,<br>
>> + ) -> None:<br>
>> + """Copy a file from local filesystem to the remote Node.<br>
>> +<br>
>> + Copy source_file from local filesystem to destination_file<br>
>> + on the remote Node associated with this remote session.<br>
>> +<br>
>> + Args:<br>
>> + source_file: the file on the local filesystem.<br>
>> + destination_file: a file or directory path on the remote Node.<br>
>> """<br>
>> diff --git a/dts/framework/remote_session/remote/ssh_session.py b/dts/framework/remote_session/remote/ssh_session.py<br>
>> index 42ff9498a2..8d127f1601 100644<br>
>> --- a/dts/framework/remote_session/remote/ssh_session.py<br>
>> +++ b/dts/framework/remote_session/remote/ssh_session.py<br>
>> @@ -1,29 +1,49 @@<br>
>> # SPDX-License-Identifier: BSD-3-Clause<br>
>> -# Copyright(c) 2010-2014 Intel Corporation<br>
>> -# Copyright(c) 2022-2023 PANTHEON.tech s.r.o.<br>
>> -# Copyright(c) 2022-2023 University of New Hampshire<br>
>> +# Copyright(c) 2023 PANTHEON.tech s.r.o.<br>
>><br>
><br>
> I've noticed in other patches you've simply appended the copyright for PANTHEON.tech to the existing list. Is there a reason you remove the others here as well?<br>
><br>
<br>
It's a rewrite of the file. I'm the only author of the code (i.e.<br>
neither Intel nor UNH contributed to the Fabric code) so I left only<br>
us there. I'm not sure this is the right way to do this, but it made<br>
sense to me. I have no problem with leaving all parties in.<br>
<br></blockquote><div><br></div><div>It also makes sense to me. I'm also not completely sure if it is the right way to handle it, but the way I see it because the Copyrights exist in every file it makes sense that they would be in the scope of that file.</div><div> </div><blockquote class="gmail_quote" style="margin:0px 0px 0px 0.8ex;border-left:1px solid rgb(204,204,204);padding-left:1ex">
>><br>
>> -import time<br>
>> +import socket<br>
>> +import traceback<br>
>> from pathlib import PurePath<br>
>><br>
>> -import pexpect # type: ignore<br>
>> -from pexpect import pxssh # type: ignore<br>
>> +from fabric import Connection # type: ignore[import]<br>
>> +from invoke.exceptions import ( # type: ignore[import]<br>
>> + CommandTimedOut,<br>
>> + ThreadException,<br>
>> + UnexpectedExit,<br>
>> +)<br>
>> +from paramiko.ssh_exception import ( # type: ignore[import]<br>
>> + AuthenticationException,<br>
>> + BadHostKeyException,<br>
>> + NoValidConnectionsError,<br>
>> + SSHException,<br>
>> +)<br>
>><br>
>> from framework.config import NodeConfiguration<br>
>> from framework.exception import SSHConnectionError, SSHSessionDeadError, SSHTimeoutError<br>
>> from framework.logger import DTSLOG<br>
>> -from framework.utils import GREEN, RED, EnvVarsDict<br>
>><br>
>> from .remote_session import CommandResult, RemoteSession<br>
>><br>
>><br>
>> class SSHSession(RemoteSession):<br>
>> - """<br>
>> - Module for creating Pexpect SSH remote sessions.<br>
>> + """A persistent SSH connection to a remote Node.<br>
>> +<br>
>> + The connection is implemented with the Fabric Python library.<br>
>> +<br>
>> + Args:<br>
>> + node_config: The configuration of the Node to connect to.<br>
>> + session_name: The name of the session.<br>
>> + logger: The logger used for logging.<br>
>> + This should be passed from the parent OSSession.<br>
>> +<br>
>> + Attributes:<br>
>> + session: The underlying Fabric SSH connection.<br>
>> +<br>
>> + Raises:<br>
>> + SSHConnectionError: The connection cannot be established.<br>
>> """<br>
>><br>
>> - session: pxssh.pxssh<br>
>> - magic_prompt: str<br>
>> + session: Connection<br>
>><br>
>> def __init__(<br>
>> self,<br>
>> @@ -31,218 +51,91 @@ def __init__(<br>
>> session_name: str,<br>
>> logger: DTSLOG,<br>
>> ):<br>
>> - self.magic_prompt = "MAGIC PROMPT"<br>
>> super(SSHSession, self).__init__(node_config, session_name, logger)<br>
>><br>
>> def _connect(self) -> None:<br>
>> - """<br>
>> - Create connection to assigned node.<br>
>> - """<br>
>> + errors = []<br>
>> retry_attempts = 10<br>
>> login_timeout = 20 if self.port else 10<br>
>> - password_regex = (<br>
>> - r"(?i)(?:password:)|(?:passphrase for key)|(?i)(password for .+:)"<br>
>> - )<br>
>> - try:<br>
>> - for retry_attempt in range(retry_attempts):<br>
>> - self.session = pxssh.pxssh(encoding="utf-8")<br>
>> - try:<br>
>> - self.session.login(<br>
>> - self.ip,<br>
>> - self.username,<br>
>> - self.password,<br>
>> - original_prompt="[$#>]",<br>
>> - port=self.port,<br>
>> - login_timeout=login_timeout,<br>
>> - password_regex=password_regex,<br>
>> - )<br>
>> - break<br>
>> - except Exception as e:<br>
>> - self._logger.warning(e)<br>
>> - time.sleep(2)<br>
>> - self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>(<br>
>> - f"Retrying connection: retry number {retry_attempt + 1}."<br>
>> - )<br>
>> - else:<br>
>> - raise Exception(f"Connection to {self.hostname} failed")<br>
>> -<br>
>> - self.send_expect("stty -echo", "#")<br>
>> - self.send_expect("stty columns 1000", "#")<br>
>> - self.send_expect("bind 'set enable-bracketed-paste off'", "#")<br>
>> - except Exception as e:<br>
>> - self._logger.error(RED(str(e)))<br>
>> - if getattr(self, "port", None):<br>
>> - suggestion = (<br>
>> - f"\nSuggestion: Check if the firewall on {self.hostname} is "<br>
>> - f"stopped.\n"<br>
>> + for retry_attempt in range(retry_attempts):<br>
>> + try:<br>
>> + self.session = Connection(<br>
>> + self.ip,<br>
>> + user=self.username,<br>
>> + port=self.port,<br>
>> + connect_kwargs={"password": self.password},<br>
>> + connect_timeout=login_timeout,<br>
>> )<br>
>> - self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>(GREEN(suggestion))<br>
>> -<br>
>> - raise SSHConnectionError(self.hostname)<br>
>> + self.session.open()<br>
>><br>
>> - def send_expect(<br>
>> - self, command: str, prompt: str, timeout: float = 15, verify: bool = False<br>
>> - ) -> str | int:<br>
>> - try:<br>
>> - ret = self.send_expect_base(command, prompt, timeout)<br>
>> - if verify:<br>
>> - ret_status = self.send_expect_base("echo $?", prompt, timeout)<br>
>> - try:<br>
>> - retval = int(ret_status)<br>
>> - if retval:<br>
>> - self._logger.error(f"Command: {command} failure!")<br>
>> - self._logger.error(ret)<br>
>> - return retval<br>
>> - else:<br>
>> - return ret<br>
>> - except ValueError:<br>
>> - return ret<br>
>> - else:<br>
>> - return ret<br>
>> - except Exception as e:<br>
>> - self._logger.error(<br>
>> - f"Exception happened in [{command}] and output is "<br>
>> - f"[{self._get_output()}]"<br>
>> - )<br>
>> - raise e<br>
>> -<br>
>> - def send_expect_base(self, command: str, prompt: str, timeout: float) -> str:<br>
>> - self._clean_session()<br>
>> - original_prompt = self.session.PROMPT<br>
>> - self.session.PROMPT = prompt<br>
>> - self._send_line(command)<br>
>> - self._prompt(command, timeout)<br>
>> -<br>
>> - before = self._get_output()<br>
>> - self.session.PROMPT = original_prompt<br>
>> - return before<br>
>> -<br>
>> - def _clean_session(self) -> None:<br>
>> - self.session.PROMPT = self.magic_prompt<br>
>> - self.get_output(timeout=0.01)<br>
>> - self.session.PROMPT = self.session.UNIQUE_PROMPT<br>
>> -<br>
>> - def _send_line(self, command: str) -> None:<br>
>> - if not self.is_alive():<br>
>> - raise SSHSessionDeadError(self.hostname)<br>
>> - if len(command) == 2 and command.startswith("^"):<br>
>> - self.session.sendcontrol(command[1])<br>
>> - else:<br>
>> - self.session.sendline(command)<br>
>> + except (ValueError, BadHostKeyException, AuthenticationException) as e:<br>
>> + self._logger.exception(e)<br>
>> + raise SSHConnectionError(self.hostname) from e<br>
>><br>
>> - def _prompt(self, command: str, timeout: float) -> None:<br>
>> - if not self.session.prompt(timeout):<br>
>> - raise SSHTimeoutError(command, self._get_output()) from None<br>
>> + except (NoValidConnectionsError, socket.error, SSHException) as e:<br>
>> + self._logger.debug(traceback.format_exc())<br>
>> + self._logger.warning(e)<br>
>><br>
>> - def get_output(self, timeout: float = 15) -> str:<br>
>> - """<br>
>> - Get all output before timeout<br>
>> - """<br>
>> - try:<br>
>> - self.session.prompt(timeout)<br>
>> - except Exception:<br>
>> - pass<br>
>> -<br>
>> - before = self._get_output()<br>
>> - self._flush()<br>
>> -<br>
>> - return before<br>
>> + error = repr(e)<br>
>> + if error not in errors:<br>
>> + errors.append(error)<br>
>><br>
>> - def _get_output(self) -> str:<br>
>> - if not self.is_alive():<br>
>> - raise SSHSessionDeadError(self.hostname)<br>
>> - before = self.session.before.rsplit("\r\n", 1)[0]<br>
>> - if before == "[PEXPECT]":<br>
>> - return ""<br>
>> - return before<br>
>> + self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>(<br>
>> + f"Retrying connection: retry number {retry_attempt + 1}."<br>
>> + )<br>
>><br>
>> - def _flush(self) -> None:<br>
>> - """<br>
>> - Clear all session buffer<br>
>> - """<br>
>> - self.session.buffer = ""<br>
>> - self.session.before = ""<br>
>> + else:<br>
>> + break<br>
>> + else:<br>
>> + raise SSHConnectionError(self.hostname, errors)<br>
>><br>
>> def is_alive(self) -> bool:<br>
>> - return self.session.isalive()<br>
>> + return self.session.is_connected<br>
>><br>
>> def _send_command(<br>
>> - self, command: str, timeout: float, env: EnvVarsDict | None<br>
>> + self, command: str, timeout: float, env: dict | None<br>
>> ) -> CommandResult:<br>
>> - output = self._send_command_get_output(command, timeout, env)<br>
>> - return_code = int(self._send_command_get_output("echo $?", timeout, None))<br>
>> + """Send a command and return the result of the execution.<br>
>><br>
>> - # we're capturing only stdout<br>
>> - return CommandResult(<a href="http://self.name" rel="noreferrer" target="_blank">self.name</a>, command, output, "", return_code)<br>
>> + Args:<br>
>> + command: The command to execute.<br>
>> + timeout: Wait at most this many seconds for the execution to complete.<br>
>> + env: Extra environment variables that will be used in command execution.<br>
>><br>
>> - def _send_command_get_output(<br>
>> - self, command: str, timeout: float, env: EnvVarsDict | None<br>
>> - ) -> str:<br>
>> + Raises:<br>
>> + SSHSessionDeadError: The session died while executing the command.<br>
>> + SSHTimeoutError: The command execution timed out.<br>
>> + """<br>
>> try:<br>
>> - self._clean_session()<br>
>> - if env:<br>
>> - command = f"{env} {command}"<br>
>> - self._send_line(command)<br>
>> - except Exception as e:<br>
>> - raise e<br>
>> + output = self.session.run(<br>
>> + command, env=env, warn=True, hide=True, timeout=timeout<br>
>> + )<br>
>><br>
>> - output = self.get_output(timeout=timeout)<br>
>> - self.session.PROMPT = self.session.UNIQUE_PROMPT<br>
>> - self.session.prompt(0.1)<br>
>> + except (UnexpectedExit, ThreadException) as e:<br>
>> + self._logger.exception(e)<br>
>> + raise SSHSessionDeadError(self.hostname) from e<br>
>><br>
>> - return output<br>
>> + except CommandTimedOut as e:<br>
>> + self._logger.exception(e)<br>
>> + raise SSHTimeoutError(command, e.result.stderr) from e<br>
>><br>
>> - def _close(self, force: bool = False) -> None:<br>
>> - if force is True:<br>
>> - self.session.close()<br>
>> - else:<br>
>> - if self.is_alive():<br>
>> - self.session.logout()<br>
>> + return CommandResult(<br>
>> + <a href="http://self.name" rel="noreferrer" target="_blank">self.name</a>, command, output.stdout, output.stderr, output.return_code<br>
>> + )<br>
>><br>
>> - def copy_file(<br>
>> + def copy_from(<br>
>> self,<br>
>> source_file: str | PurePath,<br>
>> destination_file: str | PurePath,<br>
>> - source_remote: bool = False,<br>
>> ) -> None:<br>
>> - """<br>
>> - Send a local file to a remote host.<br>
>> - """<br>
>> - if source_remote:<br>
>> - source_file = f"{self.username}@{self.ip}:{source_file}"<br>
>> - else:<br>
>> - destination_file = f"{self.username}@{self.ip}:{destination_file}"<br>
>> + self.session.get(str(destination_file), str(source_file))<br>
>><br>
>> - port = ""<br>
>> - if self.port:<br>
>> - port = f" -P {self.port}"<br>
>> -<br>
>> - command = (<br>
>> - f"scp -v{port} -o NoHostAuthenticationForLocalhost=yes"<br>
>> - f" {source_file} {destination_file}"<br>
>> - )<br>
>> -<br>
>> - self._spawn_scp(command)<br>
>> + def copy_to(<br>
>> + self,<br>
>> + source_file: str | PurePath,<br>
>> + destination_file: str | PurePath,<br>
>> + ) -> None:<br>
>> + self.session.put(str(source_file), str(destination_file))<br>
>><br>
>> - def _spawn_scp(self, scp_cmd: str) -> None:<br>
>> - """<br>
>> - Transfer a file with SCP<br>
>> - """<br>
>> - self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>(scp_cmd)<br>
>> - p: pexpect.spawn = pexpect.spawn(scp_cmd)<br>
>> - time.sleep(0.5)<br>
>> - ssh_newkey: str = "Are you sure you want to continue connecting"<br>
>> - i: int = p.expect(<br>
>> - [ssh_newkey, "[pP]assword", "# ", pexpect.EOF, pexpect.TIMEOUT], 120<br>
>> - )<br>
>> - if i == 0: # add once in trust list<br>
>> - p.sendline("yes")<br>
>> - i = p.expect([ssh_newkey, "[pP]assword", pexpect.EOF], 2)<br>
>> -<br>
>> - if i == 1:<br>
>> - time.sleep(0.5)<br>
>> - p.sendline(self.password)<br>
>> - p.expect("Exit status 0", 60)<br>
>> - if i == 4:<br>
>> - self._logger.error("SCP TIMEOUT error %d" % i)<br>
>> - p.close()<br>
>> + def _close(self, force: bool = False) -> None:<br>
>> + self.session.close()<br>
>> diff --git a/dts/framework/testbed_model/sut_node.py b/dts/framework/testbed_model/sut_node.py<br>
>> index 2b2b50d982..9dbc390848 100644<br>
>> --- a/dts/framework/testbed_model/sut_node.py<br>
>> +++ b/dts/framework/testbed_model/sut_node.py<br>
>> @@ -10,7 +10,7 @@<br>
>> from framework.config import BuildTargetConfiguration, NodeConfiguration<br>
>> from framework.remote_session import CommandResult, OSSession<br>
>> from framework.settings import SETTINGS<br>
>> -from framework.utils import EnvVarsDict, MesonArgs<br>
>> +from framework.utils import MesonArgs<br>
>><br>
>> from .hw import LogicalCoreCount, LogicalCoreList, VirtualDevice<br>
>> from .node import Node<br>
>> @@ -27,7 +27,7 @@ class SutNode(Node):<br>
>> _dpdk_prefix_list: list[str]<br>
>> _dpdk_timestamp: str<br>
>> _build_target_config: BuildTargetConfiguration | None<br>
>> - _env_vars: EnvVarsDict<br>
>> + _env_vars: dict<br>
>> _remote_tmp_dir: PurePath<br>
>> __remote_dpdk_dir: PurePath | None<br>
>> _dpdk_version: str | None<br>
>> @@ -38,7 +38,7 @@ def __init__(self, node_config: NodeConfiguration):<br>
>> super(SutNode, self).__init__(node_config)<br>
>> self._dpdk_prefix_list = []<br>
>> self._build_target_config = None<br>
>> - self._env_vars = EnvVarsDict()<br>
>> + self._env_vars = {}<br>
>> self._remote_tmp_dir = self.main_session.get_remote_tmp_dir()<br>
>> self.__remote_dpdk_dir = None<br>
>> self._dpdk_version = None<br>
>> @@ -94,7 +94,7 @@ def _configure_build_target(<br>
>> """<br>
>> Populate common environment variables and set build target config.<br>
>> """<br>
>> - self._env_vars = EnvVarsDict()<br>
>> + self._env_vars = {}<br>
>> self._build_target_config = build_target_config<br>
>> self._env_vars.update(<br>
>> self.main_session.get_dpdk_build_env_vars(build_target_config.arch)<br>
>> @@ -112,7 +112,7 @@ def _copy_dpdk_tarball(self) -> None:<br>
>> Copy to and extract DPDK tarball on the SUT node.<br>
>> """<br>
>> self._<a href="http://logger.info" rel="noreferrer" target="_blank">logger.info</a>("Copying DPDK tarball to SUT.")<br>
>> - self.main_session.copy_file(SETTINGS.dpdk_tarball_path, self._remote_tmp_dir)<br>
>> + self.main_session.copy_to(SETTINGS.dpdk_tarball_path, self._remote_tmp_dir)<br>
>><br>
>> # construct remote tarball path<br>
>> # the basename is the same on local host and on remote Node<br>
>> @@ -259,7 +259,7 @@ def run_dpdk_app(<br>
>> Run DPDK application on the remote node.<br>
>> """<br>
>> return self.main_session.send_command(<br>
>> - f"{app_path} {eal_args}", timeout, verify=True<br>
>> + f"{app_path} {eal_args}", timeout, privileged=True, verify=True<br>
>> )<br>
>><br>
>><br>
>> diff --git a/dts/framework/utils.py b/dts/framework/utils.py<br>
>> index 55e0b0ef0e..8cfbc6a29d 100644<br>
>> --- a/dts/framework/utils.py<br>
>> +++ b/dts/framework/utils.py<br>
>> @@ -42,19 +42,10 @@ def expand_range(range_str: str) -> list[int]:<br>
>> return expanded_range<br>
>><br>
>><br>
>> -def GREEN(text: str) -> str:<br>
>> - return f"\u001B[32;1m{str(text)}\u001B[0m"<br>
>> -<br>
>> -<br>
>> def RED(text: str) -> str:<br>
>> return f"\u001B[31;1m{str(text)}\u001B[0m"<br>
>><br>
>><br>
>> -class EnvVarsDict(dict):<br>
>> - def __str__(self) -> str:<br>
>> - return " ".join(["=".join(item) for item in self.items()])<br>
>> -<br>
>> -<br>
>> class MesonArgs(object):<br>
>> """<br>
>> Aggregate the arguments needed to build DPDK:<br>
>> diff --git a/dts/poetry.lock b/dts/poetry.lock<br>
>> index 0b2a007d4d..2438f337cd 100644<br>
>> --- a/dts/poetry.lock<br>
>> +++ b/dts/poetry.lock<br>
>> @@ -12,6 +12,18 @@ docs = ["furo", "sphinx", "zope.interface", "sphinx-notfound-page"]<br>
>> tests = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "zope.interface", "cloudpickle"]<br>
>> tests_no_zope = ["coverage[toml] (>=5.0.2)", "hypothesis", "pympler", "pytest (>=4.3.0)", "mypy (>=0.900,!=0.940)", "pytest-mypy-plugins", "cloudpickle"]<br>
>><br>
>> +[[package]]<br>
>> +name = "bcrypt"<br>
>> +version = "4.0.1"<br>
>> +description = "Modern password hashing for your software and your servers"<br>
>> +category = "main"<br>
>> +optional = false<br>
>> +python-versions = ">=3.6"<br>
>> +<br>
>> +[package.extras]<br>
>> +tests = ["pytest (>=3.2.1,!=3.3.0)"]<br>
>> +typecheck = ["mypy"]<br>
>> +<br>
>> [[package]]<br>
>> name = "black"<br>
>> version = "22.10.0"<br>
>> @@ -33,6 +45,17 @@ d = ["aiohttp (>=3.7.4)"]<br>
>> jupyter = ["ipython (>=7.8.0)", "tokenize-rt (>=3.2.0)"]<br>
>> uvloop = ["uvloop (>=0.15.2)"]<br>
>><br>
>> +[[package]]<br>
>> +name = "cffi"<br>
>> +version = "1.15.1"<br>
>> +description = "Foreign Function Interface for Python calling C code."<br>
>> +category = "main"<br>
>> +optional = false<br>
>> +python-versions = "*"<br>
>> +<br>
>> +[package.dependencies]<br>
>> +pycparser = "*"<br>
>> +<br>
>> [[package]]<br>
>> name = "click"<br>
>> version = "8.1.3"<br>
>> @@ -52,6 +75,52 @@ category = "dev"<br>
>> optional = false<br>
>> python-versions = "!=3.0.*,!=3.1.*,!=3.2.*,!=3.3.*,!=3.4.*,!=3.5.*,!=3.6.*,>=2.7"<br>
>><br>
>> +[[package]]<br>
>> +name = "cryptography"<br>
>> +version = "40.0.2"<br>
>> +description = "cryptography is a package which provides cryptographic recipes and primitives to Python developers."<br>
>> +category = "main"<br>
>> +optional = false<br>
>> +python-versions = ">=3.6"<br>
>> +<br>
>> +[package.dependencies]<br>
>> +cffi = ">=1.12"<br>
>> +<br>
>> +[package.extras]<br>
>> +docs = ["sphinx (>=5.3.0)", "sphinx-rtd-theme (>=1.1.1)"]<br>
>> +docstest = ["pyenchant (>=1.6.11)", "twine (>=1.12.0)", "sphinxcontrib-spelling (>=4.0.1)"]<br>
>> +pep8test = ["black", "ruff", "mypy", "check-manifest"]<br>
>> +sdist = ["setuptools-rust (>=0.11.4)"]<br>
>> +ssh = ["bcrypt (>=3.1.5)"]<br>
>> +test = ["pytest (>=6.2.0)", "pytest-shard (>=0.1.2)", "pytest-benchmark", "pytest-cov", "pytest-subtests", "pytest-xdist", "pretend", "iso8601"]<br>
>> +test-randomorder = ["pytest-randomly"]<br>
>> +tox = ["tox"]<br>
>> +<br>
>> +[[package]]<br>
>> +name = "fabric"<br>
>> +version = "2.7.1"<br>
>> +description = "High level SSH command execution"<br>
>> +category = "main"<br>
>> +optional = false<br>
>> +python-versions = "*"<br>
>> +<br>
>> +[package.dependencies]<br>
>> +invoke = ">=1.3,<2.0"<br>
>> +paramiko = ">=2.4"<br>
>> +pathlib2 = "*"<br>
>> +<br>
>> +[package.extras]<br>
>> +pytest = ["mock (>=2.0.0,<3.0)", "pytest (>=3.2.5,<4.0)"]<br>
>> +testing = ["mock (>=2.0.0,<3.0)"]<br>
>> +<br>
>> +[[package]]<br>
>> +name = "invoke"<br>
>> +version = "1.7.3"<br>
>> +description = "Pythonic task execution"<br>
>> +category = "main"<br>
>> +optional = false<br>
>> +python-versions = "*"<br>
>> +<br>
>> [[package]]<br>
>> name = "isort"<br>
>> version = "5.10.1"<br>
>> @@ -136,23 +205,41 @@ optional = false<br>
>> python-versions = "*"<br>
>><br>
>> [[package]]<br>
>> -name = "pathspec"<br>
>> -version = "0.10.1"<br>
>> -description = "Utility library for gitignore style pattern matching of file paths."<br>
>> -category = "dev"<br>
>> +name = "paramiko"<br>
>> +version = "3.1.0"<br>
>> +description = "SSH2 protocol library"<br>
>> +category = "main"<br>
>> optional = false<br>
>> -python-versions = ">=3.7"<br>
>> +python-versions = ">=3.6"<br>
>> +<br>
>> +[package.dependencies]<br>
>> +bcrypt = ">=3.2"<br>
>> +cryptography = ">=3.3"<br>
>> +pynacl = ">=1.5"<br>
>> +<br>
>> +[package.extras]<br>
>> +all = ["pyasn1 (>=0.1.7)", "invoke (>=2.0)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]<br>
>> +gssapi = ["pyasn1 (>=0.1.7)", "gssapi (>=1.4.1)", "pywin32 (>=2.1.8)"]<br>
>> +invoke = ["invoke (>=2.0)"]<br>
>><br>
>> [[package]]<br>
>> -name = "pexpect"<br>
>> -version = "4.8.0"<br>
>> -description = "Pexpect allows easy control of interactive console applications."<br>
>> +name = "pathlib2"<br>
>> +version = "2.3.7.post1"<br>
>> +description = "Object-oriented filesystem paths"<br>
>> category = "main"<br>
>> optional = false<br>
>> python-versions = "*"<br>
>><br>
>> [package.dependencies]<br>
>> -ptyprocess = ">=0.5"<br>
>> +six = "*"<br>
>> +<br>
>> +[[package]]<br>
>> +name = "pathspec"<br>
>> +version = "0.10.1"<br>
>> +description = "Utility library for gitignore style pattern matching of file paths."<br>
>> +category = "dev"<br>
>> +optional = false<br>
>> +python-versions = ">=3.7"<br>
>><br>
>> [[package]]<br>
>> name = "platformdirs"<br>
>> @@ -166,14 +253,6 @@ python-versions = ">=3.7"<br>
>> docs = ["furo (>=2021.7.5b38)", "proselint (>=0.10.2)", "sphinx-autodoc-typehints (>=1.12)", "sphinx (>=4)"]<br>
>> test = ["appdirs (==1.4.4)", "pytest-cov (>=2.7)", "pytest-mock (>=3.6)", "pytest (>=6)"]<br>
>><br>
>> -[[package]]<br>
>> -name = "ptyprocess"<br>
>> -version = "0.7.0"<br>
>> -description = "Run a subprocess in a pseudo terminal"<br>
>> -category = "main"<br>
>> -optional = false<br>
>> -python-versions = "*"<br>
>> -<br>
>> [[package]]<br>
>> name = "pycodestyle"<br>
>> version = "2.9.1"<br>
>> @@ -182,6 +261,14 @@ category = "dev"<br>
>> optional = false<br>
>> python-versions = ">=3.6"<br>
>><br>
>> +[[package]]<br>
>> +name = "pycparser"<br>
>> +version = "2.21"<br>
>> +description = "C parser in Python"<br>
>> +category = "main"<br>
>> +optional = false<br>
>> +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*, !=3.3.*"<br>
>> +<br>
>> [[package]]<br>
>> name = "pydocstyle"<br>
>> version = "6.1.1"<br>
>> @@ -228,6 +315,21 @@ tests = ["pytest (>=7.1.2)", "pytest-mypy", "eradicate (>=2.0.0)", "radon (>=5.1<br>
>> toml = ["toml (>=0.10.2)"]<br>
>> vulture = ["vulture"]<br>
>><br>
>> +[[package]]<br>
>> +name = "pynacl"<br>
>> +version = "1.5.0"<br>
>> +description = "Python binding to the Networking and Cryptography (NaCl) library"<br>
>> +category = "main"<br>
>> +optional = false<br>
>> +python-versions = ">=3.6"<br>
>> +<br>
>> +[package.dependencies]<br>
>> +cffi = ">=1.4.1"<br>
>> +<br>
>> +[package.extras]<br>
>> +docs = ["sphinx (>=1.6.5)", "sphinx-rtd-theme"]<br>
>> +tests = ["pytest (>=3.2.1,!=3.3.0)", "hypothesis (>=3.27.0)"]<br>
>> +<br>
>> [[package]]<br>
>> name = "pyrsistent"<br>
>> version = "0.19.1"<br>
>> @@ -244,6 +346,14 @@ category = "main"<br>
>> optional = false<br>
>> python-versions = ">=3.6"<br>
>><br>
>> +[[package]]<br>
>> +name = "six"<br>
>> +version = "1.16.0"<br>
>> +description = "Python 2 and 3 compatibility utilities"<br>
>> +category = "main"<br>
>> +optional = false<br>
>> +python-versions = ">=2.7, !=3.0.*, !=3.1.*, !=3.2.*"<br>
>> +<br>
>> [[package]]<br>
>> name = "snowballstemmer"<br>
>> version = "2.2.0"<br>
>> @@ -299,13 +409,18 @@ jsonschema = ">=4,<5"<br>
>> [metadata]<br>
>> lock-version = "1.1"<br>
>> python-versions = "^3.10"<br>
>> -content-hash = "a0f040b07fc6ce4deb0be078b9a88c2a465cb6bccb9e260a67e92c2403e2319f"<br>
>> +content-hash = "719c43bcaa5d181921debda884f8f714063df0b2336d61e9f64ecab034e8b139"<br>
>><br>
>> [metadata.files]<br>
>> attrs = []<br>
>> +bcrypt = []<br>
>> black = []<br>
>> +cffi = []<br>
>> click = []<br>
>> colorama = []<br>
>> +cryptography = []<br>
>> +fabric = []<br>
>> +invoke = []<br>
>> isort = []<br>
>> jsonpatch = []<br>
>> jsonpointer = []<br>
>> @@ -313,22 +428,22 @@ jsonschema = []<br>
>> mccabe = []<br>
>> mypy = []<br>
>> mypy-extensions = []<br>
>> +paramiko = []<br>
>> +pathlib2 = []<br>
>> pathspec = []<br>
>> -pexpect = [<br>
>> - {file = "pexpect-4.8.0-py2.py3-none-any.whl", hash = "sha256:0b48a55dcb3c05f3329815901ea4fc1537514d6ba867a152b581d69ae3710937"},<br>
>> - {file = "pexpect-4.8.0.tar.gz", hash = "sha256:fc65a43959d153d0114afe13997d439c22823a27cefceb5ff35c2178c6784c0c"},<br>
>> -]<br>
>> platformdirs = [<br>
>> {file = "platformdirs-2.5.2-py3-none-any.whl", hash = "sha256:027d8e83a2d7de06bbac4e5ef7e023c02b863d7ea5d079477e722bb41ab25788"},<br>
>> {file = "platformdirs-2.5.2.tar.gz", hash = "sha256:58c8abb07dcb441e6ee4b11d8df0ac856038f944ab98b7be6b27b2a3c7feef19"},<br>
>> ]<br>
>> -ptyprocess = []<br>
>> pycodestyle = []<br>
>> +pycparser = []<br>
>> pydocstyle = []<br>
>> pyflakes = []<br>
>> pylama = []<br>
>> +pynacl = []<br>
>> pyrsistent = []<br>
>> pyyaml = []<br>
>> +six = []<br>
>> snowballstemmer = []<br>
>> toml = []<br>
>> tomli = []<br>
>> diff --git a/dts/pyproject.toml b/dts/pyproject.toml<br>
>> index a136c91e5e..50bcdb327a 100644<br>
>> --- a/dts/pyproject.toml<br>
>> +++ b/dts/pyproject.toml<br>
>> @@ -9,10 +9,10 @@ authors = ["Owen Hilyard <<a href="mailto:ohilyard@iol.unh.edu" target="_blank">ohilyard@iol.unh.edu</a>>", "<a href="mailto:dts@dpdk.org" target="_blank">dts@dpdk.org</a>"]<br>
>><br>
>> [tool.poetry.dependencies]<br>
>> python = "^3.10"<br>
>> -pexpect = "^4.8.0"<br>
>> warlock = "^2.0.1"<br>
>> PyYAML = "^6.0"<br>
>> types-PyYAML = "^6.0.8"<br>
>> +fabric = "^2.7.1"<br>
>><br>
>> [tool.poetry.dev-dependencies]<br>
>> mypy = "^0.961"<br>
>> --<br>
>> 2.30.2<br>
>><br></blockquote><div><br></div><div>Acked-by: Jeremy Spewock <<a href="mailto:jspewock@iol.unh.edu">jspewock@iol.unh.edu</a>> </div></div></div>