tobiko/tobiko/shell/sh/_ssh.py
Eduardo Olivares fe3e5cce56 Add timeout to all open_session calls
The default paramiko timeout value when a session/channel is going to be
opened is 3600 seconds:
https://github.com/paramiko/paramiko/blob/2.10.4/paramiko/transport.py#L1009

Sometimes tobiko tests get stuck when they call the paramiko open_session
method and pytest times out (its test timeout value is 3600 too)
This happens especially when tobiko tries to connect to a machine that
has just been rebooted

This patch adds a timeout of 30 seconds to any open_session calls that
did not include any value before

After removing the open session timeout of 3600 seconds, some ubuntu
tests failed on upstream CI because it takes more than the default
ubuntu connection timeout (600 seconds) to start an ubuntu instance
sometimes - that value has been changed to 1500 seconds

Additionally, an incoherent behavior has been fixed from
SSHShellProcessFixture.create_process method:
- this method includes a retry loop, and it calls ssh_client.connect
  within it
- the ssh_client.connect method includes another retry loop
- it may happen that the timeout and reattempts of the inner loop are
  higher than the timeout and reattempts of the outer loop, which should not
  happen
This has been fixed in this patch as well

Change-Id: I4133c559c3299fe7965cafae108146d2661052fb
2022-12-22 14:50:01 +00:00

260 lines
9.7 KiB
Python

# Copyright (c) 2019 Red Hat, Inc.
#
# All Rights Reserved.
#
# Licensed under the Apache License, Version 2.0 (the "License"); you may
# not use this file except in compliance with the License. You may obtain
# a copy of the License at
#
# http://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS, WITHOUT
# WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. See the
# License for the specific language governing permissions and limitations
# under the License.
from __future__ import absolute_import
import shlex
from oslo_log import log
import paramiko
from paramiko import channel
import tobiko
from tobiko.shell.sh import _exception
from tobiko.shell.sh import _execute
from tobiko.shell.sh import _io
from tobiko.shell.sh import _local
from tobiko.shell.sh import _process
from tobiko.shell import ssh
import typing # noqa
LOG = log.getLogger(__name__)
def ssh_execute(ssh_client, command, environment=None,
timeout: tobiko.Seconds = None, stdin=None, stdout=None,
stderr=None, shell=None, expect_exit_status=0, **kwargs):
"""Execute command on remote host using SSH client"""
process = ssh_process(command=command,
environment=environment,
timeout=timeout,
shell=shell,
stdin=stdin,
stdout=stdout,
stderr=stderr,
ssh_client=ssh_client,
**kwargs)
return _execute.execute_process(process=process,
stdin=stdin,
expect_exit_status=expect_exit_status)
def ssh_process(command, environment=None, current_dir=None,
timeout: tobiko.Seconds = None, shell=None, stdin=None,
stdout=None, stderr=None, ssh_client=None, sudo=None,
network_namespace=None):
if ssh_client is None:
ssh_client = ssh.ssh_proxy_client()
if ssh_client:
return SSHShellProcessFixture(
command=command, environment=environment, current_dir=current_dir,
timeout=timeout, shell=shell, stdin=stdin, stdout=stdout,
stderr=stderr, ssh_client=ssh_client, sudo=sudo,
network_namespace=network_namespace)
else:
return _local.local_process(
command=command, environment=environment, current_dir=current_dir,
timeout=timeout, shell=shell, stdin=stdin, stdout=stdout,
stderr=stderr, sudo=sudo, network_namespace=network_namespace)
class SSHShellProcessParameters(_process.ShellProcessParameters):
ssh_client = None
open_session_timeout = 30.0
class SSHShellProcessFixture(_process.ShellProcessFixture):
def init_parameters(self, **kwargs) -> SSHShellProcessParameters:
return SSHShellProcessParameters(**kwargs)
def create_process(self):
"""Execute command on a remote host using SSH client"""
command = str(self.command)
ssh_client = self.ssh_client
parameters = self.parameters
tobiko.check_valid_type(ssh_client, ssh.SSHClientFixture)
tobiko.check_valid_type(parameters, SSHShellProcessParameters)
environment = parameters.environment
current_dir = parameters.current_dir
if (hasattr(ssh_client, 'connect_parameters') and
ssh_client.connect_parameters is not None):
ssh_client_timeout = ssh_client.connect_parameters.get(
'connection_timeout')
ssh_client_attempts = ssh_client.connect_parameters.get(
'connection_attempts')
else:
ssh_client_timeout = None
ssh_client_attempts = None
# use ssh_client timeout and attempts values if they are higher than
# self (SSHShellProcessFixture) values because the values from the
# outer retry loop should be equal or greater than those from the inner
# retry loop
process_retry_timeout = (
ssh_client_timeout if (
ssh_client_timeout and (
self.parameters.timeout is None or
ssh_client_timeout > self.parameters.timeout))
else self.parameters.timeout)
process_retry_attempts = (
ssh_client_attempts if (
ssh_client_attempts and (
self.parameters.retry_count is None or
ssh_client_attempts > self.parameters.retry_count))
else self.parameters.retry_count)
for attempt in tobiko.retry(
timeout=process_retry_timeout,
default_count=process_retry_attempts,
default_interval=self.parameters.retry_interval,
default_timeout=self.parameters.retry_timeout):
timeout = attempt.time_left
details = (f"command='{command}', "
f"current_dir='{current_dir}', "
f"login={ssh_client.login}, "
f"timeout={timeout}, "
f"attempt={attempt}, "
f"environment={environment}")
LOG.debug(f"Create remote process... ({details})")
try:
client = ssh_client.connect()
process = client.get_transport().open_session(
timeout=self.open_session_timeout)
if environment:
variables = " ".join(
f"{name}={shlex.quote(value)}"
for name, value in self.environment.items())
command = variables + " " + command
if current_dir is not None:
command = f"cd {current_dir} && {command}"
process.exec_command(command)
LOG.debug(f"Remote process created. ({details})")
return process
except Exception:
# Before doing anything else cleanup SSH connection
ssh_client.close()
LOG.debug(f"Error creating remote process. ({details})",
exc_info=1)
try:
attempt.check_limits()
except tobiko.RetryTimeLimitError as ex:
LOG.debug(f"Timed out creating remote process. ({details})")
raise _exception.ShellTimeoutExpired(command=command,
stdin=None,
stdout=None,
stderr=None,
timeout=timeout) from ex
def setup_stdin(self):
self.stdin = _io.ShellStdin(
delegate=StdinSSHChannelFile(self.process, 'wb'),
buffer_size=self.parameters.buffer_size)
def setup_stdout(self):
self.stdout = _io.ShellStdout(
delegate=StdoutSSHChannelFile(self.process, 'rb'),
buffer_size=self.parameters.buffer_size)
def setup_stderr(self):
self.stderr = _io.ShellStderr(
delegate=StderrSSHChannelFile(self.process, 'rb'),
buffer_size=self.parameters.buffer_size)
def poll_exit_status(self):
exit_status = getattr(self.process, 'exit_status', None)
if exit_status and exit_status < 0:
exit_status = None
return exit_status
def _get_exit_status(self, timeout: tobiko.Seconds = None):
process = self.process
if not process.exit_status_ready():
# Workaround for Paramiko timeout problem
# CirrOS instances could close SSH channel without sending process
# exit status
if timeout is None:
timeout = 120.
else:
timeout = min(timeout, 120.0)
LOG.debug(f"Waiting for command '{self.command}' exit status "
f"(timeout={timeout})")
# recv_exit_status method doesn't accept timeout parameter
# therefore here we wait for next channel event expecting it is
# actually the exit status
# TODO (fressi): we could use an itimer to set a timeout for
# recv_exit_status
if not process.status_event.wait(timeout=timeout):
LOG.error("Timed out before status event being set "
f"(timeout={timeout})")
if process.exit_status >= 0:
return process.exit_status
else:
return None
def kill(self, sudo=False):
process = self.process
LOG.debug('Killing remote process: %r', self.command)
try:
process.close()
except Exception:
LOG.exception("Failed killing remote process: %r",
self.command)
class SSHChannelFile(channel.ChannelFile):
def fileno(self):
return self.channel.fileno()
class StdinSSHChannelFile(SSHChannelFile):
def close(self):
super(StdinSSHChannelFile, self).close()
self.channel.shutdown_write()
@property
def write_ready(self):
return self.channel.send_ready()
class StdoutSSHChannelFile(SSHChannelFile):
def fileno(self):
return self.channel.fileno()
def close(self):
super(StdoutSSHChannelFile, self).close()
self.channel.shutdown_read()
@property
def read_ready(self):
return self.channel.recv_ready()
class StderrSSHChannelFile(SSHChannelFile, paramiko.channel.ChannelStderrFile):
def fileno(self):
return self.channel.fileno()
@property
def read_ready(self):
return self.channel.recv_stderr_ready()