xref: /dpdk/dts/framework/remote_session/remote_session.py (revision e9fd1ebf981f361844aea9ec94e17f4bda5e1479)
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