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