1# SPDX-License-Identifier: BSD-3-Clause 2# Copyright(c) 2010-2014 Intel Corporation 3# Copyright(c) 2022-2023 PANTHEON.tech s.r.o. 4# Copyright(c) 2022-2023 University of New Hampshire 5 6"""Base remote session. 7 8This module contains the abstract base class for remote sessions and defines 9the structure of the result of a command execution. 10""" 11 12 13import dataclasses 14from abc import ABC, abstractmethod 15from pathlib import PurePath 16 17from framework.config import NodeConfiguration 18from framework.exception import RemoteCommandExecutionError 19from framework.logger import DTSLOG 20from framework.settings import SETTINGS 21 22 23@dataclasses.dataclass(slots=True, frozen=True) 24class CommandResult: 25 """The result of remote execution of a command. 26 27 Attributes: 28 name: The name of the session that executed the command. 29 command: The executed command. 30 stdout: The standard output the command produced. 31 stderr: The standard error output the command produced. 32 return_code: The return code the command exited with. 33 """ 34 35 name: str 36 command: str 37 stdout: str 38 stderr: str 39 return_code: int 40 41 def __str__(self) -> str: 42 """Format the command outputs.""" 43 return ( 44 f"stdout: '{self.stdout}'\n" 45 f"stderr: '{self.stderr}'\n" 46 f"return_code: '{self.return_code}'" 47 ) 48 49 50class RemoteSession(ABC): 51 """Non-interactive remote session. 52 53 The abstract methods must be implemented in order to connect to a remote host (node) 54 and maintain a remote session. 55 The subclasses must use (or implement) some underlying transport protocol (e.g. SSH) 56 to implement the methods. On top of that, it provides some basic services common to all 57 subclasses, such as keeping history and logging what's being executed on the remote node. 58 59 Attributes: 60 name: The name of the session. 61 hostname: The node's hostname. Could be an IP (possibly with port, separated by a colon) 62 or a domain name. 63 ip: The IP address of the node or a domain name, whichever was used in `hostname`. 64 port: The port of the node, if given in `hostname`. 65 username: The username used in the connection. 66 password: The password used in the connection. Most frequently empty, 67 as the use of passwords is discouraged. 68 history: The executed commands during this session. 69 """ 70 71 name: str 72 hostname: str 73 ip: str 74 port: int | None 75 username: str 76 password: str 77 history: list[CommandResult] 78 _logger: DTSLOG 79 _node_config: NodeConfiguration 80 81 def __init__( 82 self, 83 node_config: NodeConfiguration, 84 session_name: str, 85 logger: DTSLOG, 86 ): 87 """Connect to the node during initialization. 88 89 Args: 90 node_config: The test run configuration of the node to connect to. 91 session_name: The name of the session. 92 logger: The logger instance this session will use. 93 94 Raises: 95 SSHConnectionError: If the connection to the node was not successful. 96 """ 97 self._node_config = node_config 98 99 self.name = session_name 100 self.hostname = node_config.hostname 101 self.ip = self.hostname 102 self.port = None 103 if ":" in self.hostname: 104 self.ip, port = self.hostname.split(":") 105 self.port = int(port) 106 self.username = node_config.user 107 self.password = node_config.password or "" 108 self.history = [] 109 110 self._logger = logger 111 self._logger.info(f"Connecting to {self.username}@{self.hostname}.") 112 self._connect() 113 self._logger.info(f"Connection to {self.username}@{self.hostname} successful.") 114 115 @abstractmethod 116 def _connect(self) -> None: 117 """Create a connection to the node. 118 119 The implementation must assign the established session to self.session. 120 121 The implementation must except all exceptions and convert them to an SSHConnectionError. 122 123 The implementation may optionally implement retry attempts. 124 """ 125 126 def send_command( 127 self, 128 command: str, 129 timeout: float = SETTINGS.timeout, 130 verify: bool = False, 131 env: dict | None = None, 132 ) -> CommandResult: 133 """Send `command` to the connected node. 134 135 The :option:`--timeout` command line argument and the :envvar:`DTS_TIMEOUT` 136 environment variable configure the timeout of command execution. 137 138 Args: 139 command: The command to execute. 140 timeout: Wait at most this long in seconds for `command` execution to complete. 141 verify: If :data:`True`, will check the exit code of `command`. 142 env: A dictionary with environment variables to be used with `command` execution. 143 144 Raises: 145 SSHSessionDeadError: If the session isn't alive when sending `command`. 146 SSHTimeoutError: If `command` execution timed out. 147 RemoteCommandExecutionError: If verify is :data:`True` and `command` execution failed. 148 149 Returns: 150 The output of the command along with the return code. 151 """ 152 self._logger.info(f"Sending: '{command}'" + (f" with env vars: '{env}'" if env else "")) 153 result = self._send_command(command, timeout, env) 154 if verify and result.return_code: 155 self._logger.debug( 156 f"Command '{command}' failed with return code '{result.return_code}'" 157 ) 158 self._logger.debug(f"stdout: '{result.stdout}'") 159 self._logger.debug(f"stderr: '{result.stderr}'") 160 raise RemoteCommandExecutionError(command, result.return_code) 161 self._logger.debug(f"Received from '{command}':\n{result}") 162 self.history.append(result) 163 return result 164 165 @abstractmethod 166 def _send_command(self, command: str, timeout: float, env: dict | None) -> CommandResult: 167 """Send a command to the connected node. 168 169 The implementation must execute the command remotely with `env` environment variables 170 and return the result. 171 172 The implementation must except all exceptions and raise: 173 174 * SSHSessionDeadError if the session is not alive, 175 * SSHTimeoutError if the command execution times out. 176 """ 177 178 def close(self, force: bool = False) -> None: 179 """Close the remote session and free all used resources. 180 181 Args: 182 force: Force the closure of the connection. This may not clean up all resources. 183 """ 184 self._logger.logger_exit() 185 self._close(force) 186 187 @abstractmethod 188 def _close(self, force: bool = False) -> None: 189 """Protocol specific steps needed to close the session properly. 190 191 Args: 192 force: Force the closure of the connection. This may not clean up all resources. 193 This doesn't have to be implemented in the overloaded method. 194 """ 195 196 @abstractmethod 197 def is_alive(self) -> bool: 198 """Check whether the remote session is still responding.""" 199 200 @abstractmethod 201 def copy_from( 202 self, 203 source_file: str | PurePath, 204 destination_file: str | PurePath, 205 ) -> None: 206 """Copy a file from the remote Node to the local filesystem. 207 208 Copy `source_file` from the remote Node associated with this remote session 209 to `destination_file` on the local filesystem. 210 211 Args: 212 source_file: The file on the remote Node. 213 destination_file: A file or directory path on the local filesystem. 214 """ 215 216 @abstractmethod 217 def copy_to( 218 self, 219 source_file: str | PurePath, 220 destination_file: str | PurePath, 221 ) -> None: 222 """Copy a file from local filesystem to the remote Node. 223 224 Copy `source_file` from local filesystem to `destination_file` on the remote Node 225 associated with this remote session. 226 227 Args: 228 source_file: The file on the local filesystem. 229 destination_file: A file or directory path on the remote Node. 230 """ 231