xref: /dpdk/dts/framework/remote_session/single_active_interactive_shell.py (revision 99740300890620065d06277811c81b33cd302d39)
12b648cd4SJeremy Spewock# SPDX-License-Identifier: BSD-3-Clause
22b648cd4SJeremy Spewock# Copyright(c) 2024 University of New Hampshire
32b648cd4SJeremy Spewock
42b648cd4SJeremy Spewock"""Common functionality for interactive shell handling.
52b648cd4SJeremy Spewock
62b648cd4SJeremy SpewockThe base class, :class:`SingleActiveInteractiveShell`, is meant to be extended by subclasses that
72b648cd4SJeremy Spewockcontain functionality specific to that shell type. These subclasses will often modify things like
82b648cd4SJeremy Spewockthe prompt to expect or the arguments to pass into the application, but still utilize
92b648cd4SJeremy Spewockthe same method for sending a command and collecting output. How this output is handled however
102b648cd4SJeremy Spewockis often application specific. If an application needs elevated privileges to start it is expected
112b648cd4SJeremy Spewockthat the method for gaining those privileges is provided when initializing the class.
122b648cd4SJeremy Spewock
132b648cd4SJeremy SpewockThis class is designed for applications like primary applications in DPDK where only one instance
142b648cd4SJeremy Spewockof the application can be running at a given time and, for this reason, is managed using a context
152b648cd4SJeremy Spewockmanager. This context manager starts the application when you enter the context and cleans up the
162b648cd4SJeremy Spewockapplication when you exit. Using a context manager for this is useful since it allows us to ensure
172b648cd4SJeremy Spewockthe application is cleaned up as soon as you leave the block regardless of the reason.
182b648cd4SJeremy Spewock
192b648cd4SJeremy SpewockThe :option:`--timeout` command line argument and the :envvar:`DTS_TIMEOUT`
202b648cd4SJeremy Spewockenvironment variable configure the timeout of getting the output from command execution.
212b648cd4SJeremy Spewock"""
222b648cd4SJeremy Spewock
232b648cd4SJeremy Spewockfrom abc import ABC
242b648cd4SJeremy Spewockfrom pathlib import PurePath
252b648cd4SJeremy Spewockfrom typing import ClassVar
262b648cd4SJeremy Spewock
272b648cd4SJeremy Spewockfrom paramiko import Channel, channel  # type: ignore[import-untyped]
282b648cd4SJeremy Spewockfrom typing_extensions import Self
292b648cd4SJeremy Spewock
306713e286SJeremy Spewockfrom framework.exception import (
316713e286SJeremy Spewock    InteractiveCommandExecutionError,
326713e286SJeremy Spewock    InteractiveSSHSessionDeadError,
336713e286SJeremy Spewock    InteractiveSSHTimeoutError,
346713e286SJeremy Spewock)
3565a1b4e8SJeremy Spewockfrom framework.logger import DTSLogger, get_dts_logger
362b648cd4SJeremy Spewockfrom framework.params import Params
372b648cd4SJeremy Spewockfrom framework.settings import SETTINGS
382b648cd4SJeremy Spewockfrom framework.testbed_model.node import Node
39*99740300SJeremy Spewockfrom framework.utils import MultiInheritanceBaseClass
402b648cd4SJeremy Spewock
412b648cd4SJeremy Spewock
42*99740300SJeremy Spewockclass SingleActiveInteractiveShell(MultiInheritanceBaseClass, ABC):
432b648cd4SJeremy Spewock    """The base class for managing interactive shells.
442b648cd4SJeremy Spewock
452b648cd4SJeremy Spewock    This class shouldn't be instantiated directly, but instead be extended. It contains
462b648cd4SJeremy Spewock    methods for starting interactive shells as well as sending commands to these shells
472b648cd4SJeremy Spewock    and collecting input until reaching a certain prompt. All interactive applications
482b648cd4SJeremy Spewock    will use the same SSH connection, but each will create their own channel on that
492b648cd4SJeremy Spewock    session.
502b648cd4SJeremy Spewock
512b648cd4SJeremy Spewock    Interactive shells are started and stopped using a context manager. This allows for the start
522b648cd4SJeremy Spewock    and cleanup of the application to happen at predictable times regardless of exceptions or
532b648cd4SJeremy Spewock    interrupts.
5492439dc9SJeremy Spewock
5592439dc9SJeremy Spewock    Attributes:
5692439dc9SJeremy Spewock        is_alive: :data:`True` if the application has started successfully, :data:`False`
5792439dc9SJeremy Spewock            otherwise.
582b648cd4SJeremy Spewock    """
592b648cd4SJeremy Spewock
602b648cd4SJeremy Spewock    _node: Node
612b648cd4SJeremy Spewock    _stdin: channel.ChannelStdinFile
622b648cd4SJeremy Spewock    _stdout: channel.ChannelFile
632b648cd4SJeremy Spewock    _ssh_channel: Channel
642b648cd4SJeremy Spewock    _logger: DTSLogger
652b648cd4SJeremy Spewock    _timeout: float
662b648cd4SJeremy Spewock    _app_params: Params
672b648cd4SJeremy Spewock    _privileged: bool
682b648cd4SJeremy Spewock    _real_path: PurePath
692b648cd4SJeremy Spewock
7092439dc9SJeremy Spewock    #: The number of times to try starting the application before considering it a failure.
7192439dc9SJeremy Spewock    _init_attempts: ClassVar[int] = 5
7292439dc9SJeremy Spewock
732b648cd4SJeremy Spewock    #: Prompt to expect at the end of output when sending a command.
742b648cd4SJeremy Spewock    #: This is often overridden by subclasses.
752b648cd4SJeremy Spewock    _default_prompt: ClassVar[str] = ""
762b648cd4SJeremy Spewock
772b648cd4SJeremy Spewock    #: Extra characters to add to the end of every command
782b648cd4SJeremy Spewock    #: before sending them. This is often overridden by subclasses and is
796713e286SJeremy Spewock    #: most commonly an additional newline character. This additional newline
806713e286SJeremy Spewock    #: character is used to force the line that is currently awaiting input
816713e286SJeremy Spewock    #: into the stdout buffer so that it can be consumed and checked against
826713e286SJeremy Spewock    #: the expected prompt.
832b648cd4SJeremy Spewock    _command_extra_chars: ClassVar[str] = ""
842b648cd4SJeremy Spewock
852b648cd4SJeremy Spewock    #: Path to the executable to start the interactive application.
862b648cd4SJeremy Spewock    path: ClassVar[PurePath]
872b648cd4SJeremy Spewock
8892439dc9SJeremy Spewock    is_alive: bool = False
8992439dc9SJeremy Spewock
902b648cd4SJeremy Spewock    def __init__(
912b648cd4SJeremy Spewock        self,
922b648cd4SJeremy Spewock        node: Node,
932b648cd4SJeremy Spewock        privileged: bool = False,
942b648cd4SJeremy Spewock        timeout: float = SETTINGS.timeout,
952b648cd4SJeremy Spewock        app_params: Params = Params(),
9665a1b4e8SJeremy Spewock        name: str | None = None,
97*99740300SJeremy Spewock        **kwargs,
982b648cd4SJeremy Spewock    ) -> None:
992b648cd4SJeremy Spewock        """Create an SSH channel during initialization.
1002b648cd4SJeremy Spewock
101*99740300SJeremy Spewock        Additional keyword arguments can be passed through `kwargs` if needed for fulfilling other
102*99740300SJeremy Spewock        constructors in the case of multiple inheritance.
103*99740300SJeremy Spewock
1042b648cd4SJeremy Spewock        Args:
1052b648cd4SJeremy Spewock            node: The node on which to run start the interactive shell.
1062b648cd4SJeremy Spewock            privileged: Enables the shell to run as superuser.
1072b648cd4SJeremy Spewock            timeout: The timeout used for the SSH channel that is dedicated to this interactive
1082b648cd4SJeremy Spewock                shell. This timeout is for collecting output, so if reading from the buffer
1092b648cd4SJeremy Spewock                and no output is gathered within the timeout, an exception is thrown.
1102b648cd4SJeremy Spewock            app_params: The command line parameters to be passed to the application on startup.
11165a1b4e8SJeremy Spewock            name: Name for the interactive shell to use for logging. This name will be appended to
11265a1b4e8SJeremy Spewock                the name of the underlying node which it is running on.
1132b648cd4SJeremy Spewock        """
1142b648cd4SJeremy Spewock        self._node = node
11565a1b4e8SJeremy Spewock        if name is None:
11665a1b4e8SJeremy Spewock            name = type(self).__name__
11765a1b4e8SJeremy Spewock        self._logger = get_dts_logger(f"{node.name}.{name}")
1182b648cd4SJeremy Spewock        self._app_params = app_params
1192b648cd4SJeremy Spewock        self._privileged = privileged
1202b648cd4SJeremy Spewock        self._timeout = timeout
1212b648cd4SJeremy Spewock        # Ensure path is properly formatted for the host
1222b648cd4SJeremy Spewock        self._update_real_path(self.path)
123*99740300SJeremy Spewock        super().__init__(node, **kwargs)
1242b648cd4SJeremy Spewock
1252b648cd4SJeremy Spewock    def _setup_ssh_channel(self):
1262b648cd4SJeremy Spewock        self._ssh_channel = self._node.main_session.interactive_session.session.invoke_shell()
1272b648cd4SJeremy Spewock        self._stdin = self._ssh_channel.makefile_stdin("w")
1282b648cd4SJeremy Spewock        self._stdout = self._ssh_channel.makefile("r")
1292b648cd4SJeremy Spewock        self._ssh_channel.settimeout(self._timeout)
1302b648cd4SJeremy Spewock        self._ssh_channel.set_combine_stderr(True)  # combines stdout and stderr streams
1312b648cd4SJeremy Spewock
1322b648cd4SJeremy Spewock    def _make_start_command(self) -> str:
1332b648cd4SJeremy Spewock        """Makes the command that starts the interactive shell."""
1342b648cd4SJeremy Spewock        start_command = f"{self._real_path} {self._app_params or ''}"
1352b648cd4SJeremy Spewock        if self._privileged:
1362b648cd4SJeremy Spewock            start_command = self._node.main_session._get_privileged_command(start_command)
1372b648cd4SJeremy Spewock        return start_command
1382b648cd4SJeremy Spewock
1392b648cd4SJeremy Spewock    def _start_application(self) -> None:
1402b648cd4SJeremy Spewock        """Starts a new interactive application based on the path to the app.
1412b648cd4SJeremy Spewock
14292439dc9SJeremy Spewock        This method is often overridden by subclasses as their process for starting may look
14392439dc9SJeremy Spewock        different. Initialization of the shell on the host can be retried up to
14492439dc9SJeremy Spewock        `self._init_attempts` - 1 times. This is done because some DPDK applications need slightly
14592439dc9SJeremy Spewock        more time after exiting their script to clean up EAL before others can start.
14692439dc9SJeremy Spewock
14792439dc9SJeremy Spewock        Raises:
14892439dc9SJeremy Spewock            InteractiveCommandExecutionError: If the application fails to start within the allotted
14992439dc9SJeremy Spewock                number of retries.
1502b648cd4SJeremy Spewock        """
1512b648cd4SJeremy Spewock        self._setup_ssh_channel()
15292439dc9SJeremy Spewock        self._ssh_channel.settimeout(5)
15392439dc9SJeremy Spewock        start_command = self._make_start_command()
15492439dc9SJeremy Spewock        self.is_alive = True
15592439dc9SJeremy Spewock        for attempt in range(self._init_attempts):
15692439dc9SJeremy Spewock            try:
15792439dc9SJeremy Spewock                self.send_command(start_command)
15892439dc9SJeremy Spewock                break
1596713e286SJeremy Spewock            except InteractiveSSHTimeoutError:
16092439dc9SJeremy Spewock                self._logger.info(
16192439dc9SJeremy Spewock                    f"Interactive shell failed to start (attempt {attempt+1} out of "
16292439dc9SJeremy Spewock                    f"{self._init_attempts})"
16392439dc9SJeremy Spewock                )
16492439dc9SJeremy Spewock        else:
16592439dc9SJeremy Spewock            self._ssh_channel.settimeout(self._timeout)
16692439dc9SJeremy Spewock            self.is_alive = False  # update state on failure to start
16792439dc9SJeremy Spewock            raise InteractiveCommandExecutionError("Failed to start application.")
16892439dc9SJeremy Spewock        self._ssh_channel.settimeout(self._timeout)
1692b648cd4SJeremy Spewock
1702b648cd4SJeremy Spewock    def send_command(
1712b648cd4SJeremy Spewock        self, command: str, prompt: str | None = None, skip_first_line: bool = False
1722b648cd4SJeremy Spewock    ) -> str:
1732b648cd4SJeremy Spewock        """Send `command` and get all output before the expected ending string.
1742b648cd4SJeremy Spewock
1752b648cd4SJeremy Spewock        Lines that expect input are not included in the stdout buffer, so they cannot
1762b648cd4SJeremy Spewock        be used for expect.
1772b648cd4SJeremy Spewock
1782b648cd4SJeremy Spewock        Example:
1792b648cd4SJeremy Spewock            If you were prompted to log into something with a username and password,
1802b648cd4SJeremy Spewock            you cannot expect ``username:`` because it won't yet be in the stdout buffer.
1812b648cd4SJeremy Spewock            A workaround for this could be consuming an extra newline character to force
1822b648cd4SJeremy Spewock            the current `prompt` into the stdout buffer.
1832b648cd4SJeremy Spewock
1842b648cd4SJeremy Spewock        Args:
1852b648cd4SJeremy Spewock            command: The command to send.
1862b648cd4SJeremy Spewock            prompt: After sending the command, `send_command` will be expecting this string.
1872b648cd4SJeremy Spewock                If :data:`None`, will use the class's default prompt.
1882b648cd4SJeremy Spewock            skip_first_line: Skip the first line when capturing the output.
1892b648cd4SJeremy Spewock
1902b648cd4SJeremy Spewock        Returns:
1912b648cd4SJeremy Spewock            All output in the buffer before expected string.
19292439dc9SJeremy Spewock
19392439dc9SJeremy Spewock        Raises:
19492439dc9SJeremy Spewock            InteractiveCommandExecutionError: If attempting to send a command to a shell that is
19592439dc9SJeremy Spewock                not currently running.
1966713e286SJeremy Spewock            InteractiveSSHSessionDeadError: The session died while executing the command.
1976713e286SJeremy Spewock            InteractiveSSHTimeoutError: If command was sent but prompt could not be found in
1986713e286SJeremy Spewock                the output before the timeout.
1992b648cd4SJeremy Spewock        """
20092439dc9SJeremy Spewock        if not self.is_alive:
20192439dc9SJeremy Spewock            raise InteractiveCommandExecutionError(
20292439dc9SJeremy Spewock                f"Cannot send command {command} to application because the shell is not running."
20392439dc9SJeremy Spewock            )
2042b648cd4SJeremy Spewock        self._logger.info(f"Sending: '{command}'")
2052b648cd4SJeremy Spewock        if prompt is None:
2062b648cd4SJeremy Spewock            prompt = self._default_prompt
2076713e286SJeremy Spewock        out: str = ""
2086713e286SJeremy Spewock        try:
2092b648cd4SJeremy Spewock            self._stdin.write(f"{command}{self._command_extra_chars}\n")
2102b648cd4SJeremy Spewock            self._stdin.flush()
2112b648cd4SJeremy Spewock            for line in self._stdout:
2122b648cd4SJeremy Spewock                if skip_first_line:
2132b648cd4SJeremy Spewock                    skip_first_line = False
2142b648cd4SJeremy Spewock                    continue
2156713e286SJeremy Spewock                if line.rstrip().endswith(prompt):
2162b648cd4SJeremy Spewock                    break
2172b648cd4SJeremy Spewock                out += line
2186713e286SJeremy Spewock        except TimeoutError as e:
2196713e286SJeremy Spewock            self._logger.exception(e)
2206713e286SJeremy Spewock            self._logger.debug(
2216713e286SJeremy Spewock                f"Prompt ({prompt}) was not found in output from command before timeout."
2226713e286SJeremy Spewock            )
2236713e286SJeremy Spewock            raise InteractiveSSHTimeoutError(command) from e
2246713e286SJeremy Spewock        except OSError as e:
2256713e286SJeremy Spewock            self._logger.exception(e)
2266713e286SJeremy Spewock            raise InteractiveSSHSessionDeadError(
2276713e286SJeremy Spewock                self._node.main_session.interactive_session.hostname
2286713e286SJeremy Spewock            ) from e
2296713e286SJeremy Spewock        finally:
2302b648cd4SJeremy Spewock            self._logger.debug(f"Got output: {out}")
2312b648cd4SJeremy Spewock        return out
2322b648cd4SJeremy Spewock
2332b648cd4SJeremy Spewock    def _close(self) -> None:
2342b648cd4SJeremy Spewock        self._stdin.close()
2352b648cd4SJeremy Spewock        self._ssh_channel.close()
2362b648cd4SJeremy Spewock
2372b648cd4SJeremy Spewock    def _update_real_path(self, path: PurePath) -> None:
2382b648cd4SJeremy Spewock        """Updates the interactive shell's real path used at command line."""
2392b648cd4SJeremy Spewock        self._real_path = self._node.main_session.join_remote_path(path)
2402b648cd4SJeremy Spewock
2412b648cd4SJeremy Spewock    def __enter__(self) -> Self:
2422b648cd4SJeremy Spewock        """Enter the context block.
2432b648cd4SJeremy Spewock
2442b648cd4SJeremy Spewock        Upon entering a context block with this class, the desired behavior is to create the
2452b648cd4SJeremy Spewock        channel for the application to use, and then start the application.
2462b648cd4SJeremy Spewock
2472b648cd4SJeremy Spewock        Returns:
2482b648cd4SJeremy Spewock            Reference to the object for the application after it has been started.
2492b648cd4SJeremy Spewock        """
2502b648cd4SJeremy Spewock        self._start_application()
2512b648cd4SJeremy Spewock        return self
2522b648cd4SJeremy Spewock
2532b648cd4SJeremy Spewock    def __exit__(self, *_) -> None:
2542b648cd4SJeremy Spewock        """Exit the context block.
2552b648cd4SJeremy Spewock
2562b648cd4SJeremy Spewock        Upon exiting a context block with this class, we want to ensure that the instance of the
2572b648cd4SJeremy Spewock        application is explicitly closed and properly cleaned up using its close method. Note that
2582b648cd4SJeremy Spewock        because this method returns :data:`None` if an exception was raised within the block, it is
2592b648cd4SJeremy Spewock        not handled and will be re-raised after the application is closed.
2602b648cd4SJeremy Spewock
2612b648cd4SJeremy Spewock        The desired behavior is to close the application regardless of the reason for exiting the
2622b648cd4SJeremy Spewock        context and then recreate that reason afterwards. All method arguments are ignored for
2632b648cd4SJeremy Spewock        this reason.
2642b648cd4SJeremy Spewock        """
2652b648cd4SJeremy Spewock        self._close()
266