From patchwork Thu Oct 13 10:35:13 2022 Content-Type: text/plain; charset="utf-8" MIME-Version: 1.0 Content-Transfer-Encoding: 8bit X-Patchwork-Submitter: =?utf-8?q?Juraj_Linke=C5=A1?= X-Patchwork-Id: 118132 X-Patchwork-Delegate: thomas@monjalon.net Return-Path: X-Original-To: patchwork@inbox.dpdk.org Delivered-To: patchwork@inbox.dpdk.org Received: from mails.dpdk.org (mails.dpdk.org [217.70.189.124]) by inbox.dpdk.org (Postfix) with ESMTP id CF27BA00C2; Thu, 13 Oct 2022 12:36:05 +0200 (CEST) Received: from [217.70.189.124] (localhost [127.0.0.1]) by mails.dpdk.org (Postfix) with ESMTP id 0599342EA4; Thu, 13 Oct 2022 12:35:31 +0200 (CEST) Received: from lb.pantheon.sk (lb.pantheon.sk [46.229.239.20]) by mails.dpdk.org (Postfix) with ESMTP id 8C9AB42E8B for ; Thu, 13 Oct 2022 12:35:27 +0200 (CEST) Received: from localhost (localhost [127.0.0.1]) by lb.pantheon.sk (Postfix) with ESMTP id C2EDF165604; Thu, 13 Oct 2022 12:35:26 +0200 (CEST) X-Virus-Scanned: amavisd-new at siecit.sk Received: from lb.pantheon.sk ([127.0.0.1]) by localhost (lb.pantheon.sk [127.0.0.1]) (amavisd-new, port 10024) with ESMTP id 7TrXl5eF4TLq; Thu, 13 Oct 2022 12:35:25 +0200 (CEST) Received: from entguard.lab.pantheon.local (unknown [46.229.239.141]) by lb.pantheon.sk (Postfix) with ESMTP id 53261165614; Thu, 13 Oct 2022 12:35:21 +0200 (CEST) From: =?utf-8?q?Juraj_Linke=C5=A1?= To: thomas@monjalon.net, Honnappa.Nagarahalli@arm.com, ohilyard@iol.unh.edu, lijuan.tu@intel.com, kda@semihalf.com, bruce.richardson@intel.com Cc: dev@dpdk.org, =?utf-8?q?Juraj_Linke=C5=A1?= Subject: [PATCH v6 06/10] dts: add ssh session module Date: Thu, 13 Oct 2022 10:35:13 +0000 Message-Id: <20221013103517.3443997-7-juraj.linkes@pantheon.tech> X-Mailer: git-send-email 2.25.1 In-Reply-To: <20221013103517.3443997-1-juraj.linkes@pantheon.tech> References: <20221013103517.3443997-1-juraj.linkes@pantheon.tech> MIME-Version: 1.0 X-BeenThere: dev@dpdk.org X-Mailman-Version: 2.1.29 Precedence: list List-Id: DPDK patches and discussions List-Unsubscribe: , List-Archive: List-Post: List-Help: List-Subscribe: , Errors-To: dev-bounces@dpdk.org The module uses the pexpect python library and implements connection to a node and two ways to interact with the node: 1. Send a string with specified prompt which will be matched after the string has been sent to the node. 2. Send a command to be executed. No prompt is specified here. Signed-off-by: Owen Hilyard Signed-off-by: Juraj Linkeš --- dts/framework/exception.py | 57 ++++++ dts/framework/remote_session/__init__.py | 10 + .../remote_session/remote_session.py | 40 ++-- dts/framework/remote_session/ssh_session.py | 185 ++++++++++++++++++ dts/framework/utils.py | 13 ++ 5 files changed, 285 insertions(+), 20 deletions(-) create mode 100644 dts/framework/exception.py create mode 100644 dts/framework/remote_session/ssh_session.py create mode 100644 dts/framework/utils.py diff --git a/dts/framework/exception.py b/dts/framework/exception.py new file mode 100644 index 0000000000..8bff9cf9f6 --- /dev/null +++ b/dts/framework/exception.py @@ -0,0 +1,57 @@ +# 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 +# + +""" +User-defined exceptions used across the framework. +""" + + +class SSHTimeoutError(Exception): + """ + Command execution timeout. + """ + + command: str + output: str + + def __init__(self, command: str, output: str): + self.command = command + self.output = output + + def __str__(self) -> str: + return f"TIMEOUT on {self.command}" + + def get_output(self) -> str: + return self.output + + +class SSHConnectionError(Exception): + """ + SSH connection error. + """ + + host: str + + def __init__(self, host: str): + self.host = host + + def __str__(self) -> str: + return f"Error trying to connect with {self.host}" + + +class SSHSessionDeadError(Exception): + """ + SSH session is not alive. + It can no longer be used. + """ + + host: str + + def __init__(self, host: str): + self.host = host + + def __str__(self) -> str: + return f"SSH session with {self.host} has died" diff --git a/dts/framework/remote_session/__init__.py b/dts/framework/remote_session/__init__.py index d924d8aaa9..d7478e6800 100644 --- a/dts/framework/remote_session/__init__.py +++ b/dts/framework/remote_session/__init__.py @@ -2,4 +2,14 @@ # 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/remote_session.py b/dts/framework/remote_session/remote_session.py index 7c499c32e3..eaa4fa7a42 100644 --- a/dts/framework/remote_session/remote_session.py +++ b/dts/framework/remote_session/remote_session.py @@ -55,6 +55,13 @@ def __init__( self._connect() self.logger.info(f"Connection to {self.username}@{self.hostname} successful.") + @abstractmethod + def _connect(self) -> None: + """ + Create connection to assigned node. + """ + pass + def send_command(self, command: str, timeout: float = SETTINGS.timeout) -> str: self.logger.info(f"Sending: {command}") out = self._send_command(command, timeout) @@ -62,39 +69,32 @@ def send_command(self, command: str, timeout: float = SETTINGS.timeout) -> str: self._history_add(command=command, output=out) return out - def close(self, force: bool = False) -> None: - self.logger.logger_exit() - self._close(force) + @abstractmethod + def _send_command(self, command: str, timeout: float) -> str: + """ + Send a command and return the output. + """ + pass def _history_add(self, command: str, output: str) -> None: self.history.append( HistoryRecord(name=self.name, command=command, output=output) ) - @abstractmethod - def is_alive(self) -> bool: - """ - Check whether the session is still responding. - """ - pass - - @abstractmethod - def _connect(self) -> None: - """ - Create connection to assigned node. - """ - pass + def close(self, force: bool = False) -> None: + self.logger.logger_exit() + self._close(force) @abstractmethod - def _send_command(self, command: str, timeout: float) -> str: + def _close(self, force: bool = False) -> None: """ - Send a command and return the output. + Close the remote session, freeing all used resources. """ pass @abstractmethod - def _close(self, force: bool = False) -> None: + def is_alive(self) -> bool: """ - Close the remote session, freeing all used resources. + Check whether the session is still responding. """ pass diff --git a/dts/framework/remote_session/ssh_session.py b/dts/framework/remote_session/ssh_session.py new file mode 100644 index 0000000000..f71acfb1ca --- /dev/null +++ b/dts/framework/remote_session/ssh_session.py @@ -0,0 +1,185 @@ +# 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 +# + +import time + +from pexpect import pxssh + +from framework.config import NodeConfiguration +from framework.exception import SSHConnectionError, SSHSessionDeadError, SSHTimeoutError +from framework.logger import DTSLOG +from framework.utils import GREEN, RED + +from .remote_session import RemoteSession + + +class SSHSession(RemoteSession): + """ + Module for creating Pexpect SSH sessions to a node. + """ + + session: pxssh.pxssh + magic_prompt: str + + def __init__( + self, + node_config: NodeConfiguration, + session_name: str, + logger: DTSLOG, + ): + self.magic_prompt = "MAGIC PROMPT" + super(SSHSession, self).__init__(node_config, session_name, logger) + + def _connect(self) -> None: + """ + Create connection to assigned node. + """ + retry_attempts = 10 + login_timeout = 20 if self.port else 10 + password_regex = ( + r"(?i)(?:password:)|(?:passphrase for key)|(?i)(password for .+:)" + ) + try: + for retry_attempt in range(retry_attempts): + self.session = pxssh.pxssh(encoding="utf-8") + try: + self.session.login( + self.ip, + self.username, + self.password, + original_prompt="[$#>]", + port=self.port, + login_timeout=login_timeout, + password_regex=password_regex, + ) + break + except Exception as e: + self.logger.warning(e) + time.sleep(2) + self.logger.info( + f"Retrying connection: retry number {retry_attempt + 1}." + ) + else: + raise Exception(f"Connection to {self.hostname} failed") + + self.send_expect("stty -echo", "#") + self.send_expect("stty columns 1000", "#") + except Exception as e: + self.logger.error(RED(str(e))) + if getattr(self, "port", None): + suggestion = ( + f"\nSuggestion: Check if the firewall on {self.hostname} is " + f"stopped.\n" + ) + self.logger.info(GREEN(suggestion)) + + raise SSHConnectionError(self.hostname) + + def send_expect( + self, command: str, prompt: str, timeout: float = 15, verify: bool = False + ) -> str | int: + try: + ret = self.send_expect_base(command, prompt, timeout) + if verify: + ret_status = self.send_expect_base("echo $?", prompt, timeout) + try: + retval = int(ret_status) + if retval: + self.logger.error(f"Command: {command} failure!") + self.logger.error(ret) + return retval + else: + return ret + except ValueError: + return ret + else: + return ret + except Exception as e: + self.logger.error( + f"Exception happened in [{command}] and output is " + f"[{self._get_output()}]" + ) + raise e + + def send_expect_base(self, command: str, prompt: str, timeout: float) -> str: + self._clean_session() + original_prompt = self.session.PROMPT + self.session.PROMPT = prompt + self._send_line(command) + self._prompt(command, timeout) + + before = self._get_output() + self.session.PROMPT = original_prompt + return before + + def _clean_session(self) -> None: + self.get_output(timeout=0.01) + + def _send_line(self, command: str) -> None: + if not self.is_alive(): + raise SSHSessionDeadError(self.hostname) + if len(command) == 2 and command.startswith("^"): + self.session.sendcontrol(command[1]) + else: + self.session.sendline(command) + + def _prompt(self, command: str, timeout: float) -> None: + if not self.session.prompt(timeout): + raise SSHTimeoutError(command, self._get_output()) from None + + def get_output(self, timeout: float = 15) -> str: + """ + Get all output before timeout + """ + self.session.PROMPT = self.magic_prompt + try: + self.session.prompt(timeout) + except Exception: + pass + + before = self._get_output() + self._flush() + + self.logger.debug(before) + return before + + def _get_output(self) -> str: + if not self.is_alive(): + raise SSHSessionDeadError(self.hostname) + before = self.session.before.rsplit("\r\n", 1)[0] + if before == "[PEXPECT]": + return "" + return before + + def _flush(self) -> None: + """ + Clear all session buffer + """ + self.session.buffer = "" + self.session.before = "" + + def is_alive(self) -> bool: + return self.session.isalive() + + def _send_command(self, command: str, timeout: float) -> str: + try: + self._clean_session() + self._send_line(command) + except Exception as e: + raise e + + output = self.get_output(timeout=timeout) + self.session.PROMPT = self.session.UNIQUE_PROMPT + self.session.prompt(0.1) + + return output + + def _close(self, force: bool = False) -> None: + if force is True: + self.session.close() + else: + if self.is_alive(): + self.session.logout() diff --git a/dts/framework/utils.py b/dts/framework/utils.py new file mode 100644 index 0000000000..fe13ae5e77 --- /dev/null +++ b/dts/framework/utils.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. +# Copyright(c) 2022 University of New Hampshire +# + + +def GREEN(text: str) -> str: + return f"\u001B[32;1m{str(text)}\u001B[0m" + + +def RED(text: str) -> str: + return f"\u001B[31;1m{str(text)}\u001B[0m"