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