1840b1e01SJuraj Linkeš# SPDX-License-Identifier: BSD-3-Clause 2840b1e01SJuraj Linkeš# Copyright(c) 2023 PANTHEON.tech s.r.o. 3840b1e01SJuraj Linkeš 46ef07151SJuraj Linkeš"""SSH remote session.""" 56ef07151SJuraj Linkeš 6840b1e01SJuraj Linkešimport socket 7840b1e01SJuraj Linkešimport traceback 8*441c5fbfSTomáš Ďurovecfrom pathlib import Path, PurePath 9840b1e01SJuraj Linkeš 10282688eaSLuca Vizzarrofrom fabric import Connection # type: ignore[import-untyped] 11282688eaSLuca Vizzarrofrom invoke.exceptions import ( # type: ignore[import-untyped] 12840b1e01SJuraj Linkeš CommandTimedOut, 13840b1e01SJuraj Linkeš ThreadException, 14840b1e01SJuraj Linkeš UnexpectedExit, 15840b1e01SJuraj Linkeš) 16282688eaSLuca Vizzarrofrom paramiko.ssh_exception import ( # type: ignore[import-untyped] 17840b1e01SJuraj Linkeš AuthenticationException, 18840b1e01SJuraj Linkeš BadHostKeyException, 19840b1e01SJuraj Linkeš NoValidConnectionsError, 20840b1e01SJuraj Linkeš SSHException, 21840b1e01SJuraj Linkeš) 22840b1e01SJuraj Linkeš 23840b1e01SJuraj Linkešfrom framework.exception import SSHConnectionError, SSHSessionDeadError, SSHTimeoutError 24840b1e01SJuraj Linkeš 25840b1e01SJuraj Linkešfrom .remote_session import CommandResult, RemoteSession 26840b1e01SJuraj Linkeš 27840b1e01SJuraj Linkeš 28840b1e01SJuraj Linkešclass SSHSession(RemoteSession): 29840b1e01SJuraj Linkeš """A persistent SSH connection to a remote Node. 30840b1e01SJuraj Linkeš 316ef07151SJuraj Linkeš The connection is implemented with 326ef07151SJuraj Linkeš `the Fabric Python library <https://docs.fabfile.org/en/latest/>`_. 33840b1e01SJuraj Linkeš 34840b1e01SJuraj Linkeš Attributes: 35840b1e01SJuraj Linkeš session: The underlying Fabric SSH connection. 36840b1e01SJuraj Linkeš 37840b1e01SJuraj Linkeš Raises: 38840b1e01SJuraj Linkeš SSHConnectionError: The connection cannot be established. 39840b1e01SJuraj Linkeš """ 40840b1e01SJuraj Linkeš 41840b1e01SJuraj Linkeš session: Connection 42840b1e01SJuraj Linkeš 43840b1e01SJuraj Linkeš def _connect(self) -> None: 44840b1e01SJuraj Linkeš errors = [] 45840b1e01SJuraj Linkeš retry_attempts = 10 46840b1e01SJuraj Linkeš login_timeout = 20 if self.port else 10 47840b1e01SJuraj Linkeš for retry_attempt in range(retry_attempts): 48840b1e01SJuraj Linkeš try: 49840b1e01SJuraj Linkeš self.session = Connection( 50840b1e01SJuraj Linkeš self.ip, 51840b1e01SJuraj Linkeš user=self.username, 52840b1e01SJuraj Linkeš port=self.port, 53840b1e01SJuraj Linkeš connect_kwargs={"password": self.password}, 54840b1e01SJuraj Linkeš connect_timeout=login_timeout, 55840b1e01SJuraj Linkeš ) 56840b1e01SJuraj Linkeš self.session.open() 57840b1e01SJuraj Linkeš 58840b1e01SJuraj Linkeš except (ValueError, BadHostKeyException, AuthenticationException) as e: 59840b1e01SJuraj Linkeš self._logger.exception(e) 60840b1e01SJuraj Linkeš raise SSHConnectionError(self.hostname) from e 61840b1e01SJuraj Linkeš 62840b1e01SJuraj Linkeš except (NoValidConnectionsError, socket.error, SSHException) as e: 63840b1e01SJuraj Linkeš self._logger.debug(traceback.format_exc()) 64840b1e01SJuraj Linkeš self._logger.warning(e) 65840b1e01SJuraj Linkeš 66840b1e01SJuraj Linkeš error = repr(e) 67840b1e01SJuraj Linkeš if error not in errors: 68840b1e01SJuraj Linkeš errors.append(error) 69840b1e01SJuraj Linkeš 70840b1e01SJuraj Linkeš self._logger.info(f"Retrying connection: retry number {retry_attempt + 1}.") 71840b1e01SJuraj Linkeš 72840b1e01SJuraj Linkeš else: 73840b1e01SJuraj Linkeš break 74840b1e01SJuraj Linkeš else: 75840b1e01SJuraj Linkeš raise SSHConnectionError(self.hostname, errors) 76840b1e01SJuraj Linkeš 77840b1e01SJuraj Linkeš def _send_command(self, command: str, timeout: float, env: dict | None) -> CommandResult: 78840b1e01SJuraj Linkeš """Send a command and return the result of the execution. 79840b1e01SJuraj Linkeš 80840b1e01SJuraj Linkeš Args: 81840b1e01SJuraj Linkeš command: The command to execute. 826ef07151SJuraj Linkeš timeout: Wait at most this long in seconds for the command execution to complete. 83840b1e01SJuraj Linkeš env: Extra environment variables that will be used in command execution. 84840b1e01SJuraj Linkeš 85840b1e01SJuraj Linkeš Raises: 86840b1e01SJuraj Linkeš SSHSessionDeadError: The session died while executing the command. 87840b1e01SJuraj Linkeš SSHTimeoutError: The command execution timed out. 88840b1e01SJuraj Linkeš """ 89840b1e01SJuraj Linkeš try: 90840b1e01SJuraj Linkeš output = self.session.run(command, env=env, warn=True, hide=True, timeout=timeout) 91840b1e01SJuraj Linkeš 92840b1e01SJuraj Linkeš except (UnexpectedExit, ThreadException) as e: 93840b1e01SJuraj Linkeš self._logger.exception(e) 94840b1e01SJuraj Linkeš raise SSHSessionDeadError(self.hostname) from e 95840b1e01SJuraj Linkeš 96840b1e01SJuraj Linkeš except CommandTimedOut as e: 97840b1e01SJuraj Linkeš self._logger.exception(e) 98840b1e01SJuraj Linkeš raise SSHTimeoutError(command) from e 99840b1e01SJuraj Linkeš 100840b1e01SJuraj Linkeš return CommandResult(self.name, command, output.stdout, output.stderr, output.return_code) 101840b1e01SJuraj Linkeš 1026e3cdef8SJuraj Linkeš def is_alive(self) -> bool: 1036e3cdef8SJuraj Linkeš """Overrides :meth:`~.remote_session.RemoteSession.is_alive`.""" 1046e3cdef8SJuraj Linkeš return self.session.is_connected 1056e3cdef8SJuraj Linkeš 106*441c5fbfSTomáš Ďurovec def copy_from(self, source_file: str | PurePath, destination_dir: str | Path) -> None: 1076ef07151SJuraj Linkeš """Overrides :meth:`~.remote_session.RemoteSession.copy_from`.""" 108*441c5fbfSTomáš Ďurovec self.session.get(str(source_file), str(destination_dir)) 109840b1e01SJuraj Linkeš 110*441c5fbfSTomáš Ďurovec def copy_to(self, source_file: str | Path, destination_dir: str | PurePath) -> None: 1116ef07151SJuraj Linkeš """Overrides :meth:`~.remote_session.RemoteSession.copy_to`.""" 112*441c5fbfSTomáš Ďurovec self.session.put(str(source_file), str(destination_dir)) 113840b1e01SJuraj Linkeš 1146e3cdef8SJuraj Linkeš def close(self) -> None: 1156e3cdef8SJuraj Linkeš """Overrides :meth:`~.remote_session.RemoteSession.close`.""" 116840b1e01SJuraj Linkeš self.session.close() 117