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